mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-07 16:41:00 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9499b228a | ||
|
|
3cae20a10c | ||
|
|
7313fa08a8 | ||
|
|
1174a6dbaa | ||
|
|
c37218b86d | ||
|
|
efa91e7a72 | ||
|
|
e6c8eaf23c | ||
|
|
832e28aaca | ||
|
|
404dd544ab | ||
|
|
87d37a8be4 | ||
|
|
561ac12304 | ||
|
|
450815db96 | ||
|
|
eea81e7fd6 | ||
|
|
dffcfb77f3 | ||
|
|
b4ff2f68f8 | ||
|
|
1c7cc36472 | ||
|
|
b42d991157 | ||
|
|
f129b732ae |
@@ -175,6 +175,7 @@ BentoPDF offers a comprehensive suite of tools to handle all your PDF needs.
|
|||||||
| **Create Fillable Forms** | Create professional fillable PDF forms with text fields, checkboxes, dropdowns, radio buttons, signatures, and more. Fully compliant with PDF standards for compatibility with all PDF viewers. |
|
| **Create Fillable Forms** | Create professional fillable PDF forms with text fields, checkboxes, dropdowns, radio buttons, signatures, and more. Fully compliant with PDF standards for compatibility with all PDF viewers. |
|
||||||
| **PDF Form Filler** | Fill in forms directly in the browser. Also supports XFA forms. |
|
| **PDF Form Filler** | Fill in forms directly in the browser. Also supports XFA forms. |
|
||||||
| **Add Page Numbers** | Easily add page numbers with customizable formatting. |
|
| **Add Page Numbers** | Easily add page numbers with customizable formatting. |
|
||||||
|
| **Bates Numbering** | Add sequential Bates numbers across one or more PDF files. |
|
||||||
| **Add Watermark** | Add text or image watermarks to protect your documents. |
|
| **Add Watermark** | Add text or image watermarks to protect your documents. |
|
||||||
| **Header & Footer** | Add customizable headers and footers. |
|
| **Header & Footer** | Add customizable headers and footers. |
|
||||||
| **Crop PDF** | Crop specific pages or the entire document. |
|
| **Crop PDF** | Crop specific pages or the entire document. |
|
||||||
@@ -824,7 +825,7 @@ For detailed security configuration, see [SECURITY.md](SECURITY.md).
|
|||||||
|
|
||||||
### Digital Signature CORS Proxy (Required)
|
### Digital Signature CORS Proxy (Required)
|
||||||
|
|
||||||
The **Digital Signature** tool uses a signing library that may need to fetch certificate chain data from certificate authority provider. Since many certificate servers don't include CORS headers, a proxy is required for this feature to work in the browser.
|
The **Digital Signature** tool uses a signing library that may need to fetch certificate chain data from certificate authority providers. Since many certificate servers don't include CORS headers (and often serve over HTTP, which is blocked by browsers on HTTPS sites), a proxy is required for this feature to work.
|
||||||
|
|
||||||
**When is the proxy needed?**
|
**When is the proxy needed?**
|
||||||
|
|
||||||
@@ -846,30 +847,48 @@ The **Digital Signature** tool uses a signing library that may need to fetch cer
|
|||||||
npx wrangler login
|
npx wrangler login
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Deploy the worker:**
|
3. **Update allowed origins** — open `cors-proxy-worker.js` and change `ALLOWED_ORIGINS` to your domain:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ALLOWED_ORIGINS = [
|
||||||
|
'https://your-domain.com',
|
||||||
|
'https://www.your-domain.com',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Without this step, the proxy will reject all requests from your site with a 403 error. The default only allows `bentopdf.com`.
|
||||||
|
|
||||||
|
4. **Deploy the worker:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx wrangler deploy
|
npx wrangler deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Note your worker URL** (e.g., `https://bentopdf-cors-proxy.your-subdomain.workers.dev`)
|
5. **Note your worker URL** (e.g., `https://bentopdf-cors-proxy.your-subdomain.workers.dev`)
|
||||||
|
|
||||||
5. **Set the environment variable when building:**
|
6. **Set the environment variable when building:**
|
||||||
```bash
|
```bash
|
||||||
VITE_CORS_PROXY_URL=https://your-worker-url.workers.dev npm run build
|
VITE_CORS_PROXY_URL=https://your-worker-url.workers.dev npm run build
|
||||||
```
|
```
|
||||||
|
Or with Docker:
|
||||||
|
```bash
|
||||||
|
docker build --build-arg VITE_CORS_PROXY_URL="https://your-worker-url.workers.dev" -t your-bentopdf .
|
||||||
|
```
|
||||||
|
|
||||||
#### Production Security Features
|
#### Production Security Features
|
||||||
|
|
||||||
The CORS proxy includes several security measures:
|
The CORS proxy includes several security measures:
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
| ----------------------- | ------------------------------------------------------------------------- |
|
| ----------------------- | -------------------------------------------------------------------------------------- |
|
||||||
| **URL Restrictions** | Only allows certificate URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`) |
|
| **Origin Validation** | Only allows requests from domains listed in `ALLOWED_ORIGINS` |
|
||||||
| **Private IP Blocking** | Blocks requests to localhost, 10.x, 192.168.x, 172.16-31.x |
|
| **URL Restrictions** | Only allows certificate URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`, `/crl`) |
|
||||||
| **File Size Limit** | Rejects files larger than 10MB |
|
| **Private IP Blocking** | Blocks IPv4/IPv6 private ranges, link-local, loopback, decimal IPs, and cloud metadata |
|
||||||
| **Rate Limiting** | 60 requests per IP per minute (requires KV) |
|
| **Content-Type Safety** | Only returns safe certificate MIME types, blocks upstream content-type injection |
|
||||||
| **HMAC Signatures** | Optional client-side signing (limited protection) |
|
| **File Size Limit** | Streams response with 10MB limit, aborts mid-download if exceeded |
|
||||||
|
| **Rate Limiting** | 60 requests per IP per minute (requires KV) |
|
||||||
|
| **HMAC Signatures** | Optional client-side signing (deters casual abuse) |
|
||||||
|
|
||||||
#### Enabling Rate Limiting (Recommended)
|
#### Enabling Rate Limiting (Recommended)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -7,10 +7,10 @@ type: application
|
|||||||
# This is the chart version. This version number should be incremented each time you make changes
|
# 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.
|
# to the chart and its templates, including the app version.
|
||||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||||
version: 1.0.0
|
version: 1.0.2
|
||||||
|
|
||||||
# This is the version number of the application being deployed. This version number should be
|
# 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
|
# 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.
|
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||||
# It is recommended to use it with quotes.
|
# It is recommended to use it with quotes.
|
||||||
appVersion: "2.1.0"
|
appVersion: '2.2.1'
|
||||||
|
|||||||
+2
-2
@@ -30,7 +30,7 @@ ingress:
|
|||||||
```yaml
|
```yaml
|
||||||
gateway:
|
gateway:
|
||||||
enabled: true
|
enabled: true
|
||||||
gatewayClassName: "cloudflare" # or your gateway class
|
gatewayClassName: 'cloudflare' # or your gateway class
|
||||||
|
|
||||||
httpRoute:
|
httpRoute:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -74,7 +74,7 @@ httpRoute:
|
|||||||
```yaml
|
```yaml
|
||||||
env:
|
env:
|
||||||
- name: DISABLE_IPV6
|
- name: DISABLE_IPV6
|
||||||
value: "true"
|
value: 'true'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Publish this chart to GHCR (OCI) for testing/deploying
|
## Publish this chart to GHCR (OCI) for testing/deploying
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{{- if .Values.httpRoute.enabled }}
|
{{- if .Values.httpRoute.enabled }}
|
||||||
{{- $defaultGatewayName := .Values.gateway.name -}}
|
{{- $defaultGatewayName := .Values.gateway.name -}}
|
||||||
{{- if not $defaultGatewayName -}}
|
{{- if not $defaultGatewayName -}}
|
||||||
{{- $defaultGatewayName = printf "%s-gateway" (include "chart.fullname" .) -}}
|
{{- $defaultGatewayName = printf "%s-gateway" (include "bentopdf.name" .) -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- $defaultGatewayNs := .Release.Namespace -}}
|
{{- $defaultGatewayNs := .Release.Namespace -}}
|
||||||
{{- if .Values.gateway.enabled -}}
|
{{- if .Values.gateway.enabled -}}
|
||||||
@@ -31,16 +31,16 @@
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- else if contains "NodePort" .Values.service.type }}
|
{{- else if contains "NodePort" .Values.service.type }}
|
||||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "chart.fullname" . }})
|
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "bentopdf.name" . }})
|
||||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||||
echo http://$NODE_IP:$NODE_PORT
|
echo http://$NODE_IP:$NODE_PORT
|
||||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||||
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||||
You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "chart.fullname" . }}'
|
You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "bentopdf.name" . }}'
|
||||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "chart.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "bentopdf.name" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||||
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
||||||
{{- else if contains "ClusterIP" .Values.service.type }}
|
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "chart.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "bentopdf.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||||
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||||
echo "Visit http://127.0.0.1:8080 to use your application"
|
echo "Visit http://127.0.0.1:8080 to use your application"
|
||||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
||||||
|
|||||||
@@ -1,41 +1,23 @@
|
|||||||
{{/*
|
{{/*
|
||||||
Expand the name of the chart.
|
Expand the name of the bentopdf
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "chart.name" -}}
|
{{- define "bentopdf.name" -}}
|
||||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
|
||||||
Create a default fully qualified app name.
|
|
||||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
|
||||||
If release name contains chart name it will be used as a full name.
|
|
||||||
*/}}
|
|
||||||
{{- define "chart.fullname" -}}
|
|
||||||
{{- if .Values.fullnameOverride }}
|
|
||||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- else }}
|
|
||||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
|
||||||
{{- if contains $name .Release.Name }}
|
|
||||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- else }}
|
|
||||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Create chart name and version as used by the chart label.
|
Create chart name and version as used by the chart label.
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "chart.chart" -}}
|
{{- define "bentopdf.chart" -}}
|
||||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Common labels
|
Common labels
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "chart.labels" -}}
|
{{- define "bentopdf.labels" -}}
|
||||||
helm.sh/chart: {{ include "chart.chart" . }}
|
helm.sh/chart: {{ include "bentopdf.chart" . }}
|
||||||
{{ include "chart.selectorLabels" . }}
|
{{ include "bentopdf.selectorLabels" . }}
|
||||||
{{- if .Chart.AppVersion }}
|
{{- if .Chart.AppVersion }}
|
||||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -45,8 +27,7 @@ app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|||||||
{{/*
|
{{/*
|
||||||
Selector labels
|
Selector labels
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "chart.selectorLabels" -}}
|
{{- define "bentopdf.selectorLabels" -}}
|
||||||
app.kubernetes.io/name: {{ include "chart.name" . }}
|
app.kubernetes.io/name: {{ include "bentopdf.name" . }}
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "chart.fullname" . }}
|
name: {{ include "bentopdf.name" . }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "chart.labels" . | nindent 4 }}
|
{{- include "bentopdf.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
replicas: {{ .Values.replicaCount }}
|
replicas: {{ .Values.replicaCount }}
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
{{- include "chart.selectorLabels" . | nindent 6 }}
|
{{- include "bentopdf.selectorLabels" . | nindent 6 }}
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
{{- with .Values.podAnnotations }}
|
{{- with .Values.podAnnotations }}
|
||||||
@@ -16,7 +17,7 @@ spec:
|
|||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "chart.labels" . | nindent 8 }}
|
{{- include "bentopdf.labels" . | nindent 8 }}
|
||||||
{{- with .Values.podLabels }}
|
{{- with .Values.podLabels }}
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{- if .Values.gateway.enabled -}}
|
{{- if .Values.gateway.enabled -}}
|
||||||
{{- $gatewayName := .Values.gateway.name -}}
|
{{- $gatewayName := .Values.gateway.name -}}
|
||||||
{{- if not $gatewayName -}}
|
{{- if not $gatewayName -}}
|
||||||
{{- $gatewayName = printf "%s-gateway" (include "chart.fullname" .) -}}
|
{{- $gatewayName = printf "%s-gateway" (include "bentopdf.name" .) -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- $gatewayNs := default .Release.Namespace .Values.gateway.namespace -}}
|
{{- $gatewayNs := default .Release.Namespace .Values.gateway.namespace -}}
|
||||||
apiVersion: gateway.networking.k8s.io/v1
|
apiVersion: gateway.networking.k8s.io/v1
|
||||||
@@ -10,7 +10,7 @@ metadata:
|
|||||||
name: {{ $gatewayName }}
|
name: {{ $gatewayName }}
|
||||||
namespace: {{ $gatewayNs }}
|
namespace: {{ $gatewayNs }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "chart.labels" . | nindent 4 }}
|
{{- include "bentopdf.labels" . | nindent 4 }}
|
||||||
{{- with .Values.gateway.annotations }}
|
{{- with .Values.gateway.annotations }}
|
||||||
annotations:
|
annotations:
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
|
|||||||
@@ -1,20 +1,11 @@
|
|||||||
{{- if .Values.httpRoute.enabled -}}
|
{{- if .Values.httpRoute.enabled -}}
|
||||||
{{- $fullName := include "chart.fullname" . -}}
|
|
||||||
{{- $svcPort := .Values.service.port -}}
|
|
||||||
{{- $defaultGatewayName := .Values.gateway.name -}}
|
|
||||||
{{- if not $defaultGatewayName -}}
|
|
||||||
{{- $defaultGatewayName = printf "%s-gateway" $fullName -}}
|
|
||||||
{{- end -}}
|
|
||||||
{{- $defaultGatewayNs := .Release.Namespace -}}
|
|
||||||
{{- if .Values.gateway.enabled -}}
|
|
||||||
{{- $defaultGatewayNs = default .Release.Namespace .Values.gateway.namespace -}}
|
|
||||||
{{- end -}}
|
|
||||||
apiVersion: gateway.networking.k8s.io/v1
|
apiVersion: gateway.networking.k8s.io/v1
|
||||||
kind: HTTPRoute
|
kind: HTTPRoute
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ $fullName }}
|
name: {{ include "bentopdf.name" . }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "chart.labels" . | nindent 4 }}
|
{{- include "bentopdf.labels" . | nindent 4 }}
|
||||||
{{- with .Values.httpRoute.annotations }}
|
{{- with .Values.httpRoute.annotations }}
|
||||||
annotations:
|
annotations:
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
@@ -23,14 +14,9 @@ spec:
|
|||||||
parentRefs:
|
parentRefs:
|
||||||
{{- if .Values.httpRoute.parentRefs }}
|
{{- if .Values.httpRoute.parentRefs }}
|
||||||
{{- range $ref := .Values.httpRoute.parentRefs }}
|
{{- range $ref := .Values.httpRoute.parentRefs }}
|
||||||
- name: {{ default $defaultGatewayName $ref.name | quote }}
|
- name: {{ $.Values.gateway.name | default $ref.name | quote }}
|
||||||
{{- $refNs := $ref.namespace | default $defaultGatewayNs -}}
|
namespace: {{ $ref.namespace | default (default $.Release.Namespace $.Values.gateway.namespace) | quote }}
|
||||||
namespace: {{ $refNs | quote }}
|
sectionName: {{ $ref.sectionName | default "http" | quote }}
|
||||||
{{- with $ref.sectionName }}
|
|
||||||
sectionName: {{ . | quote }}
|
|
||||||
{{- else }}
|
|
||||||
sectionName: "http"
|
|
||||||
{{- end }}
|
|
||||||
{{- with $ref.kind }}
|
{{- with $ref.kind }}
|
||||||
kind: {{ . | quote }}
|
kind: {{ . | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -40,8 +26,8 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- else }}
|
{{- else }}
|
||||||
# Default parentRef when parentRefs is empty or not set
|
# Default parentRef when parentRefs is empty or not set
|
||||||
- name: {{ $defaultGatewayName | quote }}
|
- name: {{ .Values.gateway.name | default (printf "%s-gateway" (include "bentopdf.name" $)) | quote }}
|
||||||
namespace: {{ $defaultGatewayNs | quote }}
|
namespace: {{ .Values.gateway.namespace | default .Release.Namespace | quote }}
|
||||||
sectionName: "http"
|
sectionName: "http"
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- with .Values.httpRoute.hostnames }}
|
{{- with .Values.httpRoute.hostnames }}
|
||||||
@@ -52,15 +38,15 @@ spec:
|
|||||||
{{- range .Values.httpRoute.rules }}
|
{{- range .Values.httpRoute.rules }}
|
||||||
- {{- with .matches }}
|
- {{- with .matches }}
|
||||||
matches:
|
matches:
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- with .filters }}
|
{{- with .filters }}
|
||||||
filters:
|
filters:
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
backendRefs:
|
backendRefs:
|
||||||
- name: {{ $fullName }}
|
- name: {{ include "bentopdf.name" $ }}
|
||||||
port: {{ $svcPort }}
|
port: {{ $.Values.service.port }}
|
||||||
weight: 1
|
weight: 1
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "chart.fullname" . }}
|
name: {{ include "bentopdf.name" . }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "chart.labels" . | nindent 4 }}
|
{{- include "bentopdf.labels" . | nindent 4 }}
|
||||||
{{- with .Values.ingress.annotations }}
|
{{- with .Values.ingress.annotations }}
|
||||||
annotations:
|
annotations:
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
@@ -35,7 +36,7 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: {{ include "chart.fullname" $ }}
|
name: {{ include "bentopdf.name" $ }}
|
||||||
port:
|
port:
|
||||||
number: {{ $.Values.service.port }}
|
number: {{ $.Values.service.port }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "chart.fullname" . }}
|
name: {{ include "bentopdf.name" . }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "chart.labels" . | nindent 4 }}
|
{{- include "bentopdf.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
type: {{ .Values.service.type }}
|
type: {{ .Values.service.type }}
|
||||||
ports:
|
ports:
|
||||||
@@ -12,4 +13,4 @@ spec:
|
|||||||
protocol: TCP
|
protocol: TCP
|
||||||
name: http
|
name: http
|
||||||
selector:
|
selector:
|
||||||
{{- include "chart.selectorLabels" . | nindent 4 }}
|
{{- include "bentopdf.selectorLabels" . | nindent 4 }}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
# The service account was removed, as it's not necessary for a chart like this.
|
|
||||||
#
|
|
||||||
# There are no calls to the Kubernetes API - routing between services.
|
|
||||||
# It's a simple nginx static website deployment only.
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Pod
|
kind: Pod
|
||||||
metadata:
|
metadata:
|
||||||
name: "{{ include "chart.fullname" . }}-test-connection"
|
name: "{{ include "bentopdf.name" . }}-test-connection"
|
||||||
labels:
|
labels:
|
||||||
{{- include "chart.labels" . | nindent 4 }}
|
{{- include "bentopdf.labels" . | nindent 4 }}
|
||||||
annotations:
|
annotations:
|
||||||
"helm.sh/hook": test
|
"helm.sh/hook": test
|
||||||
spec:
|
spec:
|
||||||
@@ -11,5 +11,5 @@ spec:
|
|||||||
- name: wget
|
- name: wget
|
||||||
image: busybox
|
image: busybox
|
||||||
command: ['wget']
|
command: ['wget']
|
||||||
args: ['-qO-', 'http://{{ include "chart.fullname" . }}:{{ .Values.service.port }}/']
|
args: ['-qO-', 'http://{{ include "bentopdf.name" . }}:{{ .Values.service.port }}/']
|
||||||
restartPolicy: Never
|
restartPolicy: Never
|
||||||
|
|||||||
+6
-7
@@ -5,11 +5,10 @@ image:
|
|||||||
# Image built from this repo's `Dockerfile` (nginx serving static frontend).
|
# Image built from this repo's `Dockerfile` (nginx serving static frontend).
|
||||||
repository: ghcr.io/alam00000/bentopdf-simple
|
repository: ghcr.io/alam00000/bentopdf-simple
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
tag: ""
|
tag: ''
|
||||||
|
|
||||||
imagePullSecrets: []
|
imagePullSecrets: []
|
||||||
nameOverride: ""
|
nameOverride: ''
|
||||||
fullnameOverride: ""
|
|
||||||
|
|
||||||
podAnnotations: {}
|
podAnnotations: {}
|
||||||
podLabels: {}
|
podLabels: {}
|
||||||
@@ -25,7 +24,7 @@ env: []
|
|||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: false
|
enabled: false
|
||||||
className: ""
|
className: ''
|
||||||
annotations: {}
|
annotations: {}
|
||||||
hosts:
|
hosts:
|
||||||
- host: bentopdf.local
|
- host: bentopdf.local
|
||||||
@@ -37,10 +36,10 @@ ingress:
|
|||||||
# Gateway API (optional)
|
# Gateway API (optional)
|
||||||
gateway:
|
gateway:
|
||||||
enabled: false
|
enabled: false
|
||||||
name: "" # default: release fullname + "-gateway" suffix
|
name: '' # default: release name + "-gateway" suffix
|
||||||
namespace: "" # to override the namespace
|
namespace: '' # to override the namespace
|
||||||
annotations: {}
|
annotations: {}
|
||||||
gatewayClassName: "" # required when enabled=true
|
gatewayClassName: '' # required when enabled=true
|
||||||
listeners:
|
listeners:
|
||||||
- name: http
|
- name: http
|
||||||
protocol: HTTP
|
protocol: HTTP
|
||||||
|
|||||||
+392
-288
@@ -12,28 +12,27 @@
|
|||||||
* - PROXY_SECRET: Shared secret for HMAC signature verification
|
* - PROXY_SECRET: Shared secret for HMAC signature verification
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ALLOWED_PATTERNS = [
|
const ALLOWED_PATH_PATTERNS = [
|
||||||
/\.crt$/i,
|
/\.crt$/i,
|
||||||
/\.cer$/i,
|
/\.cer$/i,
|
||||||
/\.pem$/i,
|
/\.pem$/i,
|
||||||
/\/certs\//i,
|
/\/certs\//i,
|
||||||
/\/ocsp/i,
|
/\/ocsp/i,
|
||||||
/\/crl/i,
|
/\/crl/i,
|
||||||
/caIssuers/i,
|
/caIssuers/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALLOWED_ORIGINS = [
|
const ALLOWED_ORIGINS = ['https://www.bentopdf.com', 'https://bentopdf.com'];
|
||||||
'https://www.bentopdf.com',
|
|
||||||
'https://bentopdf.com',
|
|
||||||
];
|
|
||||||
|
|
||||||
const BLOCKED_DOMAINS = [
|
const SAFE_CONTENT_TYPES = [
|
||||||
'localhost',
|
'application/x-x509-ca-cert',
|
||||||
'127.0.0.1',
|
'application/pkix-cert',
|
||||||
'0.0.0.0',
|
'application/x-pem-file',
|
||||||
|
'application/pkcs7-mime',
|
||||||
|
'application/octet-stream',
|
||||||
|
'text/plain',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1000;
|
const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
const RATE_LIMIT_MAX_REQUESTS = 60;
|
const RATE_LIMIT_MAX_REQUESTS = 60;
|
||||||
@@ -42,310 +41,415 @@ const RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
|||||||
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
async function verifySignature(message, signature, secret) {
|
async function verifySignature(message, signature, secret) {
|
||||||
try {
|
try {
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const key = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
encoder.encode(secret),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['verify']
|
|
||||||
);
|
|
||||||
|
|
||||||
const signatureBytes = new Uint8Array(
|
|
||||||
signature.match(/.{1,2}/g).map(byte => parseInt(byte, 16))
|
|
||||||
);
|
|
||||||
|
|
||||||
return await crypto.subtle.verify(
|
|
||||||
'HMAC',
|
|
||||||
key,
|
|
||||||
signatureBytes,
|
|
||||||
encoder.encode(message)
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Signature verification error:', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateSignature(message, secret) {
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
encoder.encode(secret),
|
encoder.encode(secret),
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
false,
|
false,
|
||||||
['sign']
|
['verify']
|
||||||
);
|
);
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign(
|
const signatureBytes = new Uint8Array(
|
||||||
'HMAC',
|
signature.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))
|
||||||
key,
|
|
||||||
encoder.encode(message)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return Array.from(new Uint8Array(signature))
|
return await crypto.subtle.verify(
|
||||||
.map(b => b.toString(16).padStart(2, '0'))
|
'HMAC',
|
||||||
.join('');
|
key,
|
||||||
|
signatureBytes,
|
||||||
|
encoder.encode(message)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Signature verification error:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAllowedOrigin(origin) {
|
function isAllowedOrigin(origin) {
|
||||||
if (!origin) return false;
|
if (!origin) return false;
|
||||||
return ALLOWED_ORIGINS.some(allowed => origin.startsWith(allowed.replace(/\/$/, '')));
|
return ALLOWED_ORIGINS.includes(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateOrReservedHost(hostname) {
|
||||||
|
if (
|
||||||
|
/^10\./.test(hostname) ||
|
||||||
|
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
|
||||||
|
/^192\.168\./.test(hostname) ||
|
||||||
|
/^169\.254\./.test(hostname) || // link-local (cloud metadata)
|
||||||
|
/^100\.(6[4-9]|[7-9]\d|1[0-1]\d|12[0-7])\./.test(hostname) || // CGNAT
|
||||||
|
/^127\./.test(hostname) ||
|
||||||
|
/^0\./.test(hostname)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+$/.test(hostname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lower = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||||
|
if (
|
||||||
|
lower === '::1' ||
|
||||||
|
lower.startsWith('::ffff:') ||
|
||||||
|
lower.startsWith('fe80') ||
|
||||||
|
lower.startsWith('fc') ||
|
||||||
|
lower.startsWith('fd') ||
|
||||||
|
lower.startsWith('ff')
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockedNames = [
|
||||||
|
'localhost',
|
||||||
|
'localhost.localdomain',
|
||||||
|
'0.0.0.0',
|
||||||
|
'[::1]',
|
||||||
|
];
|
||||||
|
if (blockedNames.includes(hostname.toLowerCase())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidCertificateUrl(urlString) {
|
function isValidCertificateUrl(urlString) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(urlString);
|
const url = new URL(urlString);
|
||||||
|
|
||||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
if (BLOCKED_DOMAINS.some(domain => url.hostname.includes(domain))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostname = url.hostname;
|
|
||||||
if (/^10\./.test(hostname) ||
|
|
||||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
|
|
||||||
/^192\.168\./.test(hostname)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ALLOWED_PATTERNS.some(pattern => pattern.test(urlString));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPrivateOrReservedHost(url.hostname)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ALLOWED_PATH_PATTERNS.some((pattern) => pattern.test(url.pathname));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafeContentType(upstreamContentType) {
|
||||||
|
if (!upstreamContentType) return 'application/octet-stream';
|
||||||
|
const match = SAFE_CONTENT_TYPES.find((ct) =>
|
||||||
|
upstreamContentType.startsWith(ct)
|
||||||
|
);
|
||||||
|
return match || 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
function corsHeaders(origin) {
|
function corsHeaders(origin) {
|
||||||
return {
|
return {
|
||||||
'Access-Control-Allow-Origin': origin || '*',
|
'Access-Control-Allow-Origin': origin,
|
||||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
'Access-Control-Allow-Headers': 'Content-Type',
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
'Access-Control-Max-Age': '86400',
|
'Access-Control-Max-Age': '86400',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOptions(request) {
|
function handleOptions(request) {
|
||||||
const origin = request.headers.get('Origin');
|
const origin = request.headers.get('Origin');
|
||||||
return new Response(null, {
|
if (!isAllowedOrigin(origin)) {
|
||||||
status: 204,
|
return new Response(null, { status: 403 });
|
||||||
headers: corsHeaders(origin),
|
}
|
||||||
});
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: corsHeaders(origin),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(request, env, ctx) {
|
async fetch(request, env, ctx) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const origin = request.headers.get('Origin');
|
const origin = request.headers.get('Origin');
|
||||||
|
|
||||||
if (request.method === 'OPTIONS') {
|
if (request.method === 'OPTIONS') {
|
||||||
return handleOptions(request);
|
return handleOptions(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: If you are selfhosting this proxy, you can remove this check, or can set it to only accept requests from your own domain
|
||||||
|
if (!isAllowedOrigin(origin)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'This proxy only accepts requests from allowed origins',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: If you are selfhosting this proxy, you can remove this check, or can set it to only accept requests from your own domain
|
if (request.method !== 'GET') {
|
||||||
if (!isAllowedOrigin(origin)) {
|
return new Response('Method not allowed', {
|
||||||
return new Response(JSON.stringify({
|
status: 405,
|
||||||
error: 'Forbidden',
|
headers: corsHeaders(origin),
|
||||||
message: 'This proxy only accepts requests from bentopdf.com',
|
});
|
||||||
}), {
|
}
|
||||||
status: 403,
|
|
||||||
headers: {
|
const targetUrl = url.searchParams.get('url');
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
if (!targetUrl) {
|
||||||
});
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Missing url parameter',
|
||||||
|
usage: 'GET /?url=<certificate_url>',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (request.method !== 'GET') {
|
if (!isValidCertificateUrl(targetUrl)) {
|
||||||
return new Response('Method not allowed', {
|
return new Response(
|
||||||
status: 405,
|
JSON.stringify({
|
||||||
headers: corsHeaders(origin),
|
error: 'Invalid or disallowed URL',
|
||||||
});
|
message:
|
||||||
|
'Only certificate-related URLs are allowed (*.crt, *.cer, *.pem, /certs/, /ocsp, /crl)',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const targetUrl = url.searchParams.get('url');
|
if (env.PROXY_SECRET) {
|
||||||
const timestamp = url.searchParams.get('t');
|
const timestamp = url.searchParams.get('t');
|
||||||
const signature = url.searchParams.get('sig');
|
const signature = url.searchParams.get('sig');
|
||||||
|
|
||||||
if (env.PROXY_SECRET) {
|
if (!timestamp || !signature) {
|
||||||
if (!timestamp || !signature) {
|
return new Response(
|
||||||
return new Response(JSON.stringify({
|
JSON.stringify({
|
||||||
error: 'Missing authentication parameters',
|
error: 'Missing authentication parameters',
|
||||||
message: 'Request must include timestamp (t) and signature (sig) parameters',
|
message:
|
||||||
}), {
|
'Request must include timestamp (t) and signature (sig) parameters',
|
||||||
status: 401,
|
}),
|
||||||
headers: {
|
{
|
||||||
...corsHeaders(origin),
|
status: 401,
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
},
|
...corsHeaders(origin),
|
||||||
});
|
'Content-Type': 'application/json',
|
||||||
}
|
},
|
||||||
|
}
|
||||||
const requestTime = parseInt(timestamp, 10);
|
);
|
||||||
const now = Date.now();
|
}
|
||||||
if (isNaN(requestTime) || Math.abs(now - requestTime) > MAX_TIMESTAMP_AGE_MS) {
|
|
||||||
return new Response(JSON.stringify({
|
const requestTime = parseInt(timestamp, 10);
|
||||||
error: 'Request expired or invalid timestamp',
|
const now = Date.now();
|
||||||
message: 'Timestamp must be within 5 minutes of current time',
|
if (
|
||||||
}), {
|
isNaN(requestTime) ||
|
||||||
status: 401,
|
Math.abs(now - requestTime) > MAX_TIMESTAMP_AGE_MS
|
||||||
headers: {
|
) {
|
||||||
...corsHeaders(origin),
|
return new Response(
|
||||||
'Content-Type': 'application/json',
|
JSON.stringify({
|
||||||
},
|
error: 'Request expired or invalid timestamp',
|
||||||
});
|
message: 'Timestamp must be within 5 minutes of current time',
|
||||||
}
|
}),
|
||||||
|
{
|
||||||
const message = `${targetUrl}${timestamp}`;
|
status: 401,
|
||||||
const isValid = await verifySignature(message, signature, env.PROXY_SECRET);
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
if (!isValid) {
|
'Content-Type': 'application/json',
|
||||||
return new Response(JSON.stringify({
|
},
|
||||||
error: 'Invalid signature',
|
}
|
||||||
message: 'Request signature verification failed',
|
);
|
||||||
}), {
|
}
|
||||||
status: 401,
|
|
||||||
headers: {
|
const message = `${targetUrl}${timestamp}`;
|
||||||
...corsHeaders(origin),
|
const isValid = await verifySignature(
|
||||||
'Content-Type': 'application/json',
|
message,
|
||||||
},
|
signature,
|
||||||
});
|
env.PROXY_SECRET
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Invalid signature',
|
||||||
|
message: 'Request signature verification failed',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
|
||||||
|
const rateLimitKey = `ratelimit:${clientIP}`;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (env.RATE_LIMIT_KV) {
|
||||||
|
const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, {
|
||||||
|
type: 'json',
|
||||||
|
});
|
||||||
|
const requests = rateLimitData?.requests || [];
|
||||||
|
|
||||||
|
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 }),
|
||||||
|
{
|
||||||
|
expirationTtl: 120,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
'[CORS Proxy] RATE_LIMIT_KV not configured — rate limiting is disabled'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'BentoPDF-CertProxy/1.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Failed to fetch certificate',
|
||||||
|
status: response.status,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Content-Length header first (fast reject for known-large responses)
|
||||||
|
const contentLength = parseInt(
|
||||||
|
response.headers.get('Content-Length') || '0',
|
||||||
|
10
|
||||||
|
);
|
||||||
|
if (contentLength > MAX_FILE_SIZE_BYTES) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'File too large',
|
||||||
|
message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 413,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const chunks = [];
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
totalSize += value.byteLength;
|
||||||
|
if (totalSize > MAX_FILE_SIZE_BYTES) {
|
||||||
|
reader.cancel();
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'File too large',
|
||||||
|
message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 413,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetUrl) {
|
chunks.push(value);
|
||||||
return new Response(JSON.stringify({
|
}
|
||||||
error: 'Missing url parameter',
|
|
||||||
usage: 'GET /?url=<certificate_url>',
|
const certData = new Uint8Array(totalSize);
|
||||||
}), {
|
let offset = 0;
|
||||||
status: 400,
|
for (const chunk of chunks) {
|
||||||
headers: {
|
certData.set(chunk, offset);
|
||||||
...corsHeaders(origin),
|
offset += chunk.byteLength;
|
||||||
'Content-Type': 'application/json',
|
}
|
||||||
},
|
|
||||||
});
|
return new Response(certData, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
|
'Content-Type': getSafeContentType(
|
||||||
|
response.headers.get('Content-Type')
|
||||||
|
),
|
||||||
|
'Content-Length': totalSize.toString(),
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Proxy fetch error:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Proxy error',
|
||||||
|
message: 'Failed to fetch the requested certificate',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
if (!isValidCertificateUrl(targetUrl)) {
|
}
|
||||||
return new Response(JSON.stringify({
|
},
|
||||||
error: 'Invalid or disallowed URL',
|
|
||||||
message: 'Only certificate-related URLs are allowed (*.crt, *.cer, *.pem, /certs/, /ocsp, /crl)',
|
|
||||||
}), {
|
|
||||||
status: 403,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders(origin),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
|
|
||||||
const rateLimitKey = `ratelimit:${clientIP}`;
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (env.RATE_LIMIT_KV) {
|
|
||||||
const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, { type: 'json' });
|
|
||||||
const requests = rateLimitData?.requests || [];
|
|
||||||
|
|
||||||
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 }), {
|
|
||||||
expirationTtl: 120,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(targetUrl, {
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'BentoPDF-CertProxy/1.0',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Failed to fetch certificate',
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
}), {
|
|
||||||
status: response.status,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders(origin),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentLength = parseInt(response.headers.get('Content-Length') || '0', 10);
|
|
||||||
if (contentLength > MAX_FILE_SIZE_BYTES) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'File too large',
|
|
||||||
message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`,
|
|
||||||
size: contentLength,
|
|
||||||
maxSize: MAX_FILE_SIZE_BYTES,
|
|
||||||
}), {
|
|
||||||
status: 413,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders(origin),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const certData = await response.arrayBuffer();
|
|
||||||
|
|
||||||
if (certData.byteLength > MAX_FILE_SIZE_BYTES) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'File too large',
|
|
||||||
message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`,
|
|
||||||
size: certData.byteLength,
|
|
||||||
maxSize: MAX_FILE_SIZE_BYTES,
|
|
||||||
}), {
|
|
||||||
status: 413,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders(origin),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(certData, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders(origin),
|
|
||||||
'Content-Type': response.headers.get('Content-Type') || 'application/x-x509-ca-cert',
|
|
||||||
'Content-Length': certData.byteLength.toString(),
|
|
||||||
'Cache-Control': 'public, max-age=86400',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Proxy error',
|
|
||||||
message: error.message,
|
|
||||||
}), {
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders(origin),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
The digital signature tool uses a CORS proxy to fetch issuer certificates from external Certificate Authorities (CAs). This is necessary because many CA servers don't include CORS headers in their responses, which prevents direct browser-based fetching.
|
The digital signature tool uses a CORS proxy to fetch issuer certificates from external Certificate Authorities (CAs). This is necessary because many CA servers don't include CORS headers in their responses, which prevents direct browser-based fetching.
|
||||||
|
|
||||||
|
Additionally, many CA servers serve certificates over plain HTTP. When your BentoPDF instance is hosted over HTTPS, browsers block these HTTP requests (mixed content policy). The CORS proxy resolves both issues by routing requests through an HTTPS endpoint with proper headers.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
When signing a PDF with a certificate:
|
When signing a PDF with a certificate:
|
||||||
@@ -13,35 +15,64 @@ When signing a PDF with a certificate:
|
|||||||
|
|
||||||
## Self-Hosting the CORS Proxy
|
## Self-Hosting the CORS Proxy
|
||||||
|
|
||||||
If you're self-hosting BentoPDF, you'll need to deploy your own CORS proxy.
|
If you're self-hosting BentoPDF, you'll need to deploy your own CORS proxy for digital signatures to work with certificates that require chain fetching.
|
||||||
|
|
||||||
### Option 1: Cloudflare Workers (Recommended)
|
### Option 1: Cloudflare Workers (Recommended)
|
||||||
|
|
||||||
1. **Install Wrangler CLI**:
|
1. **Install Wrangler CLI**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g wrangler
|
npm install -g wrangler
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Login to Cloudflare**:
|
2. **Login to Cloudflare**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wrangler login
|
wrangler login
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Deploy the proxy**:
|
3. **Clone BentoPDF and update allowed origins**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/alam00000/bentopdf.git
|
||||||
|
cd bentopdf/cloudflare
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `cors-proxy-worker.js` and change the `ALLOWED_ORIGINS` array to your domain:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ALLOWED_ORIGINS = [
|
||||||
|
'https://your-domain.com',
|
||||||
|
'https://www.your-domain.com',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
::: warning Important
|
||||||
|
Without this change, the proxy will reject all requests from your site with a **403 Forbidden** error. The default only allows requests from `bentopdf.com`.
|
||||||
|
:::
|
||||||
|
|
||||||
|
4. **Deploy the proxy**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd cloudflare
|
|
||||||
wrangler deploy
|
wrangler deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Update your environment**:
|
Note your worker URL (e.g., `https://bentopdf-cors-proxy.your-subdomain.workers.dev`).
|
||||||
Create a `.env` or set in your hosting platform:
|
|
||||||
```
|
5. **Rebuild BentoPDF with the proxy URL**:
|
||||||
VITE_CORS_PROXY_URL=https://your-worker-name.your-subdomain.workers.dev
|
|
||||||
|
If using Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build \
|
||||||
|
--build-arg VITE_CORS_PROXY_URL="https://your-worker.workers.dev" \
|
||||||
|
-t your-bentopdf .
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Rebuild BentoPDF**:
|
If building from source:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
VITE_CORS_PROXY_URL=https://your-worker.workers.dev npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 2: Custom Backend Proxy
|
### Option 2: Custom Backend Proxy
|
||||||
@@ -51,9 +82,9 @@ You can also create your own proxy endpoint. The requirements are:
|
|||||||
1. Accept GET requests with a `url` query parameter
|
1. Accept GET requests with a `url` query parameter
|
||||||
2. Fetch the URL from your server (no CORS restrictions server-side)
|
2. Fetch the URL from your server (no CORS restrictions server-side)
|
||||||
3. Return the response with these headers:
|
3. Return the response with these headers:
|
||||||
- `Access-Control-Allow-Origin: *` (or your specific origin)
|
- `Access-Control-Allow-Origin: https://your-domain.com`
|
||||||
- `Access-Control-Allow-Methods: GET, OPTIONS`
|
- `Access-Control-Allow-Methods: GET, OPTIONS`
|
||||||
- `Content-Type: application/x-x509-ca-cert`
|
- `X-Content-Type-Options: nosniff`
|
||||||
|
|
||||||
Example Express.js implementation:
|
Example Express.js implementation:
|
||||||
|
|
||||||
@@ -70,8 +101,9 @@ app.get('/api/cert-proxy', async (req, res) => {
|
|||||||
const response = await fetch(targetUrl);
|
const response = await fetch(targetUrl);
|
||||||
const data = await response.arrayBuffer();
|
const data = await response.arrayBuffer();
|
||||||
|
|
||||||
res.set('Access-Control-Allow-Origin', '*');
|
res.set('Access-Control-Allow-Origin', 'https://your-domain.com');
|
||||||
res.set('Content-Type', 'application/x-x509-ca-cert');
|
res.set('Content-Type', 'application/octet-stream');
|
||||||
|
res.set('X-Content-Type-Options', 'nosniff');
|
||||||
res.send(Buffer.from(data));
|
res.send(Buffer.from(data));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Proxy error' });
|
res.status(500).json({ error: 'Proxy error' });
|
||||||
@@ -83,9 +115,15 @@ app.get('/api/cert-proxy', async (req, res) => {
|
|||||||
|
|
||||||
The included Cloudflare Worker has several security measures:
|
The included Cloudflare Worker has several security measures:
|
||||||
|
|
||||||
- **URL Validation**: Only allows certificate-related URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`, `/crl`)
|
| Feature | Description |
|
||||||
- **Blocked Domains**: Prevents access to localhost and private IP ranges
|
| ----------------------- | -------------------------------------------------------------------------------------- |
|
||||||
- **HTTP Methods**: Only allows GET requests
|
| **Origin Validation** | Only allows requests from domains listed in `ALLOWED_ORIGINS` |
|
||||||
|
| **URL Restrictions** | Only allows certificate URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`, `/crl`) |
|
||||||
|
| **Private IP Blocking** | Blocks IPv4/IPv6 private ranges, link-local, loopback, decimal IPs, and cloud metadata |
|
||||||
|
| **Content-Type Safety** | Only returns safe certificate MIME types, blocks upstream content-type injection |
|
||||||
|
| **File Size Limit** | Streams response with 10MB limit, aborts mid-download if exceeded |
|
||||||
|
| **Rate Limiting** | 60 requests per IP per minute (requires KV) |
|
||||||
|
| **HMAC Signatures** | Optional client-side signing (deters casual abuse) |
|
||||||
|
|
||||||
## Disabling the Proxy
|
## Disabling the Proxy
|
||||||
|
|
||||||
@@ -95,22 +133,39 @@ If you don't want to use a CORS proxy, set the environment variable to an empty
|
|||||||
VITE_CORS_PROXY_URL=
|
VITE_CORS_PROXY_URL=
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note**: Without the proxy, signing with certificates that require external chain fetching (like FNMT or some corporate CAs) will fail.
|
**Note**: Without the proxy, signing with certificates that require external chain fetching (like FNMT or some corporate CAs) will fail with a "Failed to fetch" error.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### "Failed to fetch certificate chain" Error
|
### "Signing error: TypeError: Failed to fetch"
|
||||||
|
|
||||||
1. Check that your CORS proxy is deployed and accessible
|
This usually means either:
|
||||||
2. Verify the `VITE_CORS_PROXY_URL` is correctly set
|
|
||||||
3. Test the proxy directly:
|
1. **No CORS proxy configured** — Set `VITE_CORS_PROXY_URL` and rebuild
|
||||||
```bash
|
2. **Mixed content blocked** — Your site is HTTPS but the certificate's issuer URL is HTTP. The CORS proxy resolves this.
|
||||||
curl "https://your-proxy.workers.dev?url=https://www.cert.fnmt.es/certs/ACUSU.crt"
|
3. **CORS proxy rejecting your origin** — Check that your domain is in the `ALLOWED_ORIGINS` array in `cors-proxy-worker.js`
|
||||||
```
|
|
||||||
|
### "403 Forbidden" from the proxy
|
||||||
|
|
||||||
|
Your domain is not in the `ALLOWED_ORIGINS` list. Edit `cors-proxy-worker.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ALLOWED_ORIGINS = ['https://your-domain.com'];
|
||||||
|
```
|
||||||
|
|
||||||
|
Then redeploy: `npx wrangler deploy`
|
||||||
|
|
||||||
|
### Testing the proxy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Origin: https://your-domain.com" \
|
||||||
|
"https://your-proxy.workers.dev?url=http://www.cert.fnmt.es/certs/ACUSU.crt"
|
||||||
|
```
|
||||||
|
|
||||||
### Certificates That Work Without Proxy
|
### Certificates That Work Without Proxy
|
||||||
|
|
||||||
Some certificates include the full chain in the P12/PFX file and don't require external fetching:
|
Some certificates include the full chain in the P12/PFX file and don't require external fetching:
|
||||||
|
|
||||||
- Self-signed certificates
|
- Self-signed certificates
|
||||||
- Some commercial CAs that bundle intermediate certificates
|
- Some commercial CAs that bundle intermediate certificates
|
||||||
- Certificates you've manually assembled with the full chain
|
- Certificates you've manually assembled with the full chain
|
||||||
|
|||||||
+32
@@ -717,6 +717,38 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between p-4 bg-gray-900 rounded-lg border border-gray-700"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label
|
||||||
|
for="compact-mode-toggle"
|
||||||
|
class="text-sm font-medium text-gray-200 cursor-pointer"
|
||||||
|
data-i18n="settings.compactMode"
|
||||||
|
>
|
||||||
|
Compact Mode
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
class="text-xs text-gray-400 mt-1"
|
||||||
|
data-i18n="settings.compactModeDescription"
|
||||||
|
>
|
||||||
|
Display tools as a compact list instead of cards
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
class="relative inline-flex items-center cursor-pointer ml-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="compact-mode-toggle"
|
||||||
|
class="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="w-11 h-6 bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Advanced Settings Link -->
|
<!-- Advanced Settings Link -->
|
||||||
<a
|
<a
|
||||||
href="wasm-settings.html"
|
href="wasm-settings.html"
|
||||||
|
|||||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bento-pdf",
|
"name": "bento-pdf",
|
||||||
"version": "2.1.0",
|
"version": "2.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bento-pdf",
|
"name": "bento-pdf",
|
||||||
"version": "2.1.0",
|
"version": "2.2.1",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/cedarville-cursive": "^5.2.7",
|
"@fontsource/cedarville-cursive": "^5.2.7",
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
"tiff": "^7.1.2",
|
"tiff": "^7.1.2",
|
||||||
"utif": "^3.1.0",
|
"utif": "^3.1.0",
|
||||||
"vite-plugin-static-copy": "^3.1.4",
|
"vite-plugin-static-copy": "^3.1.4",
|
||||||
"xlsx": "https://cdn.sheetjs.com/xlsx-latest/xlsx-latest.tgz",
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||||
"zgapdfsigner": "^2.7.5"
|
"zgapdfsigner": "^2.7.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -13852,7 +13852,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/xlsx": {
|
"node_modules/xlsx": {
|
||||||
"version": "0.20.3",
|
"version": "0.20.3",
|
||||||
"resolved": "https://cdn.sheetjs.com/xlsx-latest/xlsx-latest.tgz",
|
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||||
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
|
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "bento-pdf",
|
"name": "bento-pdf",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.2.1",
|
"version": "2.3.0",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
"tiff": "^7.1.2",
|
"tiff": "^7.1.2",
|
||||||
"utif": "^3.1.0",
|
"utif": "^3.1.0",
|
||||||
"vite-plugin-static-copy": "^3.1.4",
|
"vite-plugin-static-copy": "^3.1.4",
|
||||||
"xlsx": "https://cdn.sheetjs.com/xlsx-latest/xlsx-latest.tgz",
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||||
"zgapdfsigner": "^2.7.5"
|
"zgapdfsigner": "^2.7.5"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|||||||
@@ -86,6 +86,10 @@
|
|||||||
"name": "أرقام الصفحات",
|
"name": "أرقام الصفحات",
|
||||||
"subtitle": "إدراج أرقام الصفحات في مستندك."
|
"subtitle": "إدراج أرقام الصفحات في مستندك."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "ترقيم بيتس",
|
||||||
|
"subtitle": "إضافة أرقام بيتس التسلسلية عبر ملف PDF واحد أو أكثر."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "إضافة علامة مائية",
|
"name": "إضافة علامة مائية",
|
||||||
"subtitle": "ختم نص أو صورة على صفحات PDF الخاصة بك."
|
"subtitle": "ختم نص أو صورة على صفحات PDF الخاصة بك."
|
||||||
|
|||||||
@@ -358,6 +358,6 @@
|
|||||||
},
|
},
|
||||||
"simpleMode": {
|
"simpleMode": {
|
||||||
"title": "Інструменты PDF",
|
"title": "Інструменты PDF",
|
||||||
"subtitle": "Абярыце інструмент, каб пачаць"
|
"subtitle": "Выберыце інструмент, каб пачаць"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,10 @@
|
|||||||
"name": "Нумары старонак",
|
"name": "Нумары старонак",
|
||||||
"subtitle": "Уставіць у дакумент нумары старонак."
|
"subtitle": "Уставіць у дакумент нумары старонак."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Нумарацыя Бейтса",
|
||||||
|
"subtitle": "Дадаць паслядоўныя нумары Бейтса да аднаго або некалькіх файлаў PDF."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Дадаць вадзяны знак",
|
"name": "Дадаць вадзяны знак",
|
||||||
"subtitle": "Накласці на старонкі PDF тэкст або відарыс."
|
"subtitle": "Накласці на старонкі PDF тэкст або відарыс."
|
||||||
@@ -590,10 +594,10 @@
|
|||||||
"subtitle": "Канвертаваць PDF у PDF на аснове відарысаў. Звесці слаі і выдаліць тэкст, які можна вылучыць."
|
"subtitle": "Канвертаваць PDF у PDF на аснове відарысаў. Звесці слаі і выдаліць тэкст, які можна вылучыць."
|
||||||
},
|
},
|
||||||
"pdfWorkflow": {
|
"pdfWorkflow": {
|
||||||
"name": "Канструктар працоўнага працэсу PDF",
|
"name": "Канструктар рабочага працэсу PDF",
|
||||||
"subtitle": "Стварайце ўласныя канвееры апрацоўкі PDF з дапамогай візуальнага рэдактара вузлоў.",
|
"subtitle": "Стварыць уласныя канвееры апрацоўкі PDF з дапамогай візуальнага рэдактара.",
|
||||||
"nodes": "Вузлы",
|
"nodes": "Вузлы",
|
||||||
"searchNodes": "Шукаць вузлы...",
|
"searchNodes": "Пошук вузлоў...",
|
||||||
"run": "Запусціць",
|
"run": "Запусціць",
|
||||||
"clear": "Ачысціць",
|
"clear": "Ачысціць",
|
||||||
"save": "Захаваць",
|
"save": "Захаваць",
|
||||||
@@ -605,21 +609,21 @@
|
|||||||
"processing": "Апрацоўка...",
|
"processing": "Апрацоўка...",
|
||||||
"saveTemplate": "Захаваць шаблон",
|
"saveTemplate": "Захаваць шаблон",
|
||||||
"templateName": "Назва шаблону",
|
"templateName": "Назва шаблону",
|
||||||
"templatePlaceholder": "напр. Працоўны працэс рахункаў-фактур",
|
"templatePlaceholder": "напрыклад, Праца з накладнымі",
|
||||||
"cancel": "Адмяніць",
|
"cancel": "Скасаваць",
|
||||||
"loadTemplate": "Загрузіць шаблон",
|
"loadTemplate": "Загрузіць шаблон",
|
||||||
"noTemplates": "Пакуль няма захаваных шаблонаў.",
|
"noTemplates": "Пакуль няма захаваных шаблонаў.",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"workflowCompleted": "Працоўны працэс завершаны",
|
"workflowCompleted": "Рабочы працэс завершаны",
|
||||||
"errorDuringExecution": "Памылка падчас выканання",
|
"errorDuringExecution": "Памылка падчас выканання",
|
||||||
"addNodeError": "Дадайце хаця б адзін вузел для запуску працоўнага працэсу.",
|
"addNodeError": "Дадайце хаця б адзін вузел для запуску рабочага працэсу.",
|
||||||
"needInputOutput": "Для працы працоўнага працэсу патрэбен хаця б адзін вузел уводу і адзін вузел вываду.",
|
"needInputOutput": "Для працы рабочага працэсу патрэбен хаця б адзін вузел уводу і адзін вузел вываду.",
|
||||||
"enterName": "Калі ласка, увядзіце назву.",
|
"enterName": "Увядзіце назву.",
|
||||||
"templateExists": "Шаблон з такой назвай ужо існуе.",
|
"templateExists": "Шаблон з такой назвай ужо існуе.",
|
||||||
"templateSaved": "Шаблон \"{{name}}\" захаваны.",
|
"templateSaved": "Шаблон \"{{name}}\" захаваны.",
|
||||||
"templateLoaded": "Шаблон \"{{name}}\" загружаны.",
|
"templateLoaded": "Шаблон \"{{name}}\" загружаны.",
|
||||||
"failedLoadTemplate": "Не ўдалося загрузіць шаблон.",
|
"failedLoadTemplate": "Не ўдалося загрузіць шаблон.",
|
||||||
"noSettings": "Няма наладжвальных параметраў для гэтага вузла.",
|
"noSettings": "Няма налад для гэтага вузла.",
|
||||||
"advancedSettings": "Пашыраныя налады"
|
"advancedSettings": "Пашыраныя налады"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,10 @@
|
|||||||
"name": "Sidetal",
|
"name": "Sidetal",
|
||||||
"subtitle": "Indsæt sidetal i dokumentet."
|
"subtitle": "Indsæt sidetal i dokumentet."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Bates-nummerering",
|
||||||
|
"subtitle": "Tilføj sekventielle Bates-numre på tværs af en eller flere PDF-filer."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Tilføj vandmærke",
|
"name": "Tilføj vandmærke",
|
||||||
"subtitle": "Placer tekst eller et billede oven på dine PDF-sider."
|
"subtitle": "Placer tekst eller et billede oven på dine PDF-sider."
|
||||||
|
|||||||
@@ -86,6 +86,10 @@
|
|||||||
"name": "Seitenzahlen",
|
"name": "Seitenzahlen",
|
||||||
"subtitle": "Seitenzahlen in Ihr Dokument einfügen."
|
"subtitle": "Seitenzahlen in Ihr Dokument einfügen."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Bates-Nummerierung",
|
||||||
|
"subtitle": "Fortlaufende Bates-Nummern über eine oder mehrere PDF-Dateien hinzufügen."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Wasserzeichen hinzufügen",
|
"name": "Wasserzeichen hinzufügen",
|
||||||
"subtitle": "Text oder ein Bild über Ihre PDF-Seiten stempeln."
|
"subtitle": "Text oder ein Bild über Ihre PDF-Seiten stempeln."
|
||||||
|
|||||||
@@ -86,6 +86,10 @@
|
|||||||
"name": "Page Numbers",
|
"name": "Page Numbers",
|
||||||
"subtitle": "Insert page numbers into your document."
|
"subtitle": "Insert page numbers into your document."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Bates Numbering",
|
||||||
|
"subtitle": "Add sequential Bates numbers across one or more PDF files."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Add Watermark",
|
"name": "Add Watermark",
|
||||||
"subtitle": "Stamp text or an image over your PDF pages."
|
"subtitle": "Stamp text or an image over your PDF pages."
|
||||||
|
|||||||
@@ -86,6 +86,10 @@
|
|||||||
"name": "Números de Página",
|
"name": "Números de Página",
|
||||||
"subtitle": "Inserta números de página en tu documento."
|
"subtitle": "Inserta números de página en tu documento."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Numeración Bates",
|
||||||
|
"subtitle": "Añadir números Bates secuenciales en uno o más archivos PDF."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Agregar Marca de Agua",
|
"name": "Agregar Marca de Agua",
|
||||||
"subtitle": "Estampa texto o una imagen sobre tus páginas PDF."
|
"subtitle": "Estampa texto o una imagen sobre tus páginas PDF."
|
||||||
|
|||||||
@@ -86,6 +86,10 @@
|
|||||||
"name": "Numéros de page",
|
"name": "Numéros de page",
|
||||||
"subtitle": "Insérer une numérotation dans le document."
|
"subtitle": "Insérer une numérotation dans le document."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Numérotation Bates",
|
||||||
|
"subtitle": "Ajouter des numéros Bates séquentiels sur un ou plusieurs fichiers PDF."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Ajouter un filigrane",
|
"name": "Ajouter un filigrane",
|
||||||
"subtitle": "Apposer un texte ou une image sur les pages du PDF."
|
"subtitle": "Apposer un texte ou une image sur les pages du PDF."
|
||||||
|
|||||||
@@ -86,6 +86,10 @@
|
|||||||
"name": "Nomor Halaman",
|
"name": "Nomor Halaman",
|
||||||
"subtitle": "Sisipkan nomor halaman ke dokumen Anda."
|
"subtitle": "Sisipkan nomor halaman ke dokumen Anda."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Penomoran Bates",
|
||||||
|
"subtitle": "Tambahkan nomor Bates berurutan pada satu atau lebih file PDF."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Tambah Watermark",
|
"name": "Tambah Watermark",
|
||||||
"subtitle": "Cap teks atau gambar di atas halaman PDF Anda."
|
"subtitle": "Cap teks atau gambar di atas halaman PDF Anda."
|
||||||
|
|||||||
@@ -86,6 +86,10 @@
|
|||||||
"name": "Numeri di Pagina",
|
"name": "Numeri di Pagina",
|
||||||
"subtitle": "Inserisci i numeri di pagina nel tuo documento."
|
"subtitle": "Inserisci i numeri di pagina nel tuo documento."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Numerazione Bates",
|
||||||
|
"subtitle": "Aggiungi numeri Bates sequenziali su uno o più file PDF."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Aggiungi Filigrana",
|
"name": "Aggiungi Filigrana",
|
||||||
"subtitle": "Applica testo o un'immagine sulle pagine del tuo PDF."
|
"subtitle": "Applica testo o un'immagine sulle pagine del tuo PDF."
|
||||||
|
|||||||
@@ -86,6 +86,10 @@
|
|||||||
"name": "Paginanummers",
|
"name": "Paginanummers",
|
||||||
"subtitle": "Paginanummers aan je document toevoegen."
|
"subtitle": "Paginanummers aan je document toevoegen."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Bates-nummering",
|
||||||
|
"subtitle": "Voeg opeenvolgende Bates-nummers toe aan een of meer PDF-bestanden."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Watermerk toevoegen",
|
"name": "Watermerk toevoegen",
|
||||||
"subtitle": "Tekst of een afbeelding over de pagina's van je PDF stempelen."
|
"subtitle": "Tekst of een afbeelding over de pagina's van je PDF stempelen."
|
||||||
|
|||||||
@@ -64,6 +64,10 @@
|
|||||||
"name": "Números de Página",
|
"name": "Números de Página",
|
||||||
"subtitle": "Insira números de página no seu documento."
|
"subtitle": "Insira números de página no seu documento."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Numeração Bates",
|
||||||
|
"subtitle": "Adicionar números Bates sequenciais em um ou mais arquivos PDF."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Adicionar Marca d'Água",
|
"name": "Adicionar Marca d'Água",
|
||||||
"subtitle": "Carimbe texto ou uma imagem sobre as páginas do seu PDF."
|
"subtitle": "Carimbe texto ou uma imagem sobre as páginas do seu PDF."
|
||||||
|
|||||||
@@ -64,6 +64,10 @@
|
|||||||
"name": "Sayfa Numaraları",
|
"name": "Sayfa Numaraları",
|
||||||
"subtitle": "Belgenize sayfa numaraları ekleyin."
|
"subtitle": "Belgenize sayfa numaraları ekleyin."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Bates Numaralandırma",
|
||||||
|
"subtitle": "Bir veya daha fazla PDF dosyasına sıralı Bates numaraları ekleyin."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Filigran Ekle",
|
"name": "Filigran Ekle",
|
||||||
"subtitle": "PDF sayfalarınızın üzerine metin veya görsel damgası ekleyin."
|
"subtitle": "PDF sayfalarınızın üzerine metin veya görsel damgası ekleyin."
|
||||||
|
|||||||
@@ -86,6 +86,10 @@
|
|||||||
"name": "Số trang",
|
"name": "Số trang",
|
||||||
"subtitle": "Chèn số trang vào tài liệu của bạn."
|
"subtitle": "Chèn số trang vào tài liệu của bạn."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Đánh số Bates",
|
||||||
|
"subtitle": "Thêm số Bates tuần tự trên một hoặc nhiều tệp PDF."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Thêm Watermark",
|
"name": "Thêm Watermark",
|
||||||
"subtitle": "Đóng dấu văn bản hoặc hình ảnh lên các trang PDF của bạn."
|
"subtitle": "Đóng dấu văn bản hoặc hình ảnh lên các trang PDF của bạn."
|
||||||
|
|||||||
@@ -64,6 +64,10 @@
|
|||||||
"name": "頁碼",
|
"name": "頁碼",
|
||||||
"subtitle": "在你的文件中插入頁碼。"
|
"subtitle": "在你的文件中插入頁碼。"
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Bates編號",
|
||||||
|
"subtitle": "在一個或多個PDF檔案中新增連續的Bates編號。"
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "添加浮水印",
|
"name": "添加浮水印",
|
||||||
"subtitle": "在你的 PDF 頁面上壓印文字或圖片。"
|
"subtitle": "在你的 PDF 頁面上壓印文字或圖片。"
|
||||||
|
|||||||
@@ -86,6 +86,10 @@
|
|||||||
"name": "页码",
|
"name": "页码",
|
||||||
"subtitle": "将页码插入到您的文档中。"
|
"subtitle": "将页码插入到您的文档中。"
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Bates编号",
|
||||||
|
"subtitle": "在一个或多个PDF文件中添加连续的Bates编号。"
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "添加水印",
|
"name": "添加水印",
|
||||||
"subtitle": "在您的 PDF 页面上添加文字或图片水印。"
|
"subtitle": "在您的 PDF 页面上添加文字或图片水印。"
|
||||||
|
|||||||
@@ -311,6 +311,22 @@
|
|||||||
"created_at": "2026-02-03T13:50:03Z",
|
"created_at": "2026-02-03T13:50:03Z",
|
||||||
"repoId": 1074785178,
|
"repoId": 1074785178,
|
||||||
"pullRequestNo": 461
|
"pullRequestNo": 461
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tzabbi",
|
||||||
|
"id": 40226087,
|
||||||
|
"comment_id": 3926638030,
|
||||||
|
"created_at": "2026-02-19T11:27:36Z",
|
||||||
|
"repoId": 1074785178,
|
||||||
|
"pullRequestNo": 493
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "diogotcorreia",
|
||||||
|
"id": 7467891,
|
||||||
|
"comment_id": 3937759209,
|
||||||
|
"created_at": "2026-02-21T00:26:29Z",
|
||||||
|
"repoId": 1074785178,
|
||||||
|
"pullRequestNo": 501
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+120
-14
@@ -76,6 +76,106 @@ body {
|
|||||||
border-color: #4f46e5;
|
border-color: #4f46e5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.category-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-group:first-child .category-header {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header:hover {
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header:hover .category-chevron {
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tools {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 300ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-group.collapsed .category-chevron {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-mode .category-tools {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
gap: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-mode .tool-card {
|
||||||
|
flex-direction: row;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
border-top: none;
|
||||||
|
border-bottom: 1px solid #1f2937;
|
||||||
|
background-color: transparent;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-mode .tool-card i {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
min-width: 1.25rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-mode .tool-card i.ph {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-mode .tool-card h3 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-mode .tool-card p {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-mode .tool-card:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background-color: #1f2937;
|
||||||
|
border-color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.compact-mode .category-tools {
|
||||||
|
grid-template-columns: repeat(2, 1fr) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.compact-mode .category-tools {
|
||||||
|
grid-template-columns: repeat(3, 1fr) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
transition:
|
transition:
|
||||||
background-color 0.2s ease-in-out,
|
background-color 0.2s ease-in-out,
|
||||||
@@ -116,7 +216,7 @@ input[type='file']::file-selector-button {
|
|||||||
border: 2px dashed #4f46e5;
|
border: 2px dashed #4f46e5;
|
||||||
}
|
}
|
||||||
|
|
||||||
#embed-pdf-container>div {
|
#embed-pdf-container > div {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
@@ -126,7 +226,7 @@ input[type='file']::file-selector-button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-thumbnail,
|
.page-thumbnail,
|
||||||
#file-list>li {
|
#file-list > li {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,8 +364,6 @@ footer a {
|
|||||||
z-index: -1;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.section-divider {
|
.section-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: linear-gradient(to right, transparent, #4d44f7, transparent);
|
background: linear-gradient(to right, transparent, #4d44f7, transparent);
|
||||||
@@ -501,19 +599,19 @@ footer a {
|
|||||||
color: rgb(165 180 252);
|
color: rgb(165 180 252);
|
||||||
}
|
}
|
||||||
|
|
||||||
details>summary {
|
details > summary {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
details>summary::-webkit-details-marker {
|
details > summary::-webkit-details-marker {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
details>summary .icon {
|
details > summary .icon {
|
||||||
transition: transform 0.2s ease-in-out;
|
transition: transform 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
details[open]>summary .icon {
|
details[open] > summary .icon {
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,7 +635,9 @@ button:disabled,
|
|||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||||
|
'Courier New', monospace;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
background-color: #374151;
|
background-color: #374151;
|
||||||
@@ -547,7 +647,9 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.shortcut-input {
|
.shortcut-input {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||||
|
'Courier New', monospace;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,18 +693,22 @@ button:disabled,
|
|||||||
linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
||||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
|
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
|
||||||
mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%);
|
mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%);
|
||||||
-webkit-mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%);
|
-webkit-mask-image: radial-gradient(
|
||||||
|
ellipse at center,
|
||||||
|
black 40%,
|
||||||
|
transparent 80%
|
||||||
|
);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide spin buttons for number inputs */
|
/* Hide spin buttons for number inputs */
|
||||||
input[type="number"] {
|
input[type='number'] {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
-moz-appearance: textfield; /* Firefox */
|
-moz-appearance: textfield; /* Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="number"]::-webkit-inner-spin-button,
|
input[type='number']::-webkit-inner-spin-button,
|
||||||
input[type="number"]::-webkit-outer-spin-button {
|
input[type='number']::-webkit-outer-spin-button {
|
||||||
-webkit-appearance: none; /* Chrome, Safari, Edge */
|
-webkit-appearance: none; /* Chrome, Safari, Edge */
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -108,6 +108,12 @@ export const categories = [
|
|||||||
icon: 'ph-list-numbers',
|
icon: 'ph-list-numbers',
|
||||||
subtitle: 'Insert page numbers into your document.',
|
subtitle: 'Insert page numbers into your document.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: import.meta.env.BASE_URL + 'bates-numbering.html',
|
||||||
|
name: 'Bates Numbering',
|
||||||
|
icon: 'ph-hash',
|
||||||
|
subtitle: 'Add sequential Bates numbers across one or more PDF files.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: import.meta.env.BASE_URL + 'add-watermark.html',
|
href: import.meta.env.BASE_URL + 'add-watermark.html',
|
||||||
name: 'Add Watermark',
|
name: 'Add Watermark',
|
||||||
|
|||||||
@@ -0,0 +1,548 @@
|
|||||||
|
import { createIcons, icons } from 'lucide';
|
||||||
|
import { showAlert, showLoader, hideLoader } from '../ui.js';
|
||||||
|
import { downloadFile, hexToRgb, formatBytes } from '../utils/helpers.js';
|
||||||
|
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import Sortable from 'sortablejs';
|
||||||
|
import { FileEntry, Position, StylePreset } from '@/types';
|
||||||
|
|
||||||
|
const FONT_MAP: Record<string, keyof typeof StandardFonts> = {
|
||||||
|
Helvetica: 'Helvetica',
|
||||||
|
TimesRoman: 'TimesRoman',
|
||||||
|
Courier: 'Courier',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STYLE_PRESETS: Record<string, StylePreset> = {
|
||||||
|
'full-6': {
|
||||||
|
template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]',
|
||||||
|
padding: 6,
|
||||||
|
},
|
||||||
|
'full-5': {
|
||||||
|
template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
'full-4': {
|
||||||
|
template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]',
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
'full-3': {
|
||||||
|
template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]',
|
||||||
|
padding: 3,
|
||||||
|
},
|
||||||
|
'full-0': {
|
||||||
|
template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]',
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
'no-page-6': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 6 },
|
||||||
|
'no-page-5': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 5 },
|
||||||
|
'no-page-4': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 4 },
|
||||||
|
'no-page-3': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 3 },
|
||||||
|
'no-page-0': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 0 },
|
||||||
|
'case-6': { template: 'Case XYZ [BATES]', padding: 6 },
|
||||||
|
'case-5': { template: 'Case XYZ [BATES]', padding: 5 },
|
||||||
|
'case-4': { template: 'Case XYZ [BATES]', padding: 4 },
|
||||||
|
'case-3': { template: 'Case XYZ [BATES]', padding: 3 },
|
||||||
|
'case-0': { template: 'Case XYZ [BATES]', padding: 0 },
|
||||||
|
'bates-6': { template: '[BATES]', padding: 6 },
|
||||||
|
'bates-5': { template: '[BATES]', padding: 5 },
|
||||||
|
'bates-4': { template: '[BATES]', padding: 4 },
|
||||||
|
'bates-3': { template: '[BATES]', padding: 3 },
|
||||||
|
'bates-0': { template: '[BATES]', padding: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const files: FileEntry[] = [];
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initializePage);
|
||||||
|
} else {
|
||||||
|
initializePage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializePage() {
|
||||||
|
createIcons({ icons });
|
||||||
|
|
||||||
|
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||||
|
const dropZone = document.getElementById('drop-zone');
|
||||||
|
const backBtn = document.getElementById('back-to-tools');
|
||||||
|
const processBtn = document.getElementById('process-btn');
|
||||||
|
const addMoreBtn = document.getElementById('add-more-btn');
|
||||||
|
const clearFilesBtn = document.getElementById('clear-files-btn');
|
||||||
|
const stylePreset = document.getElementById(
|
||||||
|
'style-preset'
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const templateInput = document.getElementById(
|
||||||
|
'bates-template'
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.addEventListener('change', () => {
|
||||||
|
if (fileInput.files?.length) {
|
||||||
|
handleFiles(fileInput.files);
|
||||||
|
fileInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dropZone) {
|
||||||
|
dropZone.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.add('border-indigo-500');
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('dragleave', () => {
|
||||||
|
dropZone.classList.remove('border-indigo-500');
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('border-indigo-500');
|
||||||
|
if (e.dataTransfer?.files.length) handleFiles(e.dataTransfer.files);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backBtn) {
|
||||||
|
backBtn.addEventListener('click', () => {
|
||||||
|
window.location.href = import.meta.env.BASE_URL;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processBtn) {
|
||||||
|
processBtn.addEventListener('click', applyBatesNumbers);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addMoreBtn) {
|
||||||
|
addMoreBtn.addEventListener('click', () => fileInput?.click());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearFilesBtn) {
|
||||||
|
clearFilesBtn.addEventListener('click', resetState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stylePreset) {
|
||||||
|
stylePreset.addEventListener('change', () => {
|
||||||
|
const value = stylePreset.value;
|
||||||
|
const isCustom = value === 'custom';
|
||||||
|
const paddingGroup = document.getElementById('padding-group');
|
||||||
|
if (!isCustom && STYLE_PRESETS[value]) {
|
||||||
|
templateInput.value = STYLE_PRESETS[value].template;
|
||||||
|
if (paddingGroup) paddingGroup.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
if (paddingGroup) paddingGroup.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
templateInput.readOnly = !isCustom;
|
||||||
|
updatePreview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (templateInput) {
|
||||||
|
templateInput.addEventListener('input', () => {
|
||||||
|
const preset = stylePreset;
|
||||||
|
if (preset && preset.value !== 'custom') {
|
||||||
|
preset.value = 'custom';
|
||||||
|
templateInput.readOnly = false;
|
||||||
|
document.getElementById('padding-group')?.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
updatePreview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document
|
||||||
|
.getElementById('bates-padding')
|
||||||
|
?.addEventListener('change', updatePreview);
|
||||||
|
document
|
||||||
|
.getElementById('bates-start')
|
||||||
|
?.addEventListener('input', updatePreview);
|
||||||
|
document
|
||||||
|
.getElementById('file-start')
|
||||||
|
?.addEventListener('input', updatePreview);
|
||||||
|
|
||||||
|
initSortable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSortable() {
|
||||||
|
const fileList = document.getElementById('file-list');
|
||||||
|
if (!fileList) return;
|
||||||
|
Sortable.create(fileList, {
|
||||||
|
handle: '.drag-handle',
|
||||||
|
animation: 150,
|
||||||
|
onEnd: (evt) => {
|
||||||
|
if (evt.oldIndex !== undefined && evt.newIndex !== undefined) {
|
||||||
|
const [moved] = files.splice(evt.oldIndex, 1);
|
||||||
|
files.splice(evt.newIndex, 0, moved);
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFiles(fileList: FileList) {
|
||||||
|
showLoader('Loading PDFs...');
|
||||||
|
try {
|
||||||
|
for (const file of Array.from(fileList)) {
|
||||||
|
if (file.type !== 'application/pdf') continue;
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const pdfDoc = await PDFDocument.load(arrayBuffer);
|
||||||
|
files.push({ file, pageCount: pdfDoc.getPageCount() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
showAlert('Invalid File', 'Please upload valid PDF files.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFileList();
|
||||||
|
document.getElementById('options-panel')?.classList.remove('hidden');
|
||||||
|
document.getElementById('file-controls')?.classList.remove('hidden');
|
||||||
|
updatePreview();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
showAlert('Error', 'Failed to load one or more PDF files.');
|
||||||
|
} finally {
|
||||||
|
hideLoader();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFileList() {
|
||||||
|
const fileListEl = document.getElementById('file-list');
|
||||||
|
if (!fileListEl) return;
|
||||||
|
|
||||||
|
fileListEl.innerHTML = '';
|
||||||
|
let totalPages = 0;
|
||||||
|
|
||||||
|
files.forEach((entry, index) => {
|
||||||
|
totalPages += entry.pageCount;
|
||||||
|
|
||||||
|
const fileDiv = document.createElement('div');
|
||||||
|
fileDiv.className =
|
||||||
|
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||||
|
|
||||||
|
const leftSection = document.createElement('div');
|
||||||
|
leftSection.className = 'flex items-center gap-3 flex-1 min-w-0';
|
||||||
|
|
||||||
|
const dragHandle = document.createElement('i');
|
||||||
|
dragHandle.setAttribute('data-lucide', 'grip-vertical');
|
||||||
|
dragHandle.className =
|
||||||
|
'drag-handle w-4 h-4 text-gray-400 cursor-grab flex-shrink-0';
|
||||||
|
|
||||||
|
const infoContainer = document.createElement('div');
|
||||||
|
infoContainer.className = 'flex flex-col min-w-0';
|
||||||
|
|
||||||
|
const nameSpan = document.createElement('div');
|
||||||
|
nameSpan.className = 'truncate font-medium text-gray-200 text-sm';
|
||||||
|
nameSpan.textContent = entry.file.name;
|
||||||
|
|
||||||
|
const metaSpan = document.createElement('div');
|
||||||
|
metaSpan.className = 'text-xs text-gray-400';
|
||||||
|
metaSpan.textContent = `${formatBytes(entry.file.size)} \u2022 ${entry.pageCount} pages`;
|
||||||
|
|
||||||
|
infoContainer.append(nameSpan, metaSpan);
|
||||||
|
leftSection.append(dragHandle, infoContainer);
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||||
|
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||||
|
removeBtn.onclick = () => {
|
||||||
|
files.splice(index, 1);
|
||||||
|
renderFileList();
|
||||||
|
updatePreview();
|
||||||
|
if (files.length === 0) resetState();
|
||||||
|
};
|
||||||
|
|
||||||
|
fileDiv.append(leftSection, removeBtn);
|
||||||
|
fileListEl.appendChild(fileDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
createIcons({ icons });
|
||||||
|
|
||||||
|
const summary = document.createElement('div');
|
||||||
|
summary.className = 'text-xs text-gray-400 mt-1';
|
||||||
|
summary.textContent = `${files.length} file${files.length !== 1 ? 's' : ''} \u2022 ${totalPages} total pages`;
|
||||||
|
fileListEl.appendChild(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBatesText(
|
||||||
|
template: string,
|
||||||
|
batesNum: number,
|
||||||
|
pageNum: number,
|
||||||
|
fileNum: number,
|
||||||
|
fileName: string,
|
||||||
|
padding: number
|
||||||
|
): string {
|
||||||
|
const batesStr =
|
||||||
|
padding > 0 ? String(batesNum).padStart(padding, '0') : String(batesNum);
|
||||||
|
return template
|
||||||
|
.replace(/\[BATES\]/g, batesStr)
|
||||||
|
.replace(/\[PAGE\]/g, String(pageNum))
|
||||||
|
.replace(/\[FILE\]/g, String(fileNum))
|
||||||
|
.replace(/\[FILENAME\]/g, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivePadding(): number {
|
||||||
|
const presetValue = (
|
||||||
|
document.getElementById('style-preset') as HTMLSelectElement
|
||||||
|
).value;
|
||||||
|
if (presetValue !== 'custom' && STYLE_PRESETS[presetValue]) {
|
||||||
|
return STYLE_PRESETS[presetValue].padding;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
parseInt(
|
||||||
|
(document.getElementById('bates-padding') as HTMLSelectElement).value
|
||||||
|
) || 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreview() {
|
||||||
|
const previewEl = document.getElementById('preview-content');
|
||||||
|
if (!previewEl) return;
|
||||||
|
|
||||||
|
const template = (
|
||||||
|
document.getElementById('bates-template') as HTMLInputElement
|
||||||
|
).value;
|
||||||
|
const padding = getActivePadding();
|
||||||
|
const batesStart =
|
||||||
|
parseInt(
|
||||||
|
(document.getElementById('bates-start') as HTMLInputElement).value
|
||||||
|
) || 1;
|
||||||
|
const fileStart =
|
||||||
|
parseInt(
|
||||||
|
(document.getElementById('file-start') as HTMLInputElement).value
|
||||||
|
) || 1;
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
lines.push(
|
||||||
|
formatBatesText(template, batesStart, 1, fileStart, 'document', padding)
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
formatBatesText(
|
||||||
|
template,
|
||||||
|
batesStart + 1,
|
||||||
|
2,
|
||||||
|
fileStart,
|
||||||
|
'document',
|
||||||
|
padding
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let batesCounter = batesStart;
|
||||||
|
let fileCounter = fileStart;
|
||||||
|
for (const entry of files) {
|
||||||
|
const name = entry.file.name.replace(/\.pdf$/i, '');
|
||||||
|
lines.push(
|
||||||
|
`File ${fileCounter}, Page 1: ${formatBatesText(template, batesCounter, 1, fileCounter, name, padding)}`
|
||||||
|
);
|
||||||
|
if (entry.pageCount > 1) {
|
||||||
|
lines.push(
|
||||||
|
`File ${fileCounter}, Page 2: ${formatBatesText(template, batesCounter + 1, 2, fileCounter, name, padding)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
batesCounter += entry.pageCount;
|
||||||
|
fileCounter++;
|
||||||
|
}
|
||||||
|
const lastEntry = files[files.length - 1];
|
||||||
|
const lastName = lastEntry.file.name.replace(/\.pdf$/i, '');
|
||||||
|
const lastBates = batesCounter - 1;
|
||||||
|
lines.push('...');
|
||||||
|
lines.push(
|
||||||
|
`File ${fileStart + files.length - 1}, Page ${lastEntry.pageCount}: ${formatBatesText(template, lastBates, lastEntry.pageCount, fileStart + files.length - 1, lastName, padding)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
previewEl.textContent = lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePosition(
|
||||||
|
pageWidth: number,
|
||||||
|
pageHeight: number,
|
||||||
|
xOffset: number,
|
||||||
|
yOffset: number,
|
||||||
|
textWidth: number,
|
||||||
|
fontSize: number,
|
||||||
|
position: Position
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const minMargin = 8;
|
||||||
|
const maxMargin = 40;
|
||||||
|
const marginPct = 0.04;
|
||||||
|
|
||||||
|
const hMargin = Math.max(
|
||||||
|
minMargin,
|
||||||
|
Math.min(maxMargin, pageWidth * marginPct)
|
||||||
|
);
|
||||||
|
const vMargin = Math.max(
|
||||||
|
minMargin,
|
||||||
|
Math.min(maxMargin, pageHeight * marginPct)
|
||||||
|
);
|
||||||
|
const safeH = Math.max(hMargin, textWidth / 2 + 3);
|
||||||
|
const safeV = Math.max(vMargin, fontSize + 3);
|
||||||
|
|
||||||
|
let x = 0,
|
||||||
|
y = 0;
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case 'bottom-center':
|
||||||
|
x =
|
||||||
|
Math.max(
|
||||||
|
safeH,
|
||||||
|
Math.min(pageWidth - safeH - textWidth, (pageWidth - textWidth) / 2)
|
||||||
|
) + xOffset;
|
||||||
|
y = safeV + yOffset;
|
||||||
|
break;
|
||||||
|
case 'bottom-left':
|
||||||
|
x = safeH + xOffset;
|
||||||
|
y = safeV + yOffset;
|
||||||
|
break;
|
||||||
|
case 'bottom-right':
|
||||||
|
x = Math.max(safeH, pageWidth - safeH - textWidth) + xOffset;
|
||||||
|
y = safeV + yOffset;
|
||||||
|
break;
|
||||||
|
case 'top-center':
|
||||||
|
x =
|
||||||
|
Math.max(
|
||||||
|
safeH,
|
||||||
|
Math.min(pageWidth - safeH - textWidth, (pageWidth - textWidth) / 2)
|
||||||
|
) + xOffset;
|
||||||
|
y = pageHeight - safeV - fontSize + yOffset;
|
||||||
|
break;
|
||||||
|
case 'top-left':
|
||||||
|
x = safeH + xOffset;
|
||||||
|
y = pageHeight - safeV - fontSize + yOffset;
|
||||||
|
break;
|
||||||
|
case 'top-right':
|
||||||
|
x = Math.max(safeH, pageWidth - safeH - textWidth) + xOffset;
|
||||||
|
y = pageHeight - safeV - fontSize + yOffset;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
x = Math.max(xOffset + 3, Math.min(xOffset + pageWidth - textWidth - 3, x));
|
||||||
|
y = Math.max(yOffset + 3, Math.min(yOffset + pageHeight - fontSize - 3, y));
|
||||||
|
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
files.length = 0;
|
||||||
|
const fileListEl = document.getElementById('file-list');
|
||||||
|
if (fileListEl) fileListEl.innerHTML = '';
|
||||||
|
document.getElementById('options-panel')?.classList.add('hidden');
|
||||||
|
document.getElementById('file-controls')?.classList.add('hidden');
|
||||||
|
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyBatesNumbers() {
|
||||||
|
if (files.length === 0) {
|
||||||
|
showAlert('Error', 'Please upload at least one PDF file.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoader('Applying Bates numbers...');
|
||||||
|
try {
|
||||||
|
const template = (
|
||||||
|
document.getElementById('bates-template') as HTMLInputElement
|
||||||
|
).value;
|
||||||
|
const padding = getActivePadding();
|
||||||
|
const batesStart =
|
||||||
|
parseInt(
|
||||||
|
(document.getElementById('bates-start') as HTMLInputElement).value
|
||||||
|
) || 1;
|
||||||
|
const fileStart =
|
||||||
|
parseInt(
|
||||||
|
(document.getElementById('file-start') as HTMLInputElement).value
|
||||||
|
) || 1;
|
||||||
|
const position = (document.getElementById('position') as HTMLSelectElement)
|
||||||
|
.value as Position;
|
||||||
|
const fontKey = (
|
||||||
|
document.getElementById('font-family') as HTMLSelectElement
|
||||||
|
).value;
|
||||||
|
const fontSize =
|
||||||
|
parseInt(
|
||||||
|
(document.getElementById('font-size') as HTMLInputElement).value
|
||||||
|
) || 10;
|
||||||
|
const colorHex = (document.getElementById('text-color') as HTMLInputElement)
|
||||||
|
.value;
|
||||||
|
const textColor = hexToRgb(colorHex);
|
||||||
|
|
||||||
|
const fontName = FONT_MAP[fontKey] || 'Helvetica';
|
||||||
|
const results: { name: string; bytes: Uint8Array }[] = [];
|
||||||
|
let batesCounter = batesStart;
|
||||||
|
let fileCounter = fileStart;
|
||||||
|
|
||||||
|
for (const entry of files) {
|
||||||
|
const arrayBuffer = await entry.file.arrayBuffer();
|
||||||
|
const pdfDoc = await PDFDocument.load(arrayBuffer);
|
||||||
|
const font = await pdfDoc.embedFont(StandardFonts[fontName]);
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
const fileName = entry.file.name.replace(/\.pdf$/i, '');
|
||||||
|
|
||||||
|
for (let i = 0; i < pages.length; i++) {
|
||||||
|
const page = pages[i];
|
||||||
|
const bounds = page.getCropBox() || page.getMediaBox();
|
||||||
|
const text = formatBatesText(
|
||||||
|
template,
|
||||||
|
batesCounter,
|
||||||
|
i + 1,
|
||||||
|
fileCounter,
|
||||||
|
fileName,
|
||||||
|
padding
|
||||||
|
);
|
||||||
|
const textWidth = font.widthOfTextAtSize(text, fontSize);
|
||||||
|
|
||||||
|
const { x, y } = calculatePosition(
|
||||||
|
bounds.width,
|
||||||
|
bounds.height,
|
||||||
|
bounds.x || 0,
|
||||||
|
bounds.y || 0,
|
||||||
|
textWidth,
|
||||||
|
fontSize,
|
||||||
|
position
|
||||||
|
);
|
||||||
|
|
||||||
|
page.drawText(text, {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
font,
|
||||||
|
size: fontSize,
|
||||||
|
color: rgb(textColor.r, textColor.g, textColor.b),
|
||||||
|
});
|
||||||
|
|
||||||
|
batesCounter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileCounter++;
|
||||||
|
const pdfBytes = await pdfDoc.save();
|
||||||
|
results.push({
|
||||||
|
name: `bates_${entry.file.name}`,
|
||||||
|
bytes: new Uint8Array(pdfBytes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 1) {
|
||||||
|
downloadFile(
|
||||||
|
new Blob([new Uint8Array(results[0].bytes)], {
|
||||||
|
type: 'application/pdf',
|
||||||
|
}),
|
||||||
|
results[0].name
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const zip = new JSZip();
|
||||||
|
for (const result of results) {
|
||||||
|
zip.file(result.name, result.bytes);
|
||||||
|
}
|
||||||
|
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
downloadFile(zipBlob, 'bates_numbered.zip');
|
||||||
|
}
|
||||||
|
|
||||||
|
showAlert(
|
||||||
|
'Success',
|
||||||
|
`Bates numbers applied successfully! (${batesStart} through ${batesCounter - 1})`,
|
||||||
|
'success',
|
||||||
|
() => {
|
||||||
|
resetState();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showAlert('Error', 'Failed to apply Bates numbers.');
|
||||||
|
} finally {
|
||||||
|
hideLoader();
|
||||||
|
}
|
||||||
|
}
|
||||||
+217
-178
@@ -2,82 +2,94 @@ import { PdfSigner, type SignOption } from 'zgapdfsigner';
|
|||||||
import forge from 'node-forge';
|
import forge from 'node-forge';
|
||||||
import { CertificateData, SignPdfOptions } from '@/types';
|
import { CertificateData, SignPdfOptions } from '@/types';
|
||||||
|
|
||||||
export function parsePfxFile(pfxBytes: ArrayBuffer, password: string): CertificateData {
|
export function parsePfxFile(
|
||||||
const pfxAsn1 = forge.asn1.fromDer(forge.util.createBuffer(new Uint8Array(pfxBytes)));
|
pfxBytes: ArrayBuffer,
|
||||||
const pfx = forge.pkcs12.pkcs12FromAsn1(pfxAsn1, password);
|
password: string
|
||||||
|
): CertificateData {
|
||||||
|
const pfxAsn1 = forge.asn1.fromDer(
|
||||||
|
forge.util.createBuffer(new Uint8Array(pfxBytes))
|
||||||
|
);
|
||||||
|
const pfx = forge.pkcs12.pkcs12FromAsn1(pfxAsn1, password);
|
||||||
|
|
||||||
const certBags = pfx.getBags({ bagType: forge.pki.oids.certBag });
|
const certBags = pfx.getBags({ bagType: forge.pki.oids.certBag });
|
||||||
const keyBags = pfx.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
|
const keyBags = pfx.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
|
||||||
|
|
||||||
const certBagArray = certBags[forge.pki.oids.certBag];
|
const certBagArray = certBags[forge.pki.oids.certBag];
|
||||||
const keyBagArray = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag];
|
const keyBagArray = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag];
|
||||||
|
|
||||||
if (!certBagArray || certBagArray.length === 0) {
|
if (!certBagArray || certBagArray.length === 0) {
|
||||||
throw new Error('No certificate found in PFX file');
|
throw new Error('No certificate found in PFX file');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!keyBagArray || keyBagArray.length === 0) {
|
if (!keyBagArray || keyBagArray.length === 0) {
|
||||||
throw new Error('No private key found in PFX file');
|
throw new Error('No private key found in PFX file');
|
||||||
}
|
}
|
||||||
|
|
||||||
const certificate = certBagArray[0].cert;
|
const certificate = certBagArray[0].cert;
|
||||||
|
|
||||||
if (!certificate) {
|
if (!certificate) {
|
||||||
throw new Error('Failed to extract certificate from PFX file');
|
throw new Error('Failed to extract certificate from PFX file');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { p12Buffer: pfxBytes, password, certificate };
|
return { p12Buffer: pfxBytes, password, certificate };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePemFiles(
|
export function parsePemFiles(
|
||||||
certPem: string,
|
certPem: string,
|
||||||
keyPem: string,
|
keyPem: string,
|
||||||
keyPassword?: string
|
keyPassword?: string
|
||||||
): CertificateData {
|
): CertificateData {
|
||||||
const certificate = forge.pki.certificateFromPem(certPem);
|
const certificate = forge.pki.certificateFromPem(certPem);
|
||||||
|
|
||||||
let privateKey: forge.pki.PrivateKey;
|
let privateKey: forge.pki.PrivateKey;
|
||||||
if (keyPem.includes('ENCRYPTED')) {
|
if (keyPem.includes('ENCRYPTED')) {
|
||||||
if (!keyPassword) {
|
if (!keyPassword) {
|
||||||
throw new Error('Password required for encrypted private key');
|
throw new Error('Password required for encrypted private key');
|
||||||
}
|
|
||||||
privateKey = forge.pki.decryptRsaPrivateKey(keyPem, keyPassword);
|
|
||||||
if (!privateKey) {
|
|
||||||
throw new Error('Failed to decrypt private key');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
privateKey = forge.pki.privateKeyFromPem(keyPem);
|
|
||||||
}
|
}
|
||||||
|
privateKey = forge.pki.decryptRsaPrivateKey(keyPem, keyPassword);
|
||||||
const p12Password = keyPassword || 'temp-password';
|
if (!privateKey) {
|
||||||
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
|
throw new Error('Failed to decrypt private key');
|
||||||
privateKey,
|
|
||||||
[certificate],
|
|
||||||
p12Password,
|
|
||||||
{ algorithm: '3des' }
|
|
||||||
);
|
|
||||||
const p12Der = forge.asn1.toDer(p12Asn1).getBytes();
|
|
||||||
const p12Buffer = new Uint8Array(p12Der.length);
|
|
||||||
for (let i = 0; i < p12Der.length; i++) {
|
|
||||||
p12Buffer[i] = p12Der.charCodeAt(i);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
privateKey = forge.pki.privateKeyFromPem(keyPem);
|
||||||
|
}
|
||||||
|
|
||||||
return { p12Buffer: p12Buffer.buffer, password: p12Password, certificate };
|
const p12Password = keyPassword || crypto.randomUUID();
|
||||||
|
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
|
||||||
|
privateKey,
|
||||||
|
[certificate],
|
||||||
|
p12Password,
|
||||||
|
{ algorithm: '3des' }
|
||||||
|
);
|
||||||
|
const p12Der = forge.asn1.toDer(p12Asn1).getBytes();
|
||||||
|
const p12Buffer = new Uint8Array(p12Der.length);
|
||||||
|
for (let i = 0; i < p12Der.length; i++) {
|
||||||
|
p12Buffer[i] = p12Der.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { p12Buffer: p12Buffer.buffer, password: p12Password, certificate };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCombinedPem(pemContent: string, password?: string): CertificateData {
|
export function parseCombinedPem(
|
||||||
const certMatch = pemContent.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/);
|
pemContent: string,
|
||||||
const keyMatch = pemContent.match(/-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/);
|
password?: string
|
||||||
|
): CertificateData {
|
||||||
|
const certMatch = pemContent.match(
|
||||||
|
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/
|
||||||
|
);
|
||||||
|
const keyMatch = pemContent.match(
|
||||||
|
/-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/
|
||||||
|
);
|
||||||
|
|
||||||
if (!certMatch) {
|
if (!certMatch) {
|
||||||
throw new Error('No certificate found in PEM file');
|
throw new Error('No certificate found in PEM file');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!keyMatch) {
|
if (!keyMatch) {
|
||||||
throw new Error('No private key found in PEM file');
|
throw new Error('No private key found in PEM file');
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsePemFiles(certMatch[0], keyMatch[0], password);
|
return parsePemFiles(certMatch[0], keyMatch[0], password);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,26 +128,29 @@ const CORS_PROXY_URL = import.meta.env.VITE_CORS_PROXY_URL || '';
|
|||||||
*/
|
*/
|
||||||
const CORS_PROXY_SECRET = import.meta.env.VITE_CORS_PROXY_SECRET || '';
|
const CORS_PROXY_SECRET = import.meta.env.VITE_CORS_PROXY_SECRET || '';
|
||||||
|
|
||||||
async function generateProxySignature(url: string, timestamp: number): Promise<string> {
|
async function generateProxySignature(
|
||||||
const encoder = new TextEncoder();
|
url: string,
|
||||||
const key = await crypto.subtle.importKey(
|
timestamp: number
|
||||||
'raw',
|
): Promise<string> {
|
||||||
encoder.encode(CORS_PROXY_SECRET),
|
const encoder = new TextEncoder();
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
const key = await crypto.subtle.importKey(
|
||||||
false,
|
'raw',
|
||||||
['sign']
|
encoder.encode(CORS_PROXY_SECRET),
|
||||||
);
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
const message = `${url}${timestamp}`;
|
const message = `${url}${timestamp}`;
|
||||||
const signature = await crypto.subtle.sign(
|
const signature = await crypto.subtle.sign(
|
||||||
'HMAC',
|
'HMAC',
|
||||||
key,
|
key,
|
||||||
encoder.encode(message)
|
encoder.encode(message)
|
||||||
);
|
);
|
||||||
|
|
||||||
return Array.from(new Uint8Array(signature))
|
return Array.from(new Uint8Array(signature))
|
||||||
.map(b => b.toString(16).padStart(2, '0'))
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,139 +160,163 @@ async function generateProxySignature(url: string, timestamp: number): Promise<s
|
|||||||
* the fetch fails. This wrapper routes such requests through our CORS proxy.
|
* the fetch fails. This wrapper routes such requests through our CORS proxy.
|
||||||
*
|
*
|
||||||
* If VITE_CORS_PROXY_SECRET is configured, requests include HMAC signatures for anti-spoofing.
|
* If VITE_CORS_PROXY_SECRET is configured, requests include HMAC signatures for anti-spoofing.
|
||||||
|
*
|
||||||
*/
|
*/
|
||||||
|
let fetchWrapRefCount = 0;
|
||||||
|
let savedOriginalFetch: typeof fetch | null = null;
|
||||||
|
|
||||||
function createCorsAwareFetch(): {
|
function createCorsAwareFetch(): {
|
||||||
wrappedFetch: typeof fetch;
|
wrappedFetch: typeof fetch;
|
||||||
restore: () => void;
|
restore: () => void;
|
||||||
} {
|
} {
|
||||||
const originalFetch = window.fetch.bind(window);
|
if (fetchWrapRefCount === 0) {
|
||||||
|
savedOriginalFetch = window.fetch.bind(window);
|
||||||
|
|
||||||
const wrappedFetch: typeof fetch = async (input, init) => {
|
const originalFetch = savedOriginalFetch;
|
||||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
||||||
|
|
||||||
const isExternalCertificateUrl = (
|
window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
url.includes('.crt') ||
|
const url =
|
||||||
url.includes('.cer') ||
|
typeof input === 'string'
|
||||||
url.includes('.pem') ||
|
? input
|
||||||
url.includes('/certs/') ||
|
: input instanceof URL
|
||||||
url.includes('/ocsp') ||
|
? input.toString()
|
||||||
url.includes('/crl') ||
|
: input.url;
|
||||||
url.includes('caIssuers')
|
|
||||||
) && !url.startsWith(window.location.origin);
|
|
||||||
|
|
||||||
if (isExternalCertificateUrl && CORS_PROXY_URL) {
|
const isExternalCertificateUrl =
|
||||||
let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`;
|
(url.includes('.crt') ||
|
||||||
|
url.includes('.cer') ||
|
||||||
|
url.includes('.pem') ||
|
||||||
|
url.includes('/certs/') ||
|
||||||
|
url.includes('/ocsp') ||
|
||||||
|
url.includes('/crl') ||
|
||||||
|
url.includes('caIssuers')) &&
|
||||||
|
!url.startsWith(window.location.origin);
|
||||||
|
|
||||||
if (CORS_PROXY_SECRET) {
|
if (isExternalCertificateUrl && CORS_PROXY_URL) {
|
||||||
const timestamp = Date.now();
|
let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`;
|
||||||
const signature = await generateProxySignature(url, timestamp);
|
|
||||||
proxyUrl += `&t=${timestamp}&sig=${signature}`;
|
|
||||||
console.log(`[CORS Proxy] Routing signed certificate request through proxy: ${url}`);
|
|
||||||
} else {
|
|
||||||
console.log(`[CORS Proxy] Routing certificate request through proxy: ${url}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return originalFetch(proxyUrl, init);
|
if (CORS_PROXY_SECRET) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const signature = await generateProxySignature(url, timestamp);
|
||||||
|
proxyUrl += `&t=${timestamp}&sig=${signature}`;
|
||||||
|
console.log(
|
||||||
|
`[CORS Proxy] Routing signed certificate request through proxy: ${url}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[CORS Proxy] Routing certificate request through proxy: ${url}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return originalFetch(input, init);
|
return originalFetch(proxyUrl, init);
|
||||||
};
|
}
|
||||||
|
|
||||||
window.fetch = wrappedFetch;
|
return originalFetch(input, init);
|
||||||
|
}) as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
fetchWrapRefCount++;
|
||||||
wrappedFetch,
|
|
||||||
restore: () => {
|
return {
|
||||||
window.fetch = originalFetch;
|
wrappedFetch: window.fetch,
|
||||||
}
|
restore: () => {
|
||||||
};
|
fetchWrapRefCount--;
|
||||||
|
if (fetchWrapRefCount === 0 && savedOriginalFetch) {
|
||||||
|
window.fetch = savedOriginalFetch;
|
||||||
|
savedOriginalFetch = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function signPdf(
|
export async function signPdf(
|
||||||
pdfBytes: Uint8Array,
|
pdfBytes: Uint8Array,
|
||||||
certificateData: CertificateData,
|
certificateData: CertificateData,
|
||||||
options: SignPdfOptions = {}
|
options: SignPdfOptions = {}
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const signatureInfo = options.signatureInfo ?? {};
|
const signatureInfo = options.signatureInfo ?? {};
|
||||||
|
|
||||||
const signOptions: SignOption = {
|
const signOptions: SignOption = {
|
||||||
p12cert: certificateData.p12Buffer,
|
p12cert: certificateData.p12Buffer,
|
||||||
pwd: certificateData.password,
|
pwd: certificateData.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signatureInfo.reason) {
|
||||||
|
signOptions.reason = signatureInfo.reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatureInfo.location) {
|
||||||
|
signOptions.location = signatureInfo.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatureInfo.contactInfo) {
|
||||||
|
signOptions.contact = signatureInfo.contactInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.visibleSignature?.enabled) {
|
||||||
|
const vs = options.visibleSignature;
|
||||||
|
|
||||||
|
const drawinf = {
|
||||||
|
area: {
|
||||||
|
x: vs.x,
|
||||||
|
y: vs.y,
|
||||||
|
w: vs.width,
|
||||||
|
h: vs.height,
|
||||||
|
},
|
||||||
|
pageidx: vs.page,
|
||||||
|
imgInfo: undefined as
|
||||||
|
| { imgData: ArrayBuffer; imgType: string }
|
||||||
|
| undefined,
|
||||||
|
textInfo: undefined as
|
||||||
|
| { text: string; size: number; color: string }
|
||||||
|
| undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (signatureInfo.reason) {
|
if (vs.imageData && vs.imageType) {
|
||||||
signOptions.reason = signatureInfo.reason;
|
drawinf.imgInfo = {
|
||||||
|
imgData: vs.imageData,
|
||||||
|
imgType: vs.imageType,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signatureInfo.location) {
|
if (vs.text) {
|
||||||
signOptions.location = signatureInfo.location;
|
drawinf.textInfo = {
|
||||||
|
text: vs.text,
|
||||||
|
size: vs.textSize ?? 12,
|
||||||
|
color: vs.textColor ?? '#000000',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signatureInfo.contactInfo) {
|
signOptions.drawinf = drawinf as SignOption['drawinf'];
|
||||||
signOptions.contact = signatureInfo.contactInfo;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (options.visibleSignature?.enabled) {
|
const signer = new PdfSigner(signOptions);
|
||||||
const vs = options.visibleSignature;
|
|
||||||
|
|
||||||
const drawinf = {
|
const { restore } = createCorsAwareFetch();
|
||||||
area: {
|
|
||||||
x: vs.x,
|
|
||||||
y: vs.y,
|
|
||||||
w: vs.width,
|
|
||||||
h: vs.height,
|
|
||||||
},
|
|
||||||
pageidx: vs.page,
|
|
||||||
imgInfo: undefined as { imgData: ArrayBuffer; imgType: string } | undefined,
|
|
||||||
textInfo: undefined as { text: string; size: number; color: string } | undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (vs.imageData && vs.imageType) {
|
try {
|
||||||
drawinf.imgInfo = {
|
const signedPdfBytes = await signer.sign(pdfBytes);
|
||||||
imgData: vs.imageData,
|
return new Uint8Array(signedPdfBytes);
|
||||||
imgType: vs.imageType,
|
} finally {
|
||||||
};
|
restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vs.text) {
|
|
||||||
drawinf.textInfo = {
|
|
||||||
text: vs.text,
|
|
||||||
size: vs.textSize ?? 12,
|
|
||||||
color: vs.textColor ?? '#000000',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
signOptions.drawinf = drawinf as SignOption['drawinf'];
|
|
||||||
}
|
|
||||||
|
|
||||||
const signer = new PdfSigner(signOptions);
|
|
||||||
|
|
||||||
const { restore } = createCorsAwareFetch();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const signedPdfBytes = await signer.sign(pdfBytes);
|
|
||||||
return new Uint8Array(signedPdfBytes);
|
|
||||||
} finally {
|
|
||||||
restore();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCertificateInfo(certificate: forge.pki.Certificate): {
|
export function getCertificateInfo(certificate: forge.pki.Certificate): {
|
||||||
subject: string;
|
subject: string;
|
||||||
issuer: string;
|
issuer: string;
|
||||||
validFrom: Date;
|
validFrom: Date;
|
||||||
validTo: Date;
|
validTo: Date;
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
} {
|
} {
|
||||||
const subjectCN = certificate.subject.getField('CN');
|
const subjectCN = certificate.subject.getField('CN');
|
||||||
const issuerCN = certificate.issuer.getField('CN');
|
const issuerCN = certificate.issuer.getField('CN');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subject: subjectCN?.value as string ?? 'Unknown',
|
subject: (subjectCN?.value as string) ?? 'Unknown',
|
||||||
issuer: issuerCN?.value as string ?? 'Unknown',
|
issuer: (issuerCN?.value as string) ?? 'Unknown',
|
||||||
validFrom: certificate.validity.notBefore,
|
validFrom: certificate.validity.notBefore,
|
||||||
validTo: certificate.validity.notAfter,
|
validTo: certificate.validity.notAfter,
|
||||||
serialNumber: certificate.serialNumber,
|
serialNumber: certificate.serialNumber,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+98
-5
@@ -255,19 +255,78 @@ const init = async () => {
|
|||||||
if (dom.toolGrid) {
|
if (dom.toolGrid) {
|
||||||
dom.toolGrid.textContent = '';
|
dom.toolGrid.textContent = '';
|
||||||
|
|
||||||
|
let collapsedCategories: string[] = [];
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('collapsedCategories');
|
||||||
|
if (stored) collapsedCategories = JSON.parse(stored);
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('collapsedCategories');
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCollapsedCategories() {
|
||||||
|
localStorage.setItem(
|
||||||
|
'collapsedCategories',
|
||||||
|
JSON.stringify(collapsedCategories)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
categories.forEach((category) => {
|
categories.forEach((category) => {
|
||||||
const categoryGroup = document.createElement('div');
|
const categoryGroup = document.createElement('div');
|
||||||
categoryGroup.className = 'category-group col-span-full';
|
categoryGroup.className = 'category-group col-span-full';
|
||||||
|
|
||||||
const title = document.createElement('h2');
|
const header = document.createElement('button');
|
||||||
title.className =
|
header.className = 'category-header';
|
||||||
'text-xl font-bold text-indigo-400 mb-4 mt-8 first:mt-0 text-white';
|
header.type = 'button';
|
||||||
|
|
||||||
|
const title = document.createElement('span');
|
||||||
const categoryKey = categoryTranslationKeys[category.name];
|
const categoryKey = categoryTranslationKeys[category.name];
|
||||||
title.textContent = categoryKey ? t(categoryKey) : category.name;
|
title.textContent = categoryKey ? t(categoryKey) : category.name;
|
||||||
|
|
||||||
|
const chevron = document.createElement('i');
|
||||||
|
chevron.setAttribute('data-lucide', 'chevron-down');
|
||||||
|
chevron.className =
|
||||||
|
'category-chevron w-5 h-5 text-gray-400 transition-transform duration-300';
|
||||||
|
|
||||||
|
header.append(title, chevron);
|
||||||
|
|
||||||
const toolsContainer = document.createElement('div');
|
const toolsContainer = document.createElement('div');
|
||||||
toolsContainer.className =
|
toolsContainer.className =
|
||||||
'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6';
|
'category-tools grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6';
|
||||||
|
|
||||||
|
const isCollapsed = collapsedCategories.includes(category.name);
|
||||||
|
if (isCollapsed) {
|
||||||
|
categoryGroup.classList.add('collapsed');
|
||||||
|
toolsContainer.style.maxHeight = '0px';
|
||||||
|
}
|
||||||
|
|
||||||
|
toolsContainer.addEventListener('transitionend', (e) => {
|
||||||
|
if ((e as TransitionEvent).propertyName !== 'max-height') return;
|
||||||
|
if (!categoryGroup.classList.contains('collapsed')) {
|
||||||
|
toolsContainer.style.maxHeight = 'none';
|
||||||
|
toolsContainer.style.overflow = 'visible';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
header.addEventListener('click', () => {
|
||||||
|
const collapsed = categoryGroup.classList.toggle('collapsed');
|
||||||
|
if (collapsed) {
|
||||||
|
toolsContainer.style.maxHeight = toolsContainer.scrollHeight + 'px';
|
||||||
|
toolsContainer.style.overflow = 'hidden';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
toolsContainer.style.maxHeight = '0px';
|
||||||
|
});
|
||||||
|
if (!collapsedCategories.includes(category.name)) {
|
||||||
|
collapsedCategories.push(category.name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toolsContainer.style.overflow = 'hidden';
|
||||||
|
toolsContainer.style.maxHeight = toolsContainer.scrollHeight + 'px';
|
||||||
|
collapsedCategories = collapsedCategories.filter(
|
||||||
|
(n) => n !== category.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
saveCollapsedCategories();
|
||||||
|
});
|
||||||
|
|
||||||
category.tools.forEach((tool) => {
|
category.tools.forEach((tool) => {
|
||||||
let toolCard: HTMLDivElement | HTMLAnchorElement;
|
let toolCard: HTMLDivElement | HTMLAnchorElement;
|
||||||
@@ -312,8 +371,13 @@ const init = async () => {
|
|||||||
toolsContainer.appendChild(toolCard);
|
toolsContainer.appendChild(toolCard);
|
||||||
});
|
});
|
||||||
|
|
||||||
categoryGroup.append(title, toolsContainer);
|
categoryGroup.append(header, toolsContainer);
|
||||||
dom.toolGrid.appendChild(categoryGroup);
|
dom.toolGrid.appendChild(categoryGroup);
|
||||||
|
|
||||||
|
if (!isCollapsed) {
|
||||||
|
toolsContainer.style.maxHeight = 'none';
|
||||||
|
toolsContainer.style.overflow = 'visible';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchBar = document.getElementById('search-bar');
|
const searchBar = document.getElementById('search-bar');
|
||||||
@@ -547,6 +611,35 @@ const init = async () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const compactModeToggle = document.getElementById(
|
||||||
|
'compact-mode-toggle'
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
const savedCompactMode = localStorage.getItem('compactMode') === 'true';
|
||||||
|
if (compactModeToggle) {
|
||||||
|
compactModeToggle.checked = savedCompactMode;
|
||||||
|
}
|
||||||
|
applyCompactMode(savedCompactMode);
|
||||||
|
|
||||||
|
function applyCompactMode(enabled: boolean) {
|
||||||
|
if (dom.toolGrid) {
|
||||||
|
dom.toolGrid.classList.toggle('compact-mode', enabled);
|
||||||
|
dom.toolGrid
|
||||||
|
.querySelectorAll('.category-group:not(.collapsed) .category-tools')
|
||||||
|
.forEach((container) => {
|
||||||
|
(container as HTMLElement).style.maxHeight = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compactModeToggle) {
|
||||||
|
compactModeToggle.addEventListener('change', (e) => {
|
||||||
|
const enabled = (e.target as HTMLInputElement).checked;
|
||||||
|
localStorage.setItem('compactMode', enabled.toString());
|
||||||
|
applyCompactMode(enabled);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Shortcuts UI Handlers
|
// Shortcuts UI Handlers
|
||||||
if (dom.openShortcutsBtn) {
|
if (dom.openShortcutsBtn) {
|
||||||
dom.openShortcutsBtn.addEventListener('click', () => {
|
dom.openShortcutsBtn.addEventListener('click', () => {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export interface StylePreset {
|
||||||
|
template: string;
|
||||||
|
padding: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Position =
|
||||||
|
| 'bottom-center'
|
||||||
|
| 'bottom-left'
|
||||||
|
| 'bottom-right'
|
||||||
|
| 'top-center'
|
||||||
|
| 'top-left'
|
||||||
|
| 'top-right';
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
file: File;
|
||||||
|
pageCount: number;
|
||||||
|
}
|
||||||
@@ -49,3 +49,4 @@ export * from './email-to-pdf-type.ts';
|
|||||||
export * from './bookmark-pdf-type.ts';
|
export * from './bookmark-pdf-type.ts';
|
||||||
export * from './scanner-effect-type.ts';
|
export * from './scanner-effect-type.ts';
|
||||||
export * from './adjust-colors-type.ts';
|
export * from './adjust-colors-type.ts';
|
||||||
|
export * from './bates-numbering-type.ts';
|
||||||
|
|||||||
@@ -0,0 +1,548 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
|
<title>Bates Numbering Online Free - Add Bates Stamps | BentoPDF</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Add sequential Bates numbers to PDF documents. Supports multi-file processing, custom templates, and configurable formatting. Free, secure, browser-based."
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="keywords"
|
||||||
|
content="bates numbering, bates stamp, pdf bates, legal pdf numbering"
|
||||||
|
/>
|
||||||
|
<meta name="author" content="BentoPDF" />
|
||||||
|
<meta name="robots" content="index, follow" />
|
||||||
|
|
||||||
|
<link
|
||||||
|
rel="canonical"
|
||||||
|
href="https://www.bentopdf.com/bates-numbering.html"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta
|
||||||
|
property="og:url"
|
||||||
|
content="https://www.bentopdf.com/bates-numbering"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:title"
|
||||||
|
content="Bates Numbering Online Free | BentoPDF"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="Add sequential Bates numbers to PDF documents. Multi-file support, custom templates. Free and private."
|
||||||
|
/>
|
||||||
|
<meta property="og:site_name" content="BentoPDF" />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Bates Numbering Free" />
|
||||||
|
<meta name="twitter:site" content="@BentoPDF" />
|
||||||
|
|
||||||
|
<link href="/src/css/styles.css" rel="stylesheet" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/images/favicon-192x192.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/images/favicon-512x512.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/images/apple-touch-icon.png"
|
||||||
|
/>
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="antialiased bg-gray-900">
|
||||||
|
{{> navbar }}
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="uploader"
|
||||||
|
class="min-h-screen flex flex-col items-center justify-start py-12 p-4 bg-gray-900"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="tool-uploader"
|
||||||
|
class="bg-gray-800 rounded-xl shadow-xl px-4 py-8 md:p-8 max-w-2xl w-full text-gray-200 border border-gray-700"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
id="back-to-tools"
|
||||||
|
class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold"
|
||||||
|
>
|
||||||
|
<i data-lucide="arrow-left" class="cursor-pointer"></i>
|
||||||
|
<span class="cursor-pointer" data-i18n="tools.backToTools"
|
||||||
|
>Back to Tools</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
class="text-2xl font-bold text-white mb-2"
|
||||||
|
data-i18n="tools:batesNumbering.name"
|
||||||
|
>
|
||||||
|
Bates Numbering
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-400 mb-6" data-i18n="tools:batesNumbering.subtitle">
|
||||||
|
Add sequential Bates numbers across one or more PDF files.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="drop-zone"
|
||||||
|
class="relative flex flex-col items-center justify-center w-full h-48 md:h-64 border-2 border-dashed border-gray-600 rounded-xl cursor-pointer bg-gray-900 hover:bg-gray-700 transition-colors duration-300"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
||||||
|
<i
|
||||||
|
data-lucide="upload-cloud"
|
||||||
|
class="w-10 h-10 mb-3 text-gray-400"
|
||||||
|
></i>
|
||||||
|
<p class="mb-2 text-sm text-gray-400">
|
||||||
|
<span class="font-semibold" data-i18n="upload.clickToSelect"
|
||||||
|
>Click to select files</span
|
||||||
|
>
|
||||||
|
<span data-i18n="upload.orDragAndDrop">or drag and drop</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500">PDF files (multiple supported)</p>
|
||||||
|
<p class="text-xs text-gray-500" data-i18n="upload.filesNeverLeave">
|
||||||
|
Your files never leave your device.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="file-input"
|
||||||
|
type="file"
|
||||||
|
class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
multiple
|
||||||
|
accept="application/pdf"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="file-controls" class="hidden mt-4 flex gap-3">
|
||||||
|
<button
|
||||||
|
id="add-more-btn"
|
||||||
|
class="btn bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-4 py-2 rounded-lg flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<i data-lucide="plus"></i>
|
||||||
|
<span>Add More Files</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="clear-files-btn"
|
||||||
|
class="btn bg-gray-700 hover:bg-gray-600 text-white font-semibold px-4 py-2 rounded-lg flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<i data-lucide="trash-2"></i>
|
||||||
|
<span>Clear All</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="file-list" class="mt-4 space-y-2"></div>
|
||||||
|
|
||||||
|
<div id="options-panel" class="hidden mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="style-preset"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>Select Style</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="style-preset"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<optgroup label="Exhibit + Case + Bates + Page">
|
||||||
|
<option value="full-6">Exhibit 1 Case XYZ 000001 Page 1</option>
|
||||||
|
<option value="full-5">Exhibit 1 Case XYZ 00001 Page 1</option>
|
||||||
|
<option value="full-4">Exhibit 1 Case XYZ 0001 Page 1</option>
|
||||||
|
<option value="full-3">Exhibit 1 Case XYZ 001 Page 1</option>
|
||||||
|
<option value="full-0">Exhibit 1 Case XYZ 1 Page 1</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Exhibit + Case + Bates">
|
||||||
|
<option value="no-page-6">Exhibit 1 Case XYZ 000001</option>
|
||||||
|
<option value="no-page-5">Exhibit 1 Case XYZ 00001</option>
|
||||||
|
<option value="no-page-4">Exhibit 1 Case XYZ 0001</option>
|
||||||
|
<option value="no-page-3">Exhibit 1 Case XYZ 001</option>
|
||||||
|
<option value="no-page-0">Exhibit 1 Case XYZ 1</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Case + Bates">
|
||||||
|
<option value="case-6">Case XYZ 000001</option>
|
||||||
|
<option value="case-5">Case XYZ 00001</option>
|
||||||
|
<option value="case-4">Case XYZ 0001</option>
|
||||||
|
<option value="case-3">Case XYZ 001</option>
|
||||||
|
<option value="case-0">Case XYZ 1</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Bates Number Only">
|
||||||
|
<option value="bates-6" selected>000001, 000002, 000003</option>
|
||||||
|
<option value="bates-5">00001, 00002, 00003</option>
|
||||||
|
<option value="bates-4">0001, 0002, 0003</option>
|
||||||
|
<option value="bates-3">001, 002, 003</option>
|
||||||
|
<option value="bates-0">1, 2, 3</option>
|
||||||
|
</optgroup>
|
||||||
|
<option value="custom">Custom style (other)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="bates-template"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>Customize Style</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="bates-template"
|
||||||
|
value="[BATES]"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500 font-mono"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
Placeholders: [BATES] [PAGE] [FILE] [FILENAME]
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="preview-box"
|
||||||
|
class="bg-gray-900 border border-gray-700 rounded-lg p-4 text-sm text-gray-300 font-mono"
|
||||||
|
>
|
||||||
|
<div class="text-xs text-gray-500 mb-1">Preview:</div>
|
||||||
|
<div id="preview-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div id="padding-group" class="hidden">
|
||||||
|
<label
|
||||||
|
for="bates-padding"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>Bates Padding</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="bates-padding"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="6" selected>6 digits (000001)</option>
|
||||||
|
<option value="5">5 digits (00001)</option>
|
||||||
|
<option value="4">4 digits (0001)</option>
|
||||||
|
<option value="3">3 digits (001)</option>
|
||||||
|
<option value="0">No padding (1)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="position"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>Position</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="position"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="top-center">Header Center</option>
|
||||||
|
<option value="top-left">Header Left</option>
|
||||||
|
<option value="top-right">Header Right</option>
|
||||||
|
<option value="bottom-center" selected>Footer Center</option>
|
||||||
|
<option value="bottom-left">Footer Left</option>
|
||||||
|
<option value="bottom-right">Footer Right</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="bates-start"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>Bates Starts From</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="bates-start"
|
||||||
|
value="1"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="file-start"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>File Counter Starts From</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="file-start"
|
||||||
|
value="1"
|
||||||
|
min="1"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="font-family"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>Font</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="font-family"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="Helvetica">Helvetica</option>
|
||||||
|
<option value="TimesRoman">Times New Roman</option>
|
||||||
|
<option value="Courier">Courier</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="font-size"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>Font Size</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="font-size"
|
||||||
|
value="10"
|
||||||
|
min="6"
|
||||||
|
max="72"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="text-color"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>Color</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="text-color"
|
||||||
|
value="#000000"
|
||||||
|
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="process-btn" class="btn-gradient w-full mt-4">
|
||||||
|
Apply Bates Numbers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="loader-modal"
|
||||||
|
class="hidden fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-gray-800 p-8 rounded-lg flex flex-col items-center gap-4 border border-gray-700 shadow-xl"
|
||||||
|
>
|
||||||
|
<div class="solid-spinner"></div>
|
||||||
|
<p
|
||||||
|
id="loader-text"
|
||||||
|
class="text-white text-lg font-medium"
|
||||||
|
data-i18n="loader.processing"
|
||||||
|
>
|
||||||
|
Processing...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="alert-modal"
|
||||||
|
class="fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50 hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full border border-gray-700"
|
||||||
|
>
|
||||||
|
<h3 id="alert-title" class="text-xl font-bold text-white mb-2">
|
||||||
|
Alert
|
||||||
|
</h3>
|
||||||
|
<p id="alert-message" class="text-gray-300 mb-6"></p>
|
||||||
|
<button
|
||||||
|
id="alert-ok"
|
||||||
|
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||||
|
<h2
|
||||||
|
class="text-2xl md:text-3xl font-bold text-white mb-8 text-center"
|
||||||
|
data-i18n="howItWorks.title"
|
||||||
|
>
|
||||||
|
How It Works
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-1">Upload Files</h3>
|
||||||
|
<p class="text-gray-400">
|
||||||
|
Upload one or more PDF files. Drag to reorder for multi-file
|
||||||
|
sequencing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
|
||||||
|
>
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-1">
|
||||||
|
Configure Style
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-400">
|
||||||
|
Choose a preset or create a custom template with placeholders like
|
||||||
|
[BATES], [PAGE], [FILE].
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
|
||||||
|
>
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-1">Download</h3>
|
||||||
|
<p class="text-gray-400">
|
||||||
|
Get your bates-stamped PDFs instantly. Multiple files are
|
||||||
|
delivered as a ZIP.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="max-w-6xl mx-auto px-4 py-12">
|
||||||
|
<h2
|
||||||
|
class="text-2xl md:text-3xl font-bold text-white mb-6 text-center"
|
||||||
|
data-i18n="relatedTools.title"
|
||||||
|
>
|
||||||
|
Related PDF Tools
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||||
|
<a
|
||||||
|
href="page-numbers.html"
|
||||||
|
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
|
||||||
|
>
|
||||||
|
<h3 class="text-white font-semibold mb-1">Page Numbers</h3>
|
||||||
|
<p class="text-gray-400 text-sm">Add page numbers to PDFs</p>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="header-footer.html"
|
||||||
|
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
|
||||||
|
>
|
||||||
|
<h3 class="text-white font-semibold mb-1">Header & Footer</h3>
|
||||||
|
<p class="text-gray-400 text-sm">
|
||||||
|
Add text to page headers and footers
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="add-watermark.html"
|
||||||
|
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
|
||||||
|
>
|
||||||
|
<h3 class="text-white font-semibold mb-1">Add Watermark</h3>
|
||||||
|
<p class="text-gray-400 text-sm">Watermark your PDF documents</p>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="merge-pdf.html"
|
||||||
|
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
|
||||||
|
>
|
||||||
|
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
|
||||||
|
<p class="text-gray-400 text-sm">Combine multiple PDFs</p>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="organize-pdf.html"
|
||||||
|
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
|
||||||
|
>
|
||||||
|
<h3 class="text-white font-semibold mb-1">Organize PDF</h3>
|
||||||
|
<p class="text-gray-400 text-sm">Reorder and organize pages</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||||
|
<h2
|
||||||
|
class="text-2xl md:text-3xl font-bold text-white mb-6 text-center"
|
||||||
|
data-i18n="faq.sectionTitle"
|
||||||
|
>
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
|
||||||
|
<summary
|
||||||
|
class="cursor-pointer font-semibold text-white flex items-center justify-between"
|
||||||
|
>
|
||||||
|
What is Bates numbering?
|
||||||
|
<i data-lucide="chevron-down" class="w-5 h-5"></i>
|
||||||
|
</summary>
|
||||||
|
<p class="mt-3 text-gray-400">
|
||||||
|
Bates numbering is a method of indexing legal documents for easy
|
||||||
|
identification and retrieval. Each page receives a unique sequential
|
||||||
|
number, often combined with a prefix or suffix.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
|
||||||
|
<summary
|
||||||
|
class="cursor-pointer font-semibold text-white flex items-center justify-between"
|
||||||
|
>
|
||||||
|
Can I number multiple files at once?
|
||||||
|
<i data-lucide="chevron-down" class="w-5 h-5"></i>
|
||||||
|
</summary>
|
||||||
|
<p class="mt-3 text-gray-400">
|
||||||
|
Yes! Upload multiple PDFs and the bates numbers will continue
|
||||||
|
sequentially across all files. Drag files to reorder them.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
|
||||||
|
<summary
|
||||||
|
class="cursor-pointer font-semibold text-white flex items-center justify-between"
|
||||||
|
>
|
||||||
|
Are my files secure?
|
||||||
|
<i data-lucide="chevron-down" class="w-5 h-5"></i>
|
||||||
|
</summary>
|
||||||
|
<p class="mt-3 text-gray-400">
|
||||||
|
All processing happens entirely in your browser. Your files never
|
||||||
|
leave your device.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{> footer }}
|
||||||
|
<script type="module" src="/src/js/utils/lucide-init.ts"></script>
|
||||||
|
<script type="module" src="/src/js/utils/full-width.ts"></script>
|
||||||
|
<script type="module" src="/src/js/utils/simple-mode-footer.ts"></script>
|
||||||
|
<script type="module" src="/src/version.ts"></script>
|
||||||
|
<script type="module" src="/src/js/logic/bates-numbering-page.ts"></script>
|
||||||
|
<script type="module" src="/src/js/mobileMenu.ts"></script>
|
||||||
|
<script type="module" src="/src/js/main.ts"></script>
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "SoftwareApplication",
|
||||||
|
"name": "Bates Numbering - BentoPDF",
|
||||||
|
"applicationCategory": "PDF Tool",
|
||||||
|
"operatingSystem": "Any - Web Browser",
|
||||||
|
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+7
-1
@@ -508,6 +508,12 @@
|
|||||||
category: 'editor',
|
category: 'editor',
|
||||||
icon: 'list-numbers',
|
icon: 'list-numbers',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'bates-numbering',
|
||||||
|
title: 'Bates Numbering',
|
||||||
|
category: 'editor',
|
||||||
|
icon: 'hash',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'background-color',
|
name: 'background-color',
|
||||||
title: 'Background Color',
|
title: 'Background Color',
|
||||||
@@ -821,7 +827,7 @@
|
|||||||
grid.innerHTML = filtered
|
grid.innerHTML = filtered
|
||||||
.map(
|
.map(
|
||||||
(tool) => `
|
(tool) => `
|
||||||
<a href="/src/pages/${tool.name}.html" class="tool-card">
|
<a href="/${tool.name}.html" class="tool-card">
|
||||||
<i class="ph ph-${tool.icon} text-4xl text-indigo-400 mb-2"></i>
|
<i class="ph ph-${tool.icon} text-4xl text-indigo-400 mb-2"></i>
|
||||||
<h3 class="font-semibold text-white text-sm">${tool.title}</h3>
|
<h3 class="font-semibold text-white text-sm">${tool.title}</h3>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -559,6 +559,10 @@ export default defineConfig(() => {
|
|||||||
),
|
),
|
||||||
'deskew-pdf': resolve(__dirname, 'src/pages/deskew-pdf.html'),
|
'deskew-pdf': resolve(__dirname, 'src/pages/deskew-pdf.html'),
|
||||||
'wasm-settings': resolve(__dirname, 'src/pages/wasm-settings.html'),
|
'wasm-settings': resolve(__dirname, 'src/pages/wasm-settings.html'),
|
||||||
|
'bates-numbering': resolve(
|
||||||
|
__dirname,
|
||||||
|
'src/pages/bates-numbering.html'
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user