feat(tools): 2.0 phase 5 wave 2 - pdf depth (21 tools) (#220)

This commit is contained in:
SnapOtter
2026-06-13 10:18:55 +08:00
parent ae1337901d
commit 2f39e38162
128 changed files with 11381 additions and 778 deletions
@@ -1,54 +1,67 @@
{
"version": "6",
"dialect": "sqlite",
"id": "c7909605-aabf-4832-8ef8-9390c0f7c99a",
"prevId": "00000000-0000-0000-0000-000000000000",
"id": "b83e1f2a-9c4d-4e7b-a1f3-8d2c6b5a4e90",
"prevId": "47a9d637-64e3-4cca-a916-cf42ea63b335",
"version": "7",
"dialect": "postgresql",
"tables": {
"api_keys": {
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": true
},
"key_hash": {
"name": "key_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": true
},
"key_prefix": {
"name": "key_prefix",
"type": "text",
"primaryKey": false,
"notNull": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'Default API Key'"
},
"permissions": {
"name": "permissions",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": true
},
"last_used_at": {
"name": "last_used_at",
"type": "integer",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"autoincrement": false
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
@@ -65,120 +78,423 @@
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"jobs": {
"name": "jobs",
"public.audit_log": {
"name": "audit_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
"notNull": true
},
"type": {
"name": "type",
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": false
},
"status": {
"name": "status",
"actor_username": {
"name": "actor_username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'queued'"
"notNull": true
},
"progress": {
"name": "progress",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"input_files": {
"name": "input_files",
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": true
},
"output_path": {
"name": "output_path",
"target_type": {
"name": "target_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
"notNull": false
},
"settings": {
"name": "settings",
"target_id": {
"name": "target_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
"notNull": false
},
"error": {
"name": "error",
"details": {
"name": "details",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"foreignKeys": {
"audit_log_actor_id_users_id_fk": {
"name": "audit_log_actor_id_users_id_fk",
"tableFrom": "audit_log",
"tableTo": "users",
"columnsFrom": ["actor_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"sessions": {
"name": "sessions",
"public.jobs": {
"name": "jobs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": false
},
"expires_at": {
"name": "expires_at",
"tool_id": {
"name": "tool_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pool": {
"name": "pool",
"type": "text",
"primaryKey": false,
"notNull": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "job_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"attempts": {
"name": "attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"default": 0
},
"progress": {
"name": "progress",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"input_refs": {
"name": "input_refs",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"output_refs": {
"name": "output_refs",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"bytes_in": {
"name": "bytes_in",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"bytes_out": {
"name": "bytes_out",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"jobs_created_at_idx": {
"name": "jobs_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"jobs_status_idx": {
"name": "jobs_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"jobs_user_id_users_id_fk": {
"name": "jobs_user_id_users_id_fk",
"tableFrom": "jobs",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.pipelines": {
"name": "pipelines",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"steps": {
"name": "steps",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"pipelines_user_id_users_id_fk": {
"name": "pipelines_user_id_users_id_fk",
"tableFrom": "pipelines",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.roles": {
"name": "roles",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"default": "''"
},
"permissions": {
"name": "permissions",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"is_builtin": {
"name": "is_builtin",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"roles_created_by_users_id_fk": {
"name": "roles_created_by_users_id_fk",
"tableFrom": "roles",
"tableTo": "users",
"columnsFrom": ["created_by"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"roles_name_unique": {
"name": "roles_name_unique",
"nullsNotDistinct": false,
"columns": ["name"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
@@ -195,115 +511,297 @@
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"settings": {
"public.settings": {
"name": "settings",
"schema": "",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"users": {
"name": "users",
"public.teams": {
"name": "teams",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"teams_name_unique": {
"name": "teams_name_unique",
"nullsNotDistinct": false,
"columns": ["name"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_files": {
"name": "user_files",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"original_name": {
"name": "original_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"stored_name": {
"name": "stored_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"default": 1
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"tool_chain": {
"name": "tool_chain",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"user_files_user_id_users_id_fk": {
"name": "user_files_user_id_users_id_fk",
"tableFrom": "user_files",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"team": {
"name": "team",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'Default'"
},
"must_change_password": {
"name": "must_change_password",
"type": "integer",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"auth_provider": {
"name": "auth_provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'local'"
},
"external_id": {
"name": "external_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": ["username"],
"isUnique": true
"notNull": true
},
"analytics_enabled": {
"name": "analytics_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"analytics_consent_shown_at": {
"name": "analytics_consent_shown_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"analytics_consent_remind_at": {
"name": "analytics_consent_remind_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": ["username"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
"enums": {
"public.job_status": {
"name": "job_status",
"schema": "public",
"values": ["queued", "processing", "completed", "failed", "canceled"]
}
},
"internal": {
"indexes": {}
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
@@ -1,67 +1,54 @@
{
"id": "b83e1f2a-9c4d-4e7b-a1f3-8d2c6b5a4e90",
"prevId": "47a9d637-64e3-4cca-a916-cf42ea63b335",
"version": "7",
"dialect": "postgresql",
"version": "6",
"dialect": "sqlite",
"id": "91a14a95-bbcb-46ef-abe3-6d2f6fbc8458",
"prevId": "c7909605-aabf-4832-8ef8-9390c0f7c99a",
"tables": {
"public.api_keys": {
"api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
},
"key_hash": {
"name": "key_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"key_prefix": {
"name": "key_prefix",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'Default API Key'"
},
"permissions": {
"name": "permissions",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"type": "integer",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp with time zone",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
@@ -78,423 +65,165 @@
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
"checkConstraints": {}
},
"public.audit_log": {
"name": "audit_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"actor_username": {
"name": "actor_username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"target_type": {
"name": "target_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"target_id": {
"name": "target_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"details": {
"name": "details",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"audit_log_actor_id_users_id_fk": {
"name": "audit_log_actor_id_users_id_fk",
"tableFrom": "audit_log",
"tableTo": "users",
"columnsFrom": ["actor_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.jobs": {
"jobs": {
"name": "jobs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"tool_id": {
"name": "tool_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pool": {
"name": "pool",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "job_status",
"typeSchema": "public",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'queued'"
},
"attempts": {
"name": "attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"progress": {
"name": "progress",
"type": "jsonb",
"type": "real",
"primaryKey": false,
"notNull": false
"notNull": true,
"autoincrement": false,
"default": 0
},
"input_refs": {
"name": "input_refs",
"type": "jsonb",
"input_files": {
"name": "input_files",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true,
"autoincrement": false
},
"output_refs": {
"name": "output_refs",
"type": "jsonb",
"output_path": {
"name": "output_path",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "jsonb",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
},
"error": {
"name": "error",
"type": "jsonb",
"type": "text",
"primaryKey": false,
"notNull": false
},
"bytes_in": {
"name": "bytes_in",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"bytes_out": {
"name": "bytes_out",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"jobs_created_at_idx": {
"name": "jobs_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"jobs_status_idx": {
"name": "jobs_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"jobs_user_id_users_id_fk": {
"name": "jobs_user_id_users_id_fk",
"tableFrom": "jobs",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
"checkConstraints": {}
},
"public.pipelines": {
"pipelines": {
"name": "pipelines",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
},
"steps": {
"name": "steps",
"type": "jsonb",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"type": "integer",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"pipelines_user_id_users_id_fk": {
"name": "pipelines_user_id_users_id_fk",
"tableFrom": "pipelines",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
"checkConstraints": {}
},
"public.roles": {
"name": "roles",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"permissions": {
"name": "permissions",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"is_builtin": {
"name": "is_builtin",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"roles_created_by_users_id_fk": {
"name": "roles_created_by_users_id_fk",
"tableFrom": "roles",
"tableTo": "users",
"columnsFrom": ["created_by"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"roles_name_unique": {
"name": "roles_name_unique",
"nullsNotDistinct": false,
"columns": ["name"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"type": "integer",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
@@ -511,297 +240,115 @@
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
"checkConstraints": {}
},
"public.settings": {
"settings": {
"name": "settings",
"schema": "",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.teams": {
"name": "teams",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"teams_name_unique": {
"name": "teams_name_unique",
"nullsNotDistinct": false,
"columns": ["name"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_files": {
"name": "user_files",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"original_name": {
"name": "original_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"stored_name": {
"name": "stored_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"tool_chain": {
"name": "tool_chain",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_files_user_id_users_id_fk": {
"name": "user_files_user_id_users_id_fk",
"tableFrom": "user_files",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
"checkConstraints": {}
},
"public.users": {
"users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"team": {
"name": "team",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'Default'"
},
"must_change_password": {
"name": "must_change_password",
"type": "boolean",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"auth_provider": {
"name": "auth_provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'local'"
},
"external_id": {
"name": "external_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"type": "integer",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"analytics_enabled": {
"name": "analytics_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"analytics_consent_shown_at": {
"name": "analytics_consent_shown_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"analytics_consent_remind_at": {
"name": "analytics_consent_remind_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": ["username"]
"columns": ["username"],
"isUnique": true
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"enums": {
"public.job_status": {
"name": "job_status",
"schema": "public",
"values": ["queued", "processing", "completed", "failed", "canceled"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"enums": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+4 -1
View File
@@ -62,6 +62,9 @@ export async function closeFlowProducer(): Promise<void> {
export async function enqueueToolJob(data: ToolJobData): Promise<Job<ToolJobData, ToolJobResult>> {
// Insert the durable DB row first (crash-safe: row exists even if
// Redis add fails and the job is retried on next boot).
// When dbSettings is provided, persist the redacted version instead of
// the real settings (which may contain secrets like passwords). The
// worker reads settings from BullMQ job data, never the DB row.
await db.insert(schema.jobs).values({
id: data.jobId,
userId: data.userId,
@@ -70,7 +73,7 @@ export async function enqueueToolJob(data: ToolJobData): Promise<Job<ToolJobData
type: data.kind,
status: "queued",
inputRefs: data.inputRefs,
settings: data.settings as Record<string, unknown>,
settings: (data.dbSettings ?? data.settings) as Record<string, unknown>,
});
const queue = getQueue(data.pool);
+6
View File
@@ -25,6 +25,12 @@ export interface ToolJobData {
inputRefs: string[];
filename: string;
settings: unknown;
/**
* When set, persisted to the DB row instead of settings. Use to strip
* secrets the worker needs but should not persist (the worker reads
* settings from BullMQ job data, never the DB row).
*/
dbSettings?: Record<string, unknown>;
fileId?: string;
clientJobId?: string;
kind:
+104
View File
@@ -0,0 +1,104 @@
/**
* Pure helper: expand a human-friendly page specification into a Set of
* 1-based page numbers. Handles single pages (N), ranges (N-M), reverse
* indices (rN = total - N + 1), the "z" alias for the last page, and
* comma-separated combinations.
*
* Throws on out-of-range references or empty specs.
*/
export function parsePageSpec(spec: string, total: number): Set<number> {
if (total < 1) throw new Error("Total page count must be at least 1");
const result = new Set<number>();
const parts = spec
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (parts.length === 0) {
throw new Error("Empty page specification");
}
for (const part of parts) {
if (part.includes("-")) {
const [startRaw, endRaw] = part.split("-", 2);
const start = resolveToken(startRaw, total);
const end = resolveToken(endRaw, total);
const lo = Math.min(start, end);
const hi = Math.max(start, end);
for (let i = lo; i <= hi; i++) {
result.add(i);
}
} else {
result.add(resolveToken(part, total));
}
}
return result;
}
/**
* Collapse a sorted-ascending array of page numbers into the most compact
* qpdf range string by merging consecutive runs into N-M parts.
*
* Examples:
* [2,3,4,...,80] -> "2-80"
* [1,3,5,7] -> "1,3,5,7"
* [1,2,4,5,6,9] -> "1-2,4-6,9"
* [] -> ""
* [3] -> "3"
*
* The input MUST be sorted ascending with no duplicates (as produced by
* iterating 1..total and filtering).
*/
export function compressPageRuns(pages: number[]): string {
if (pages.length === 0) return "";
const parts: string[] = [];
let runStart = pages[0];
let runEnd = pages[0];
for (let i = 1; i < pages.length; i++) {
if (pages[i] === runEnd + 1) {
runEnd = pages[i];
} else {
parts.push(runStart === runEnd ? `${runStart}` : `${runStart}-${runEnd}`);
runStart = pages[i];
runEnd = pages[i];
}
}
parts.push(runStart === runEnd ? `${runStart}` : `${runStart}-${runEnd}`);
return parts.join(",");
}
function resolveToken(token: string, total: number): number {
const t = token.trim().toLowerCase();
let page: number;
if (t === "z") {
page = total;
} else if (t.startsWith("r")) {
const n = Number.parseInt(t.slice(1), 10);
if (!Number.isFinite(n) || n < 1) {
throw new Error(`Invalid reverse page index: ${token}`);
}
page = total - n + 1;
if (page < 1) {
throw new Error(
`Reverse index ${token} resolves to page ${page}, out of range for ${total} pages`,
);
}
} else {
page = Number.parseInt(t, 10);
if (!Number.isFinite(page) || page < 1) {
throw new Error(`Invalid page number: ${token}`);
}
}
if (page < 1 || page > total) {
throw new Error(`Page ${page} out of range for a ${total}-page document`);
}
return page;
}
+13 -1
View File
@@ -26,5 +26,17 @@ export interface PreparedInput {
* enqueueing; handlers own format logic only.
*/
export interface InputHandler {
prepare(raw: Buffer, filename: string, opts: { scratchDir: string }): Promise<PreparedInput>;
prepare(
raw: Buffer,
filename: string,
opts: {
scratchDir: string;
/**
* When true, skip structural validation (qpdfCheck, page caps) but
* keep header-magic checks. Used by tools that intentionally accept
* damaged inputs (e.g. repair-pdf).
*/
lenient?: boolean;
},
): Promise<PreparedInput>;
}
+27 -14
View File
@@ -1,7 +1,12 @@
import { randomUUID } from "node:crypto";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfAvailable, qpdfCheck, qpdfPageCount } from "@snapotter/doc-engine";
import {
qpdfAvailable,
qpdfCheck,
qpdfPageCount,
qpdfRequiresPassword,
} from "@snapotter/doc-engine";
import { env } from "../config.js";
import { type InputHandler, InputValidationError, type PreparedInput } from "./contract.js";
@@ -17,7 +22,7 @@ export class DocumentInputHandler implements InputHandler {
async prepare(
raw: Buffer,
filename: string,
opts: { scratchDir: string },
opts: { scratchDir: string; lenient?: boolean },
): Promise<PreparedInput> {
if (raw.length === 0) throw new InputValidationError("Empty file");
const lower = filename.toLowerCase();
@@ -25,26 +30,34 @@ export class DocumentInputHandler implements InputHandler {
if (raw.subarray(0, 5).toString() !== "%PDF-") {
throw new InputValidationError("File does not start with a PDF header");
}
if (qpdfAvailable()) {
// When lenient, skip qpdfCheck + page-cap (repair-pdf's input is
// intentionally damaged). The %PDF- header check above still runs.
if (!opts.lenient && qpdfAvailable()) {
const dir = join(opts.scratchDir, `qpdf-${randomUUID()}`);
await mkdir(dir, { recursive: true });
const p = join(dir, "input.pdf");
try {
await writeFile(p, raw);
try {
await qpdfCheck(p);
} catch (err) {
throw new InputValidationError(
`Damaged PDF: ${err instanceof Error ? err.message.slice(0, 300) : "structural check failed"}`,
);
}
if (env.MAX_PDF_PAGES > 0) {
const pages = await qpdfPageCount(p);
if (pages > env.MAX_PDF_PAGES) {
if (await qpdfRequiresPassword(p)) {
// Password-protected PDFs are structurally unverifiable without the
// password and are legitimate inputs (unlock-pdf). Page caps cannot be
// read either; the consuming tool enforces its own limits.
} else {
try {
await qpdfCheck(p);
} catch (err) {
throw new InputValidationError(
`PDF has ${pages} pages, exceeding the maximum of ${env.MAX_PDF_PAGES}`,
`Damaged PDF: ${err instanceof Error ? err.message.slice(0, 300) : "structural check failed"}`,
);
}
if (env.MAX_PDF_PAGES > 0) {
const pages = await qpdfPageCount(p);
if (pages > env.MAX_PDF_PAGES) {
throw new InputValidationError(
`PDF has ${pages} pages, exceeding the maximum of ${env.MAX_PDF_PAGES}`,
);
}
}
}
} finally {
await rm(dir, { recursive: true, force: true }).catch(() => {});
+24
View File
@@ -71,6 +71,13 @@ interface ParsedStep {
pool: Pool;
}
/**
* Tools whose settings carry passwords. They are blocked from ALL pipeline
* paths so secrets never persist in step rows (the single-tool route redacts
* via dbSettings).
*/
const PASSWORD_TOOLS = new Set(["protect-pdf", "unlock-pdf"]);
/**
* Build a FlowJob tree for a single-file pipeline.
*
@@ -313,6 +320,12 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
});
}
if (PASSWORD_TOOLS.has(step.toolId)) {
return reply.status(400).send({
error: `Step ${i + 1}: This tool cannot be used in pipelines because it requires a password`,
});
}
const settingsResult = toolConfig.settingsSchema.safeParse(step.settings);
if (!settingsResult.success) {
return reply.status(400).send({
@@ -483,6 +496,11 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
// Validate all tool IDs exist
for (let i = 0; i < steps.length; i++) {
if (PASSWORD_TOOLS.has(steps[i].toolId)) {
return reply.status(400).send({
error: `This tool cannot be used in saved pipelines because it requires a password`,
});
}
const toolConfig = getToolConfig(steps[i].toolId);
if (!toolConfig) {
return reply.status(400).send({
@@ -696,6 +714,12 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
});
}
if (PASSWORD_TOOLS.has(step.toolId)) {
return reply.status(400).send({
error: `Step ${i + 1}: This tool cannot be used in pipelines because it requires a password`,
});
}
const settingsResult = toolConfig.settingsSchema.safeParse(step.settings);
if (!settingsResult.success) {
return reply.status(400).send({
+22
View File
@@ -80,6 +80,20 @@ export interface ToolRouteConfig<T> {
) => Promise<{ buffer: Buffer; filename: string; contentType: string }>;
/** Optional v2 process function. When set, the worker calls this instead of the legacy process. */
processV2?: ToolProcessV2;
/**
* When set, the factory passes `{ scratchDir, lenient: true }` to the
* input handler's prepare(). DocumentInputHandler skips qpdfCheck and
* page-cap when lenient, keeping only the %PDF- header check. Set on
* tools that intentionally accept damaged inputs (e.g. repair-pdf).
*/
skipStructuralValidation?: boolean;
/**
* When set, produces a redacted copy of settings for the durable DB
* row. Passwords and other secrets are replaced so they do not persist
* in the jobs table (retention keeps rows for days). The BullMQ job
* data keeps the real settings; the worker reads from job data.
*/
redactSettingsForAudit?: (settings: unknown) => Record<string, unknown>;
}
/** Type-erased config stored in the registry (settings type is widened to avoid variance issues). */
@@ -94,6 +108,8 @@ export interface AnyToolRouteConfig {
ctx?: ToolProcessCtx,
) => Promise<{ buffer: Buffer; filename: string; contentType: string }>;
processV2?: ToolProcessV2;
skipStructuralValidation?: boolean;
redactSettingsForAudit?: (settings: unknown) => Record<string, unknown>;
}
// ── Legacy adapter ────────────────────────────────────────────
@@ -289,6 +305,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
try {
const prepared = await inputHandlerFor(modality).prepare(fileBuffer, fname, {
scratchDir,
lenient: config.skipStructuralValidation,
});
fileBuffer = prepared.buffer;
fname = prepared.filename;
@@ -366,6 +383,9 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
const pool = resolveToolPool(config.toolId);
// Enqueue for the BullMQ worker
const dbSettings = config.redactSettingsForAudit
? config.redactSettingsForAudit(settings)
: undefined;
await enqueueToolJob({
jobId,
toolId: config.toolId,
@@ -374,6 +394,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
inputRefs,
filename,
settings,
dbSettings,
fileId: fileId ?? undefined,
clientJobId: clientJobId ?? undefined,
kind: "tool",
@@ -404,6 +425,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
originalSize: result.originalSize,
processedSize: result.processedSize,
savedFileId: result.savedFileId,
...result.resultPayload,
});
}
return reply.status(202).send({ jobId: clientJobId || jobId, async: true });
+39
View File
@@ -0,0 +1,39 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { BookletValue } from "@snapotter/doc-engine";
import { pdfcpuBooklet } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
perSheet: z.union([z.literal(2), z.literal(4), z.literal(6), z.literal(8)]).default(2),
});
export function registerBookletPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "booklet-pdf",
settingsSchema,
process: async () => {
throw new Error("booklet-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_booklet.pdf`);
ctx.report(10, "Creating booklet");
await pdfcpuBooklet(inPath, settings.perSheet as BookletValue, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_booklet.pdf`,
contentType: "application/pdf",
};
},
});
}
+38
View File
@@ -0,0 +1,38 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pdfcpuCropMargin } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
margin: z.number().min(0).max(2000).default(20),
});
export function registerCropPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "crop-pdf",
settingsSchema,
process: async () => {
throw new Error("crop-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_cropped.pdf`);
ctx.report(10, "Cropping");
await pdfcpuCropMargin(inPath, settings.margin, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_cropped.pdf`,
contentType: "application/pdf",
};
},
});
}
@@ -0,0 +1,43 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfPagesSpec } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const rangeField = z
.string()
.max(200)
.regex(/^[0-9rz][0-9rz,-]*$/i, "Invalid page range");
const settingsSchema = z.object({
range: rangeField,
});
export function registerExtractPages(app: FastifyInstance) {
createToolRoute(app, {
toolId: "extract-pages",
settingsSchema,
process: async () => {
throw new Error("extract-pages is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_extracted.pdf`);
ctx.report(10, "Extracting pages");
await qpdfPagesSpec(inPath, settings.range, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_extracted.pdf`,
contentType: "application/pdf",
};
},
});
}
+35
View File
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pdfFlattenPy } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerFlattenPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "flatten-pdf",
settingsSchema,
process: async () => {
throw new Error("flatten-pdf is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_flattened.pdf`);
ctx.report(10, "Flattening");
await pdfFlattenPy(inPath, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_flattened.pdf`,
contentType: "application/pdf",
};
},
});
}
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { gsGrayscalePdf } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerGrayscalePdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "grayscale-pdf",
settingsSchema,
process: async () => {
throw new Error("grayscale-pdf is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_grayscale.pdf`);
ctx.report(10, "Converting to grayscale");
await gsGrayscalePdf(inPath, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_grayscale.pdf`,
contentType: "application/pdf",
};
},
});
}
+35
View File
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { htmlToPdfPy } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerHtmlToPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "html-to-pdf",
settingsSchema,
process: async () => {
throw new Error("html-to-pdf is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}.pdf`);
ctx.report(10, "Converting");
await htmlToPdfPy(inPath, outPath, "html");
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.pdf`,
contentType: "application/pdf",
};
},
});
}
+42
View File
@@ -7,6 +7,7 @@ import { registerAiCanvasExpand } from "./ai-canvas-expand.js";
import { registerBarcodeRead } from "./barcode-read.js";
import { registerBeautify } from "./beautify.js";
import { registerBlurFaces } from "./blur-faces.js";
import { registerBookletPdf } from "./booklet-pdf.js";
import { registerBorder } from "./border.js";
import { registerBulkRename } from "./bulk-rename.js";
import { registerCollage } from "./collage.js";
@@ -23,32 +24,50 @@ import { registerConvert } from "./convert.js";
import { registerConvertAudio } from "./convert-audio.js";
import { registerConvertVideo } from "./convert-video.js";
import { registerCrop } from "./crop.js";
import { registerCropPdf } from "./crop-pdf.js";
import { registerCsvExcel } from "./csv-excel.js";
import { registerCsvJson } from "./csv-json.js";
import { registerEditMetadata } from "./edit-metadata.js";
import { registerEnhanceFaces } from "./enhance-faces.js";
import { registerEraseObject } from "./erase-object.js";
import { registerExtractAudio } from "./extract-audio.js";
import { registerExtractPages } from "./extract-pages.js";
import { registerFavicon } from "./favicon.js";
import { registerFindDuplicates } from "./find-duplicates.js";
import { registerFlattenPdf } from "./flatten-pdf.js";
import { registerGifTools } from "./gif-tools.js";
import { registerGrayscalePdf } from "./grayscale-pdf.js";
import { registerHtmlToImage } from "./html-to-image.js";
import { registerHtmlToPdf } from "./html-to-pdf.js";
import { registerImageEnhancement } from "./image-enhancement.js";
import { registerImageToBase64 } from "./image-to-base64.js";
import { registerImageToPdf } from "./image-to-pdf.js";
import { registerInfo } from "./info.js";
import { registerJsonXml } from "./json-xml.js";
import { registerLinearizePdf } from "./linearize-pdf.js";
import { registerMarkdownToPdf } from "./markdown-to-pdf.js";
import { registerMemeGenerator } from "./meme-generator.js";
import { registerMergePdf } from "./merge-pdf.js";
import { registerMuteVideo } from "./mute-video.js";
import { registerNoiseRemoval } from "./noise-removal.js";
import { registerNupPdf } from "./nup-pdf.js";
import { registerOcr } from "./ocr.js";
import { registerOptimizeForWeb } from "./optimize-for-web.js";
import { registerOrganizePdf } from "./organize-pdf.js";
import { registerPassportPhoto } from "./passport-photo.js";
import { registerPdfMetadata } from "./pdf-metadata.js";
import { registerPdfPageNumbers } from "./pdf-page-numbers.js";
import { registerPdfToImage } from "./pdf-to-image.js";
import { registerPdfToText } from "./pdf-to-text.js";
import { registerPdfToWord } from "./pdf-to-word.js";
import { registerPdfaConvert } from "./pdfa-convert.js";
import { registerProtectPdf } from "./protect-pdf.js";
import { registerQrGenerate } from "./qr-generate.js";
import { registerRedEyeRemoval } from "./red-eye-removal.js";
import { registerRedactPdf } from "./redact-pdf.js";
import { registerRemoveBackground } from "./remove-background.js";
import { registerRemovePages } from "./remove-pages.js";
import { registerRepairPdf } from "./repair-pdf.js";
import { registerReplaceColor } from "./replace-color.js";
import { registerResize } from "./resize.js";
import { registerRestorePhoto } from "./restore-photo.js";
@@ -66,10 +85,12 @@ import { registerTextOverlay } from "./text-overlay.js";
import { registerTransparencyFixer } from "./transparency-fixer.js";
import { registerTrimAudio } from "./trim-audio.js";
import { registerTrimVideo } from "./trim-video.js";
import { registerUnlockPdf } from "./unlock-pdf.js";
import { registerUpscale } from "./upscale.js";
import { registerVectorize } from "./vectorize.js";
import { registerVideoToGif } from "./video-to-gif.js";
import { registerWatermarkImage } from "./watermark-image.js";
import { registerWatermarkPdf } from "./watermark-pdf.js";
import { registerWatermarkText } from "./watermark-text.js";
import { registerWordToPdf } from "./word-to-pdf.js";
@@ -174,6 +195,27 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
{ id: "compress-pdf", register: registerCompressPdf },
{ id: "rotate-pdf", register: registerRotatePdf },
{ id: "word-to-pdf", register: registerWordToPdf },
{ id: "extract-pages", register: registerExtractPages },
{ id: "remove-pages", register: registerRemovePages },
{ id: "organize-pdf", register: registerOrganizePdf },
{ id: "protect-pdf", register: registerProtectPdf },
{ id: "unlock-pdf", register: registerUnlockPdf },
{ id: "repair-pdf", register: registerRepairPdf },
{ id: "linearize-pdf", register: registerLinearizePdf },
{ id: "grayscale-pdf", register: registerGrayscalePdf },
{ id: "pdfa-convert", register: registerPdfaConvert },
{ id: "crop-pdf", register: registerCropPdf },
{ id: "nup-pdf", register: registerNupPdf },
{ id: "booklet-pdf", register: registerBookletPdf },
{ id: "watermark-pdf", register: registerWatermarkPdf },
{ id: "pdf-page-numbers", register: registerPdfPageNumbers },
{ id: "flatten-pdf", register: registerFlattenPdf },
{ id: "redact-pdf", register: registerRedactPdf },
{ id: "pdf-to-text", register: registerPdfToText },
{ id: "pdf-to-word", register: registerPdfToWord },
{ id: "pdf-metadata", register: registerPdfMetadata },
{ id: "html-to-pdf", register: registerHtmlToPdf },
{ id: "markdown-to-pdf", register: registerMarkdownToPdf },
// Data Files
{ id: "csv-excel", register: registerCsvExcel },
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfLinearize } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerLinearizePdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "linearize-pdf",
settingsSchema,
process: async () => {
throw new Error("linearize-pdf is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_linearized.pdf`);
ctx.report(10, "Linearizing");
await qpdfLinearize(inPath, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_linearized.pdf`,
contentType: "application/pdf",
};
},
});
}
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { htmlToPdfPy } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerMarkdownToPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "markdown-to-pdf",
settingsSchema,
process: async () => {
throw new Error("markdown-to-pdf is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}.pdf`);
ctx.report(10, "Converting");
await htmlToPdfPy(inPath, outPath, "markdown");
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.pdf`,
contentType: "application/pdf",
};
},
});
}
+49
View File
@@ -0,0 +1,49 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { NupValue } from "@snapotter/doc-engine";
import { pdfcpuNup } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
perSheet: z
.union([
z.literal(2),
z.literal(3),
z.literal(4),
z.literal(8),
z.literal(9),
z.literal(12),
z.literal(16),
])
.default(2),
});
export function registerNupPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "nup-pdf",
settingsSchema,
process: async () => {
throw new Error("nup-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_nup.pdf`);
ctx.report(10, "Arranging pages");
await pdfcpuNup(inPath, settings.perSheet as NupValue, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_nup.pdf`,
contentType: "application/pdf",
};
},
});
}
+43
View File
@@ -0,0 +1,43 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfPagesSpec } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const rangeField = z
.string()
.max(200)
.regex(/^[0-9rz][0-9rz,-]*$/i, "Invalid page range");
const settingsSchema = z.object({
order: rangeField,
});
export function registerOrganizePdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "organize-pdf",
settingsSchema,
process: async () => {
throw new Error("organize-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_organized.pdf`);
ctx.report(10, "Reordering pages");
await qpdfPagesSpec(inPath, settings.order, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_organized.pdf`,
contentType: "application/pdf",
};
},
});
}
+51
View File
@@ -0,0 +1,51 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pdfMetadataGetPy, pdfMetadataSetPy } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
title: z.string().max(500).optional(),
author: z.string().max(500).optional(),
subject: z.string().max(500).optional(),
keywords: z.string().max(500).optional(),
});
export function registerPdfMetadata(app: FastifyInstance) {
createToolRoute(app, {
toolId: "pdf-metadata",
settingsSchema,
process: async () => {
throw new Error("pdf-metadata is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_metadata.pdf`);
ctx.report(10, "Setting metadata");
// Build metadata record from DEFINED keys only (empty string = clear).
const meta: Record<string, string> = {};
if (settings.title !== undefined) meta.Title = settings.title;
if (settings.author !== undefined) meta.Author = settings.author;
if (settings.subject !== undefined) meta.Subject = settings.subject;
if (settings.keywords !== undefined) meta.Keywords = settings.keywords;
await pdfMetadataSetPy(inPath, outPath, meta);
const metadata = await pdfMetadataGetPy(outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_metadata.pdf`,
contentType: "application/pdf",
resultPayload: { metadata },
};
},
});
}
@@ -0,0 +1,49 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pdfcpuTextStamp } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
position: z.enum(["bl", "bc", "br", "tl", "tc", "tr"]).default("bc"),
fontSize: z.number().int().min(6).max(24).default(10),
});
export function registerPdfPageNumbers(app: FastifyInstance) {
createToolRoute(app, {
toolId: "pdf-page-numbers",
settingsSchema,
process: async () => {
throw new Error("pdf-page-numbers is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_numbered.pdf`);
ctx.report(10, "Adding page numbers");
await pdfcpuTextStamp(
inPath,
{
text: "Page %p of %P",
position: settings.position,
fontSize: settings.fontSize,
opacity: 1,
rotation: 0,
},
outPath,
);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_numbered.pdf`,
contentType: "application/pdf",
};
},
});
}
+35
View File
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pdfTextPy } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerPdfToText(app: FastifyInstance) {
createToolRoute(app, {
toolId: "pdf-to-text",
settingsSchema,
process: async () => {
throw new Error("pdf-to-text is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}.txt`);
ctx.report(10, "Extracting text");
await pdfTextPy(inPath, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.txt`,
contentType: "text/plain",
};
},
});
}
+35
View File
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pdfToWordPy } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerPdfToWord(app: FastifyInstance) {
createToolRoute(app, {
toolId: "pdf-to-word",
settingsSchema,
process: async () => {
throw new Error("pdf-to-word is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}.docx`);
ctx.report(10, "Converting");
await pdfToWordPy(inPath, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.docx`,
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
};
},
});
}
+35
View File
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { gsPdfaConvert } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerPdfaConvert(app: FastifyInstance) {
createToolRoute(app, {
toolId: "pdfa-convert",
settingsSchema,
process: async () => {
throw new Error("pdfa-convert is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_pdfa.pdf`);
ctx.report(10, "Converting to PDF/A");
await gsPdfaConvert(inPath, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_pdfa.pdf`,
contentType: "application/pdf",
};
},
});
}
+52
View File
@@ -0,0 +1,52 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfEncrypt } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
userPassword: z.string().min(1).max(256),
ownerPassword: z.string().min(1).max(256).optional(),
});
export function registerProtectPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "protect-pdf",
settingsSchema,
redactSettingsForAudit: (settings) => {
const s = settings as z.infer<typeof settingsSchema>;
return {
...s,
userPassword: "<redacted>",
ownerPassword: "<redacted>",
};
},
process: async () => {
throw new Error("protect-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_protected.pdf`);
ctx.report(10, "Encrypting");
await qpdfEncrypt(
inPath,
settings.userPassword,
settings.ownerPassword ?? settings.userPassword,
outPath,
);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_protected.pdf`,
contentType: "application/pdf",
};
},
});
}
+40
View File
@@ -0,0 +1,40 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pdfRedactPy } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
terms: z.array(z.string().min(1).max(200)).min(1).max(50),
caseSensitive: z.boolean().default(false),
});
export function registerRedactPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "redact-pdf",
settingsSchema,
process: async () => {
throw new Error("redact-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_redacted.pdf`);
ctx.report(10, "Redacting");
const { found } = await pdfRedactPy(inPath, outPath, settings.terms, settings.caseSensitive);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_redacted.pdf`,
contentType: "application/pdf",
resultPayload: { found },
};
},
});
}
+61
View File
@@ -0,0 +1,61 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfPageCount, qpdfPagesSpecUnchecked } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { compressPageRuns, parsePageSpec } from "../../lib/page-spec.js";
import { createToolRoute } from "../tool-factory.js";
const rangeField = z
.string()
.max(200)
.regex(/^[0-9rz][0-9rz,-]*$/i, "Invalid page range");
const settingsSchema = z.object({
pages: rangeField,
});
export function registerRemovePages(app: FastifyInstance) {
createToolRoute(app, {
toolId: "remove-pages",
settingsSchema,
process: async () => {
throw new Error("remove-pages is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
ctx.report(10, "Analyzing pages");
const total = await qpdfPageCount(inPath);
const removeSet = parsePageSpec(settings.pages, total);
// Build the keep list: all pages NOT in the remove set
const keepPages: number[] = [];
for (let i = 1; i <= total; i++) {
if (!removeSet.has(i)) {
keepPages.push(i);
}
}
if (keepPages.length === 0) {
throw new Error("Cannot remove every page from the document");
}
const keepSpec = compressPageRuns(keepPages);
const outPath = join(ctx.scratchDir, `${base}_removed.pdf`);
ctx.report(30, "Removing pages");
await qpdfPagesSpecUnchecked(inPath, keepSpec, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_removed.pdf`,
contentType: "application/pdf",
};
},
});
}
+36
View File
@@ -0,0 +1,36 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfRepair } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerRepairPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "repair-pdf",
settingsSchema,
skipStructuralValidation: true,
process: async () => {
throw new Error("repair-pdf is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_repaired.pdf`);
ctx.report(10, "Repairing");
await qpdfRepair(inPath, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_repaired.pdf`,
contentType: "application/pdf",
};
},
});
}
+45
View File
@@ -0,0 +1,45 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { qpdfDecrypt } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
password: z.string().min(1).max(256),
});
export function registerUnlockPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "unlock-pdf",
settingsSchema,
redactSettingsForAudit: (settings) => {
const s = settings as z.infer<typeof settingsSchema>;
return {
...s,
password: "<redacted>",
};
},
process: async () => {
throw new Error("unlock-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_unlocked.pdf`);
ctx.report(10, "Decrypting");
await qpdfDecrypt(inPath, settings.password, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_unlocked.pdf`,
contentType: "application/pdf",
};
},
});
}
@@ -0,0 +1,52 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pdfcpuTextStamp } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
text: z.string().min(1).max(200),
position: z.enum(["tl", "tc", "tr", "l", "c", "r", "bl", "bc", "br"]).default("c"),
fontSize: z.number().int().min(6).max(72).default(48),
opacity: z.number().min(0.05).max(1).default(0.3),
rotation: z.number().min(-180).max(180).default(45),
});
export function registerWatermarkPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "watermark-pdf",
settingsSchema,
process: async () => {
throw new Error("watermark-pdf is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}_watermarked.pdf`);
ctx.report(10, "Stamping watermark");
await pdfcpuTextStamp(
inPath,
{
text: settings.text,
position: settings.position,
fontSize: settings.fontSize,
opacity: settings.opacity,
rotation: settings.rotation,
},
outPath,
);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}_watermarked.pdf`,
contentType: "application/pdf",
};
},
});
}