refactor: improvement code review
This commit is contained in:
parent
3bbe2ac108
commit
e634ffddf2
35 changed files with 1360 additions and 154 deletions
8
bun.lock
8
bun.lock
|
|
@ -21,6 +21,7 @@
|
|||
"@whiskeysockets/baileys": "^7.0.0-rc11",
|
||||
"argon2": "^0.44.0",
|
||||
"bullmq": "^5.76.8",
|
||||
"busboy": "^1.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
|
|
@ -49,6 +50,7 @@
|
|||
"@tanstack/eslint-config": "latest",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/pg": "^8.15.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
|
|
@ -792,6 +794,8 @@
|
|||
|
||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||
|
||||
"@types/busboy": ["@types/busboy@1.5.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
|
@ -942,6 +946,8 @@
|
|||
|
||||
"bullmq": ["bullmq@5.76.8", "", { "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.10.1", "msgpackr": "2.0.1", "node-abort-controller": "3.1.1", "semver": "7.8.0", "tslib": "2.8.1" } }, "sha512-v3WTwA8diFtsADaJ8eK2ozyi2CYK9rDZCeoKF+dIPF/MUL8HxAOa3H72Gmu1lC4yKlho6t1PwNr/QpDVqaNEZQ=="],
|
||||
|
||||
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
|
||||
|
||||
"cacheable": ["cacheable@2.3.5", "", { "dependencies": { "@cacheable/memory": "^2.0.8", "@cacheable/utils": "^2.4.1", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-EQfaKe09tl615iNvq/TBRWTFf1AKJNXYQSsMx0Z3EI0nA+pVsVPS8wJhnRlkbdacKPh1d0qVIhwTc2zsQNFEEg=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
|
@ -1506,6 +1512,8 @@
|
|||
|
||||
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
|
||||
|
||||
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
|
|
|||
3
drizzle/0003_clammy_azazel.sql
Normal file
3
drizzle/0003_clammy_azazel.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE "apps" ALTER COLUMN "max_sessions" SET DATA TYPE integer;--> statement-breakpoint
|
||||
ALTER TABLE "apps" ALTER COLUMN "max_sessions" SET DEFAULT 10;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "last_used_totp_token" text;
|
||||
847
drizzle/meta/0003_snapshot.json
Normal file
847
drizzle/meta/0003_snapshot.json
Normal file
|
|
@ -0,0 +1,847 @@
|
|||
{
|
||||
"id": "9ada70d3-912d-49ea-ab7d-a01d95e09c44",
|
||||
"prevId": "20e640d9-30d1-49e7-a0a0-64b86ebd08fc",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.api_keys": {
|
||||
"name": "api_keys",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"app_id": {
|
||||
"name": "app_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_hash": {
|
||||
"name": "key_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_prefix": {
|
||||
"name": "key_prefix",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"api_keys_app_id_idx": {
|
||||
"name": "api_keys_app_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "app_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"api_keys_app_id_apps_id_fk": {
|
||||
"name": "api_keys_app_id_apps_id_fk",
|
||||
"tableFrom": "api_keys",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": [
|
||||
"app_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.apps": {
|
||||
"name": "apps",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_user_id": {
|
||||
"name": "owner_user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"dek_encrypted": {
|
||||
"name": "dek_encrypted",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"max_sessions": {
|
||||
"name": "max_sessions",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 10
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"apps_owner_user_id_users_id_fk": {
|
||||
"name": "apps_owner_user_id_users_id_fk",
|
||||
"tableFrom": "apps",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"owner_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.audit_log": {
|
||||
"name": "audit_log",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"actor_type": {
|
||||
"name": "actor_type",
|
||||
"type": "actor_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_id": {
|
||||
"name": "actor_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"resource_type": {
|
||||
"name": "resource_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"resource_id": {
|
||||
"name": "resource_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"diff_before": {
|
||||
"name": "diff_before",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"diff_after": {
|
||||
"name": "diff_after",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"audit_log_actor_idx": {
|
||||
"name": "audit_log_actor_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "actor_type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "actor_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"audit_log_action_idx": {
|
||||
"name": "audit_log_action_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "action",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"audit_log_created_at_idx": {
|
||||
"name": "audit_log_created_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.messages": {
|
||||
"name": "messages",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"app_id": {
|
||||
"name": "app_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"whatsapp_msg_id": {
|
||||
"name": "whatsapp_msg_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"direction": {
|
||||
"name": "direction",
|
||||
"type": "message_direction",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "message_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"to_jid": {
|
||||
"name": "to_jid",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"from_jid": {
|
||||
"name": "from_jid",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"body_encrypted": {
|
||||
"name": "body_encrypted",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"media_url": {
|
||||
"name": "media_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"media_mime": {
|
||||
"name": "media_mime",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"media_key": {
|
||||
"name": "media_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"media_size": {
|
||||
"name": "media_size",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "message_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'queued'"
|
||||
},
|
||||
"is_group": {
|
||||
"name": "is_group",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"quoted_msg_id": {
|
||||
"name": "quoted_msg_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"messages_session_id_idx": {
|
||||
"name": "messages_session_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "session_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"messages_app_id_idx": {
|
||||
"name": "messages_app_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "app_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"messages_created_at_idx": {
|
||||
"name": "messages_created_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"messages_whatsapp_msg_id_idx": {
|
||||
"name": "messages_whatsapp_msg_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "whatsapp_msg_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"messages_app_id_apps_id_fk": {
|
||||
"name": "messages_app_id_apps_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": [
|
||||
"app_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"messages_session_id_sessions_id_fk": {
|
||||
"name": "messages_session_id_sessions_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "sessions",
|
||||
"columnsFrom": [
|
||||
"session_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session_keys": {
|
||||
"name": "session_keys",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_type": {
|
||||
"name": "key_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_id": {
|
||||
"name": "key_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value_encrypted": {
|
||||
"name": "value_encrypted",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_keys_session_id_sessions_id_fk": {
|
||||
"name": "session_keys_session_id_sessions_id_fk",
|
||||
"tableFrom": "session_keys",
|
||||
"tableTo": "sessions",
|
||||
"columnsFrom": [
|
||||
"session_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_keys_unique": {
|
||||
"name": "session_keys_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"session_id",
|
||||
"key_type",
|
||||
"key_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"app_id": {
|
||||
"name": "app_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "session_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'PENDING_QR'"
|
||||
},
|
||||
"phone_number": {
|
||||
"name": "phone_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"credentials_encrypted": {
|
||||
"name": "credentials_encrypted",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"reconnect_count": {
|
||||
"name": "reconnect_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"auto_read": {
|
||||
"name": "auto_read",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"last_seen": {
|
||||
"name": "last_seen",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sessions_app_id_idx": {
|
||||
"name": "sessions_app_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "app_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"sessions_status_idx": {
|
||||
"name": "sessions_status_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sessions_app_id_apps_id_fk": {
|
||||
"name": "sessions_app_id_apps_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": [
|
||||
"app_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"totp_secret": {
|
||||
"name": "totp_secret",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"totp_enabled": {
|
||||
"name": "totp_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"last_used_totp_token": {
|
||||
"name": "last_used_totp_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.actor_type": {
|
||||
"name": "actor_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"user",
|
||||
"api_key"
|
||||
]
|
||||
},
|
||||
"public.message_direction": {
|
||||
"name": "message_direction",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"in",
|
||||
"out"
|
||||
]
|
||||
},
|
||||
"public.message_status": {
|
||||
"name": "message_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"queued",
|
||||
"sent",
|
||||
"delivered",
|
||||
"read",
|
||||
"failed"
|
||||
]
|
||||
},
|
||||
"public.message_type": {
|
||||
"name": "message_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"text",
|
||||
"image",
|
||||
"video",
|
||||
"document",
|
||||
"audio"
|
||||
]
|
||||
},
|
||||
"public.session_status": {
|
||||
"name": "session_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"PENDING_QR",
|
||||
"PENDING_PAIRING",
|
||||
"CONNECTING",
|
||||
"CONNECTED",
|
||||
"DISCONNECTED",
|
||||
"LOGGED_OUT",
|
||||
"DEAD"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,13 @@
|
|||
"when": 1778829335931,
|
||||
"tag": "0002_orange_clint_barton",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1778838712770,
|
||||
"tag": "0003_clammy_azazel",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@
|
|||
"@whiskeysockets/baileys": "^7.0.0-rc11",
|
||||
"argon2": "^0.44.0",
|
||||
"bullmq": "^5.76.8",
|
||||
"busboy": "^1.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
|
|
@ -64,6 +65,7 @@
|
|||
"@tanstack/eslint-config": "latest",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/pg": "^8.15.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export const users = pgTable("users", {
|
|||
passwordHash: text("password_hash").notNull(),
|
||||
totpSecret: text("totp_secret"),
|
||||
totpEnabled: boolean("totp_enabled").notNull().default(false),
|
||||
lastUsedTotpToken: text("last_used_totp_token"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
|
|
@ -31,7 +32,7 @@ export const apps = pgTable("apps", {
|
|||
.notNull()
|
||||
.references(() => users.id),
|
||||
dekEncrypted: text("dek_encrypted").notNull(),
|
||||
maxSessions: text("max_sessions").notNull().default("10"),
|
||||
maxSessions: integer("max_sessions").notNull().default(10),
|
||||
deletedAt: timestamp("deleted_at"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export async function createUserApp(
|
|||
name: name.trim(),
|
||||
ownerUserId: userId,
|
||||
dekEncrypted,
|
||||
maxSessions: String(maxSessions ?? 10),
|
||||
maxSessions: maxSessions ?? 10,
|
||||
})
|
||||
.returning({ id: apps.id, name: apps.name, createdAt: apps.createdAt });
|
||||
|
||||
|
|
@ -66,14 +66,14 @@ export async function updateUserApp(
|
|||
userId: string,
|
||||
updates: { name?: string; maxSessions?: number },
|
||||
audit?: { ip?: string; userAgent?: string },
|
||||
): Promise<(typeof apps.$inferSelect & { maxSessions: string }) | null> {
|
||||
): Promise<typeof apps.$inferSelect | null> {
|
||||
const app = await findUserApp(appId, userId);
|
||||
if (!app) return null;
|
||||
|
||||
const dbUpdates: Partial<typeof apps.$inferInsert> = {};
|
||||
if (updates.name !== undefined) dbUpdates.name = updates.name.trim();
|
||||
if (updates.maxSessions !== undefined)
|
||||
dbUpdates.maxSessions = String(updates.maxSessions);
|
||||
dbUpdates.maxSessions = updates.maxSessions;
|
||||
|
||||
const [updated] = await db
|
||||
.update(apps)
|
||||
|
|
@ -97,7 +97,7 @@ export async function updateUserApp(
|
|||
diffAfter: dbUpdates,
|
||||
});
|
||||
|
||||
return updated as App & { maxSessions: string };
|
||||
return updated as App;
|
||||
}
|
||||
|
||||
export async function deleteUserApp(
|
||||
|
|
|
|||
|
|
@ -22,10 +22,16 @@ export async function queryAuditLog(
|
|||
if (filters.action) conditions.push(eq(auditLog.action, filters.action));
|
||||
if (filters.resourceType)
|
||||
conditions.push(eq(auditLog.resourceType, filters.resourceType));
|
||||
if (filters.since)
|
||||
conditions.push(gte(auditLog.createdAt, new Date(filters.since)));
|
||||
if (filters.until)
|
||||
conditions.push(lte(auditLog.createdAt, new Date(filters.until)));
|
||||
if (filters.since) {
|
||||
const d = new Date(filters.since);
|
||||
if (isNaN(d.getTime())) return { data: [], page, limit };
|
||||
conditions.push(gte(auditLog.createdAt, d));
|
||||
}
|
||||
if (filters.until) {
|
||||
const d = new Date(filters.until);
|
||||
if (isNaN(d.getTime())) return { data: [], page, limit };
|
||||
conditions.push(lte(auditLog.createdAt, d));
|
||||
}
|
||||
|
||||
const rows = await db.query.auditLog.findMany({
|
||||
where: and(...conditions),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "#/db/index";
|
||||
import { users } from "#/db/schema";
|
||||
import { generateTotpSecret, getTotpUri, verifyTotp } from "#/lib/totp";
|
||||
import {
|
||||
decryptWithMasterKey,
|
||||
encryptWithMasterKey,
|
||||
} from "#/lib/crypto";
|
||||
|
||||
export async function findUserByEmail(email: string) {
|
||||
return (
|
||||
|
|
@ -31,7 +36,7 @@ export async function enrollTotpSecret(
|
|||
const secret = generateTotpSecret();
|
||||
await db
|
||||
.update(users)
|
||||
.set({ totpSecret: secret })
|
||||
.set({ totpSecret: encryptWithMasterKey(secret) })
|
||||
.where(eq(users.id, userId));
|
||||
return { ok: true, secret, uri: getTotpUri(user.email, secret) };
|
||||
}
|
||||
|
|
@ -45,13 +50,38 @@ export async function confirmTotpEnrollment(
|
|||
return { ok: false, error: "Enroll first", status: 400 };
|
||||
if (user.totpEnabled)
|
||||
return { ok: false, error: "TOTP already enabled", status: 409 };
|
||||
if (!verifyTotp(user.totpSecret, token))
|
||||
|
||||
const decryptedSecret = decryptWithMasterKey(user.totpSecret);
|
||||
if (!verifyTotp(decryptedSecret, token))
|
||||
return { ok: false, error: "Invalid token", status: 401 };
|
||||
|
||||
await db.update(users).set({ totpEnabled: true }).where(eq(users.id, userId));
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
await db
|
||||
.update(users)
|
||||
.set({ totpEnabled: true, lastUsedTotpToken: tokenHash })
|
||||
.where(eq(users.id, userId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function verifyTotpToken(
|
||||
user: typeof users.$inferSelect,
|
||||
token: string,
|
||||
): Promise<boolean> {
|
||||
if (!user.totpSecret) return false;
|
||||
|
||||
const secret = decryptWithMasterKey(user.totpSecret);
|
||||
if (!verifyTotp(secret, token)) return false;
|
||||
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
if (user.lastUsedTotpToken === tokenHash) return false;
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ lastUsedTotpToken: tokenHash })
|
||||
.where(eq(users.id, user.id));
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function disableTotp(userId: string): Promise<void> {
|
||||
await db
|
||||
.update(users)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { db } from "#/db/index";
|
||||
import { apps, sessions, sessionKeys } from "#/db/schema";
|
||||
import { startSession, stopSession, emitWebhook } from "#/lib/baileys";
|
||||
|
|
@ -98,39 +98,56 @@ export async function createSession(
|
|||
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: appWhere,
|
||||
columns: { id: true, maxSessions: true },
|
||||
columns: { id: true },
|
||||
});
|
||||
if (!app) return { ok: false, error: "App not found", status: 404 };
|
||||
|
||||
const existing = await db.query.sessions.findMany({
|
||||
where: eq(sessions.appId, appId),
|
||||
columns: { status: true },
|
||||
const initialStatus: SessionStatus =
|
||||
via === "pairing" ? "PENDING_PAIRING" : "PENDING_QR";
|
||||
|
||||
type TxResult =
|
||||
| { limited: true; max: number }
|
||||
| { limited: false; session: { id: string; status: string; createdAt: Date } };
|
||||
|
||||
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
|
||||
// Lock app row to serialize concurrent createSession calls for the same app
|
||||
const lock = await tx.execute(
|
||||
sql`SELECT max_sessions FROM apps WHERE id = ${appId} FOR UPDATE`,
|
||||
);
|
||||
const maxSessions = Number(
|
||||
(lock.rows[0] as { max_sessions: unknown }).max_sessions,
|
||||
);
|
||||
|
||||
const cnt = await tx.execute(
|
||||
sql`SELECT COUNT(*)::int AS n FROM sessions WHERE app_id = ${appId} AND status = ANY(ARRAY['PENDING_QR','PENDING_PAIRING','CONNECTING','CONNECTED','DISCONNECTED']::session_status[])`,
|
||||
);
|
||||
const activeCount = Number((cnt.rows[0] as { n: unknown }).n);
|
||||
|
||||
if (activeCount >= maxSessions) return { limited: true, max: maxSessions };
|
||||
|
||||
const [session] = await tx
|
||||
.insert(sessions)
|
||||
.values({ appId, status: initialStatus })
|
||||
.returning({
|
||||
id: sessions.id,
|
||||
status: sessions.status,
|
||||
createdAt: sessions.createdAt,
|
||||
});
|
||||
|
||||
return { limited: false, session };
|
||||
});
|
||||
const activeCount = existing.filter((s) =>
|
||||
(ACTIVE_STATUSES as readonly string[]).includes(s.status),
|
||||
).length;
|
||||
if (activeCount >= Number(app.maxSessions)) {
|
||||
|
||||
if (txResult.limited) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Session limit reached (max ${app.maxSessions})`,
|
||||
error: `Session limit reached (max ${txResult.max})`,
|
||||
status: 429,
|
||||
};
|
||||
}
|
||||
|
||||
const initialStatus: SessionStatus =
|
||||
via === "pairing" ? "PENDING_PAIRING" : "PENDING_QR";
|
||||
const [session] = await db
|
||||
.insert(sessions)
|
||||
.values({ appId, status: initialStatus })
|
||||
.returning({
|
||||
id: sessions.id,
|
||||
status: sessions.status,
|
||||
createdAt: sessions.createdAt,
|
||||
});
|
||||
if (via === "qr") startSession(txResult.session.id, appId).catch(console.error);
|
||||
|
||||
if (via === "qr") startSession(session.id, appId).catch(console.error);
|
||||
|
||||
return { ok: true, session };
|
||||
return { ok: true, session: txResult.session };
|
||||
}
|
||||
|
||||
export async function setSessionStatus(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import {
|
||||
BufferJSON,
|
||||
initAuthCreds,
|
||||
|
|
@ -56,40 +56,58 @@ export async function usePostgresAuthState(
|
|||
},
|
||||
|
||||
async set(data: SignalDataSet) {
|
||||
type Row = {
|
||||
sessionId: string;
|
||||
keyType: string;
|
||||
keyId: string;
|
||||
valueEncrypted: string;
|
||||
};
|
||||
const toDelete = new Map<string, string[]>();
|
||||
const toUpsert: Row[] = [];
|
||||
|
||||
for (const [type, typeData] of Object.entries(data)) {
|
||||
for (const [id, value] of Object.entries(typeData)) {
|
||||
if (value == null) {
|
||||
await db
|
||||
.delete(sessionKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(sessionKeys.sessionId, sessionId),
|
||||
eq(sessionKeys.keyType, type),
|
||||
eq(sessionKeys.keyId, id),
|
||||
),
|
||||
);
|
||||
if (!toDelete.has(type)) toDelete.set(type, []);
|
||||
toDelete.get(type)!.push(id);
|
||||
} else {
|
||||
const json = JSON.stringify(value, BufferJSON.replacer);
|
||||
const encrypted = encryptColumn(json, appDek);
|
||||
await db
|
||||
.insert(sessionKeys)
|
||||
.values({
|
||||
sessionId,
|
||||
keyType: type,
|
||||
keyId: id,
|
||||
valueEncrypted: encrypted,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
sessionKeys.sessionId,
|
||||
sessionKeys.keyType,
|
||||
sessionKeys.keyId,
|
||||
],
|
||||
set: { valueEncrypted: encrypted, updatedAt: new Date() },
|
||||
});
|
||||
const serialized = JSON.stringify(value, BufferJSON.replacer);
|
||||
toUpsert.push({
|
||||
sessionId,
|
||||
keyType: type,
|
||||
keyId: id,
|
||||
valueEncrypted: encryptColumn(serialized, appDek),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [type, ids] of toDelete) {
|
||||
await db.delete(sessionKeys).where(
|
||||
and(
|
||||
eq(sessionKeys.sessionId, sessionId),
|
||||
eq(sessionKeys.keyType, type),
|
||||
inArray(sessionKeys.keyId, ids),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (toUpsert.length > 0) {
|
||||
await db
|
||||
.insert(sessionKeys)
|
||||
.values(toUpsert)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
sessionKeys.sessionId,
|
||||
sessionKeys.keyType,
|
||||
sessionKeys.keyId,
|
||||
],
|
||||
set: {
|
||||
valueEncrypted: sql`EXCLUDED.value_encrypted`,
|
||||
updatedAt: sql`NOW()`,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async clear() {
|
||||
|
|
|
|||
|
|
@ -95,7 +95,8 @@ async function scheduleReconnect(
|
|||
.where(eq(sessions.id, sessionId));
|
||||
|
||||
rec.timer = setTimeout(() => {
|
||||
reconnecting.get(sessionId)!.timer = null;
|
||||
const r = reconnecting.get(sessionId);
|
||||
if (r) r.timer = null;
|
||||
startSession(sessionId, appId).catch(console.error);
|
||||
}, delay);
|
||||
}
|
||||
|
|
@ -462,15 +463,30 @@ export function getAppDekForSession(sessionId: string): Buffer | null {
|
|||
return active.get(sessionId)?.dek ?? null;
|
||||
}
|
||||
|
||||
export function getDecryptedField(
|
||||
export async function getDecryptedField(
|
||||
sessionId: string,
|
||||
appId: string,
|
||||
encryptedValue: string | null,
|
||||
): string | null {
|
||||
): Promise<string | null> {
|
||||
if (!encryptedValue) return null;
|
||||
const entry = active.get(sessionId);
|
||||
if (!entry) return null;
|
||||
|
||||
let dek = active.get(sessionId)?.dek ?? null;
|
||||
|
||||
if (!dek) {
|
||||
try {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
columns: { dekEncrypted: true },
|
||||
});
|
||||
if (!app) return null;
|
||||
dek = unwrapDek(app.dekEncrypted);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return decryptColumn(encryptedValue, entry.dek);
|
||||
return decryptColumn(encryptedValue, dek);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,8 +68,12 @@ export function decryptColumn(ciphertext: string, dek: Buffer): string {
|
|||
);
|
||||
}
|
||||
|
||||
export function blindIndex(value: string, dek: Buffer): string {
|
||||
return createHmac("sha256", dek).update(value, "utf8").digest("hex");
|
||||
export function encryptWithMasterKey(plaintext: string): string {
|
||||
return encryptColumn(plaintext, getMasterKey());
|
||||
}
|
||||
|
||||
export function decryptWithMasterKey(ciphertext: string): string {
|
||||
return decryptColumn(ciphertext, getMasterKey());
|
||||
}
|
||||
|
||||
export function validateMasterKey(): void {
|
||||
|
|
|
|||
|
|
@ -81,12 +81,12 @@ function buildWaContent(
|
|||
return { url: content.mediaUrl, caption: content.caption };
|
||||
}
|
||||
|
||||
let _worker: ReturnType<typeof createWorker> | null = null;
|
||||
const _g = globalThis as { __wahooBroadcastWorker?: ReturnType<typeof createWorker> };
|
||||
|
||||
export function startBroadcastWorker(): void {
|
||||
if (_worker) return;
|
||||
_worker = createWorker(QUEUE_BROADCAST, processBroadcast as Processor);
|
||||
_worker.on("failed", (job, err) => {
|
||||
if (_g.__wahooBroadcastWorker) return;
|
||||
_g.__wahooBroadcastWorker = createWorker(QUEUE_BROADCAST, processBroadcast as Processor);
|
||||
_g.__wahooBroadcastWorker.on("failed", (job, err) => {
|
||||
console.error("[broadcast-worker] job failed", job?.id, err.message);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,13 +12,12 @@ async function processCleanup(): Promise<void> {
|
|||
);
|
||||
}
|
||||
|
||||
let _worker: Worker | null = null;
|
||||
let _queue: Queue | null = null;
|
||||
const _g = globalThis as { __wahooCleanupWorker?: Worker; __wahooCleanupQueue?: Queue };
|
||||
|
||||
export async function startCleanupWorker(): Promise<void> {
|
||||
if (_worker) return;
|
||||
_queue = createQueue(QUEUE_CLEANUP);
|
||||
await _queue.add(
|
||||
if (_g.__wahooCleanupWorker) return;
|
||||
_g.__wahooCleanupQueue = createQueue(QUEUE_CLEANUP);
|
||||
await _g.__wahooCleanupQueue.add(
|
||||
"cleanup",
|
||||
{},
|
||||
{
|
||||
|
|
@ -26,8 +25,8 @@ export async function startCleanupWorker(): Promise<void> {
|
|||
jobId: "media-cleanup-repeatable",
|
||||
},
|
||||
);
|
||||
_worker = createWorker(QUEUE_CLEANUP, processCleanup);
|
||||
_worker.on("failed", (job, err) => {
|
||||
_g.__wahooCleanupWorker = createWorker(QUEUE_CLEANUP, processCleanup);
|
||||
_g.__wahooCleanupWorker.on("failed", (job, err) => {
|
||||
console.error("[cleanup-worker] job failed", job?.id, err.message);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ function resolveSocket(sessionId: string): WASocket | null {
|
|||
async function processSend(job: Job<SendJob>): Promise<void> {
|
||||
const { messageId, sessionId, appId, waMessage, whatsappMsgId } = job.data;
|
||||
|
||||
const mapSize = (globalThis as { __wahooActiveSessions?: Map<string, unknown> }).__wahooActiveSessions?.size ?? -1;
|
||||
const sock = resolveSocket(sessionId);
|
||||
console.log(`[send-worker] resolveSocket(${sessionId}) → ${sock ? "found" : "null"} | map size: ${mapSize}`);
|
||||
if (!sock) {
|
||||
await updateMessageStatus(whatsappMsgId, sessionId, "failed");
|
||||
emitWebhook(appId, "message.failed", {
|
||||
|
|
@ -46,22 +48,25 @@ async function processSend(job: Job<SendJob>): Promise<void> {
|
|||
await updateMessageStatus(whatsappMsgId, sessionId, "sent");
|
||||
emitWebhook(appId, "message.sent", { messageId, sessionId, whatsappMsgId });
|
||||
} catch (err) {
|
||||
const reason =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: typeof err === "object" && err !== null
|
||||
? JSON.stringify(err)
|
||||
: String(err);
|
||||
console.error("[send-worker] sendMessage error:", reason, err);
|
||||
await updateMessageStatus(whatsappMsgId, sessionId, "failed");
|
||||
emitWebhook(appId, "message.failed", {
|
||||
messageId,
|
||||
sessionId,
|
||||
reason: err instanceof Error ? err.message : "unknown",
|
||||
});
|
||||
throw err;
|
||||
emitWebhook(appId, "message.failed", { messageId, sessionId, reason });
|
||||
throw err instanceof Error ? err : new Error(reason);
|
||||
}
|
||||
}
|
||||
|
||||
let _worker: ReturnType<typeof createWorker> | null = null;
|
||||
const _g = globalThis as { __wahooSendWorker?: ReturnType<typeof createWorker> };
|
||||
|
||||
export function startSendWorker(): void {
|
||||
if (_worker) return;
|
||||
_worker = createWorker(QUEUE_SEND, processSend as Processor);
|
||||
_worker.on("failed", (job, err) => {
|
||||
if (_g.__wahooSendWorker) return;
|
||||
_g.__wahooSendWorker = createWorker(QUEUE_SEND, processSend as Processor);
|
||||
_g.__wahooSendWorker.on("failed", (job, err) => {
|
||||
console.error("[send-worker] job failed", job?.id, err.message);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ const TTL = 60 * 60 * 24 * 7;
|
|||
|
||||
function getPassword(): string {
|
||||
const s = process.env.SESSION_SECRET;
|
||||
if (!s) throw new Error("SESSION_SECRET not set");
|
||||
if (!s || s.length < 32)
|
||||
throw new Error("SESSION_SECRET must be at least 32 characters");
|
||||
return s;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ import { randomBytes } from "node:crypto";
|
|||
|
||||
const ISSUER = "Wahoo";
|
||||
|
||||
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
export function generateTotpSecret(): string {
|
||||
return randomBytes(20)
|
||||
.toString("base64")
|
||||
.replace(/[^A-Z2-7]/gi, "A")
|
||||
.slice(0, 32)
|
||||
.toUpperCase();
|
||||
const bytes = randomBytes(20);
|
||||
return Array.from(bytes)
|
||||
.map((b) => B32[b & 0x1f])
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function getTotpUri(email: string, secret: string): string {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,39 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { verifyPassword } from "#/lib/password";
|
||||
import { verifyTotp } from "#/lib/totp";
|
||||
import { createSessionCookie, json, jsonError } from "#/lib/session-api";
|
||||
import { writeAudit } from "#/lib/audit";
|
||||
import { findUserByEmail } from "#/lib/actions/auth";
|
||||
import { findUserByEmail, verifyTotpToken } from "#/lib/actions/auth";
|
||||
|
||||
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
|
||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||
const LOGIN_LIMIT = 10;
|
||||
|
||||
function checkLoginRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = loginAttempts.get(ip);
|
||||
if (!entry || now > entry.resetAt) {
|
||||
loginAttempts.set(ip, { count: 1, resetAt: now + LOGIN_WINDOW_MS });
|
||||
return true;
|
||||
}
|
||||
if (entry.count >= LOGIN_LIMIT) return false;
|
||||
entry.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/auth/login")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
// x-forwarded-for is trusted as-is; deploy behind a trusted proxy or accept that IPs may be spoofed
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
"unknown";
|
||||
|
||||
if (!checkLoginRateLimit(ip)) {
|
||||
return jsonError("Too many login attempts", 429);
|
||||
}
|
||||
|
||||
let body: { email: string; password: string; totpToken?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
|
|
@ -28,21 +53,17 @@ export const Route = createFileRoute("/api/auth/login")({
|
|||
|
||||
if (user.totpEnabled) {
|
||||
if (!totpToken) return json({ requireTotp: true }, 200);
|
||||
if (!verifyTotp(user.totpSecret!, totpToken))
|
||||
return jsonError("Invalid TOTP token", 401);
|
||||
const totpOk = await verifyTotpToken(user, totpToken);
|
||||
if (!totpOk) return jsonError("Invalid TOTP token", 401);
|
||||
}
|
||||
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for") ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
undefined;
|
||||
await writeAudit({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "login",
|
||||
resourceType: "user",
|
||||
resourceId: user.id,
|
||||
ip,
|
||||
ip: ip === "unknown" ? undefined : ip,
|
||||
userAgent: request.headers.get("user-agent") ?? undefined,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,12 +13,17 @@ export const Route = createFileRoute("/api/v1/admin/sessions/$id/")({
|
|||
const row = await findSession(params.id, { userId: sess.userId });
|
||||
if (!row) return jsonError("Not found", 404);
|
||||
|
||||
const [phoneNumber, displayName] = await Promise.all([
|
||||
getDecryptedField(row.id, row.appId, row.phoneNumber),
|
||||
getDecryptedField(row.id, row.appId, row.displayName),
|
||||
]);
|
||||
|
||||
return json({
|
||||
id: row.id,
|
||||
appId: row.appId,
|
||||
status: row.status,
|
||||
phoneNumber: getDecryptedField(row.id, row.phoneNumber),
|
||||
displayName: getDecryptedField(row.id, row.displayName),
|
||||
phoneNumber,
|
||||
displayName,
|
||||
reconnectCount: row.reconnectCount,
|
||||
lastSeen: row.lastSeen,
|
||||
createdAt: row.createdAt,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||
import { listUserSessions, createSession } from "#/lib/actions/sessions";
|
||||
import { writeAudit } from "#/lib/audit";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/admin/sessions/")({
|
||||
server: {
|
||||
|
|
@ -29,6 +30,17 @@ export const Route = createFileRoute("/api/v1/admin/sessions/")({
|
|||
const result = await createSession(body.appId, via, sess.userId);
|
||||
|
||||
if (!result.ok) return jsonError(result.error, result.status);
|
||||
|
||||
writeAudit({
|
||||
actorType: "user",
|
||||
actorId: sess.userId,
|
||||
action: "session.create",
|
||||
resourceType: "session",
|
||||
resourceId: result.session.id,
|
||||
ip: request.headers.get("x-forwarded-for")?.split(",")[0].trim(),
|
||||
userAgent: request.headers.get("user-agent") ?? undefined,
|
||||
}).catch(console.error);
|
||||
|
||||
return json(result.session, 201);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { jsonError } from "#/lib/session-api";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { join, extname } from "node:path";
|
||||
import { LocalStorageDriver } from "#/lib/storage/local";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/media/")({
|
||||
|
|
@ -25,8 +25,23 @@ export const Route = createFileRoute("/api/v1/media/")({
|
|||
const basePath = process.env.STORAGE_LOCAL_PATH ?? "./uploads";
|
||||
try {
|
||||
const data = await readFile(join(basePath, key));
|
||||
const ext = extname(key).toLowerCase();
|
||||
const mimeMap: Record<string, string> = {
|
||||
".mp4": "video/mp4",
|
||||
".mp3": "audio/mpeg",
|
||||
".ogg": "audio/ogg",
|
||||
".pdf": "application/pdf",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".webm": "video/webm",
|
||||
".mov": "video/quicktime",
|
||||
};
|
||||
const contentType = mimeMap[ext] ?? "application/octet-stream";
|
||||
return new Response(data, {
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
headers: { "content-type": contentType },
|
||||
});
|
||||
} catch {
|
||||
return jsonError("Media not found", 404);
|
||||
|
|
|
|||
|
|
@ -14,11 +14,16 @@ export const Route = createFileRoute("/api/v1/sessions/$id/")({
|
|||
const session = await findSession(params.id, { appId: auth.appId });
|
||||
if (!session) return jsonError("Not found", 404);
|
||||
|
||||
const [phoneNumber, displayName] = await Promise.all([
|
||||
getDecryptedField(session.id, auth.appId, session.phoneNumber),
|
||||
getDecryptedField(session.id, auth.appId, session.displayName),
|
||||
]);
|
||||
|
||||
return json({
|
||||
id: session.id,
|
||||
status: session.status,
|
||||
phoneNumber: getDecryptedField(session.id, session.phoneNumber),
|
||||
displayName: getDecryptedField(session.id, session.displayName),
|
||||
phoneNumber,
|
||||
displayName,
|
||||
reconnectCount: session.reconnectCount,
|
||||
lastSeen: session.lastSeen,
|
||||
createdAt: session.createdAt,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,41 @@ import { getSessionDek } from "#/lib/dek-helper";
|
|||
import { getStorageDriver } from "#/lib/storage";
|
||||
import { createQueue, QUEUE_SEND } from "#/lib/queue";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import Busboy from "busboy";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
function parseMultipart(
|
||||
contentType: string,
|
||||
body: ReadableStream<Uint8Array>,
|
||||
): Promise<{ fields: Record<string, string>; file: { buffer: Buffer; mime: string; name: string } | null }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bb = Busboy({ headers: { "content-type": contentType } });
|
||||
const fields: Record<string, string> = {};
|
||||
let file: { buffer: Buffer; mime: string; name: string } | null = null;
|
||||
|
||||
bb.on("field", (name, val) => {
|
||||
fields[name] = val;
|
||||
});
|
||||
|
||||
bb.on("file", (name, stream, info) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on("end", () => {
|
||||
file = {
|
||||
buffer: Buffer.concat(chunks),
|
||||
mime: info.mimeType || "audio/ogg",
|
||||
name: info.filename || name,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
bb.on("finish", () => resolve({ fields, file }));
|
||||
bb.on("error", reject);
|
||||
|
||||
const nodeStream = Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
|
||||
nodeStream.pipe(bb);
|
||||
});
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/v1/sessions/$id/messages/audio")({
|
||||
server: {
|
||||
|
|
@ -28,21 +63,22 @@ export const Route = createFileRoute("/api/v1/sessions/$id/messages/audio")({
|
|||
voiceNote = false,
|
||||
mediaUrl: string | undefined;
|
||||
let uploadedKey: string | undefined;
|
||||
const mime = "audio/ogg; codecs=opus";
|
||||
let mime = "audio/ogg; codecs=opus";
|
||||
|
||||
if (ct.includes("multipart/form-data")) {
|
||||
const form = await request.formData().catch(() => null);
|
||||
if (!form) return jsonError("Invalid multipart body", 400);
|
||||
to = form.get("to") as string;
|
||||
voiceNote = form.get("voice_note") === "true";
|
||||
const file = form.get("file") as File | null;
|
||||
if (!request.body) return jsonError("Missing body", 400);
|
||||
const { fields, file } = await parseMultipart(ct, request.body);
|
||||
if (!fields.to) return jsonError("Missing field: to", 400);
|
||||
if (!file) return jsonError("Missing file field", 400);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const fileMime = file.type || "audio/ogg";
|
||||
const ext = fileMime.split("/")[1]?.split(";")[0] ?? "ogg";
|
||||
to = fields.to;
|
||||
voiceNote = fields.voice_note === "true";
|
||||
const rawMime = file.mime || "audio/ogg";
|
||||
// OGG without codec hint → assume Opus (WhatsApp requires it)
|
||||
mime = rawMime === "audio/ogg" ? "audio/ogg; codecs=opus" : rawMime;
|
||||
const ext = mime.split("/")[1]?.split(";")[0]?.trim() ?? "ogg";
|
||||
const key = `media/${auth.appId}/${session.id}/${uuidv4()}.${ext}`;
|
||||
const driver = await getStorageDriver();
|
||||
await driver.upload(key, buffer, fileMime);
|
||||
await driver.upload(key, file.buffer, mime);
|
||||
uploadedKey = key;
|
||||
mediaUrl = await driver.signedUrl(key, 3600);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,41 @@ import { getSessionDek } from "#/lib/dek-helper";
|
|||
import { getStorageDriver } from "#/lib/storage";
|
||||
import { createQueue, QUEUE_SEND } from "#/lib/queue";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import Busboy from "busboy";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
function parseMultipart(
|
||||
contentType: string,
|
||||
body: ReadableStream<Uint8Array>,
|
||||
): Promise<{ fields: Record<string, string>; file: { buffer: Buffer; mime: string; name: string } | null }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bb = Busboy({ headers: { "content-type": contentType } });
|
||||
const fields: Record<string, string> = {};
|
||||
let file: { buffer: Buffer; mime: string; name: string } | null = null;
|
||||
|
||||
bb.on("field", (name, val) => {
|
||||
fields[name] = val;
|
||||
});
|
||||
|
||||
bb.on("file", (_name, stream, info) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on("end", () => {
|
||||
file = {
|
||||
buffer: Buffer.concat(chunks),
|
||||
mime: info.mimeType || "application/octet-stream",
|
||||
name: info.filename || "file",
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
bb.on("finish", () => resolve({ fields, file }));
|
||||
bb.on("error", reject);
|
||||
|
||||
const nodeStream = Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
|
||||
nodeStream.pipe(bb);
|
||||
});
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/v1/sessions/$id/messages/document")({
|
||||
server: {
|
||||
|
|
@ -31,19 +66,17 @@ export const Route = createFileRoute("/api/v1/sessions/$id/messages/document")({
|
|||
let mime = "application/octet-stream";
|
||||
|
||||
if (ct.includes("multipart/form-data")) {
|
||||
const form = await request.formData().catch(() => null);
|
||||
if (!form) return jsonError("Invalid multipart body", 400);
|
||||
to = form.get("to") as string;
|
||||
filename = (form.get("filename") as string | null) ?? undefined;
|
||||
const file = form.get("file") as File | null;
|
||||
if (!request.body) return jsonError("Missing body", 400);
|
||||
const { fields, file } = await parseMultipart(ct, request.body);
|
||||
if (!fields.to) return jsonError("Missing field: to", 400);
|
||||
if (!file) return jsonError("Missing file field", 400);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
mime = file.type.length > 0 ? file.type : mime;
|
||||
filename = filename ?? file.name;
|
||||
to = fields.to;
|
||||
filename = fields.filename ?? file.name;
|
||||
mime = file.mime.length > 0 ? file.mime : mime;
|
||||
const ext = filename.split(".").pop() ?? "bin";
|
||||
const key = `media/${auth.appId}/${session.id}/${uuidv4()}.${ext}`;
|
||||
const driver = await getStorageDriver();
|
||||
await driver.upload(key, buffer, mime);
|
||||
await driver.upload(key, file.buffer, mime);
|
||||
uploadedKey = key;
|
||||
mediaUrl = await driver.signedUrl(key, 3600);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,41 @@ import { getSessionDek } from "#/lib/dek-helper";
|
|||
import { getStorageDriver } from "#/lib/storage";
|
||||
import { createQueue, QUEUE_SEND } from "#/lib/queue";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import Busboy from "busboy";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
function parseMultipart(
|
||||
contentType: string,
|
||||
body: ReadableStream<Uint8Array>,
|
||||
): Promise<{ fields: Record<string, string>; file: { buffer: Buffer; mime: string; name: string } | null }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bb = Busboy({ headers: { "content-type": contentType } });
|
||||
const fields: Record<string, string> = {};
|
||||
let file: { buffer: Buffer; mime: string; name: string } | null = null;
|
||||
|
||||
bb.on("field", (name, val) => {
|
||||
fields[name] = val;
|
||||
});
|
||||
|
||||
bb.on("file", (_name, stream, info) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on("end", () => {
|
||||
file = {
|
||||
buffer: Buffer.concat(chunks),
|
||||
mime: info.mimeType || "image/jpeg",
|
||||
name: info.filename || "image.jpg",
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
bb.on("finish", () => resolve({ fields, file }));
|
||||
bb.on("error", reject);
|
||||
|
||||
const nodeStream = Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
|
||||
nodeStream.pipe(bb);
|
||||
});
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/v1/sessions/$id/messages/image")({
|
||||
server: {
|
||||
|
|
@ -30,18 +65,17 @@ export const Route = createFileRoute("/api/v1/sessions/$id/messages/image")({
|
|||
let uploadedKey: string | undefined;
|
||||
|
||||
if (ct.includes("multipart/form-data")) {
|
||||
const form = await request.formData().catch(() => null);
|
||||
if (!form) return jsonError("Invalid multipart body", 400);
|
||||
to = form.get("to") as string;
|
||||
caption = (form.get("caption") as string | null) ?? undefined;
|
||||
const file = form.get("file") as File | null;
|
||||
if (!request.body) return jsonError("Missing body", 400);
|
||||
const { fields, file } = await parseMultipart(ct, request.body);
|
||||
if (!fields.to) return jsonError("Missing field: to", 400);
|
||||
if (!file) return jsonError("Missing file field", 400);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const mime = file.type.length > 0 ? file.type : "image/jpeg";
|
||||
to = fields.to;
|
||||
caption = fields.caption;
|
||||
const mime = file.mime.length > 0 ? file.mime : "image/jpeg";
|
||||
const ext = mime.split("/")[1] ?? "jpg";
|
||||
const key = `media/${auth.appId}/${session.id}/${uuidv4()}.${ext}`;
|
||||
const driver = await getStorageDriver();
|
||||
await driver.upload(key, buffer, mime);
|
||||
await driver.upload(key, file.buffer, mime);
|
||||
uploadedKey = key;
|
||||
mediaUrl = await driver.signedUrl(key, 3600);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,41 @@ import { getSessionDek } from "#/lib/dek-helper";
|
|||
import { getStorageDriver } from "#/lib/storage";
|
||||
import { createQueue, QUEUE_SEND } from "#/lib/queue";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import Busboy from "busboy";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
function parseMultipart(
|
||||
contentType: string,
|
||||
body: ReadableStream<Uint8Array>,
|
||||
): Promise<{ fields: Record<string, string>; file: { buffer: Buffer; mime: string; name: string } | null }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bb = Busboy({ headers: { "content-type": contentType } });
|
||||
const fields: Record<string, string> = {};
|
||||
let file: { buffer: Buffer; mime: string; name: string } | null = null;
|
||||
|
||||
bb.on("field", (name, val) => {
|
||||
fields[name] = val;
|
||||
});
|
||||
|
||||
bb.on("file", (_name, stream, info) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on("end", () => {
|
||||
file = {
|
||||
buffer: Buffer.concat(chunks),
|
||||
mime: info.mimeType || "video/mp4",
|
||||
name: info.filename || "video.mp4",
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
bb.on("finish", () => resolve({ fields, file }));
|
||||
bb.on("error", reject);
|
||||
|
||||
const nodeStream = Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
|
||||
nodeStream.pipe(bb);
|
||||
});
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/v1/sessions/$id/messages/video")({
|
||||
server: {
|
||||
|
|
@ -30,18 +65,17 @@ export const Route = createFileRoute("/api/v1/sessions/$id/messages/video")({
|
|||
let uploadedKey: string | undefined;
|
||||
|
||||
if (ct.includes("multipart/form-data")) {
|
||||
const form = await request.formData().catch(() => null);
|
||||
if (!form) return jsonError("Invalid multipart body", 400);
|
||||
to = form.get("to") as string;
|
||||
caption = (form.get("caption") as string | null) ?? undefined;
|
||||
const file = form.get("file") as File | null;
|
||||
if (!request.body) return jsonError("Missing body", 400);
|
||||
const { fields, file } = await parseMultipart(ct, request.body);
|
||||
if (!fields.to) return jsonError("Missing field: to", 400);
|
||||
if (!file) return jsonError("Missing file field", 400);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const mime = file.type.length > 0 ? file.type : "video/mp4";
|
||||
to = fields.to;
|
||||
caption = fields.caption;
|
||||
const mime = file.mime.length > 0 ? file.mime : "video/mp4";
|
||||
const ext = mime.split("/")[1] ?? "mp4";
|
||||
const key = `media/${auth.appId}/${session.id}/${uuidv4()}.${ext}`;
|
||||
const driver = await getStorageDriver();
|
||||
await driver.upload(key, buffer, mime);
|
||||
await driver.upload(key, file.buffer, mime);
|
||||
uploadedKey = key;
|
||||
mediaUrl = await driver.signedUrl(key, 3600);
|
||||
} else {
|
||||
|
|
@ -80,7 +114,7 @@ export const Route = createFileRoute("/api/v1/sessions/$id/messages/video")({
|
|||
sessionId: session.id,
|
||||
appId: auth.appId,
|
||||
whatsappMsgId,
|
||||
waMessage: { to, content: { video: { url: mediaUrl }, caption } },
|
||||
waMessage: { to, content: { video: { url: mediaUrl }, caption, mimetype: "video/mp4" } },
|
||||
});
|
||||
await queue.close();
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,11 @@ export const Route = createFileRoute("/api/v1/sessions/$id/pair")({
|
|||
|
||||
if (!getSocket(params.id)) {
|
||||
await startSession(params.id, auth.appId);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const POLL_MS = 200;
|
||||
const DEADLINE = Date.now() + 5000;
|
||||
while (!getSocket(params.id) && Date.now() < DEADLINE) {
|
||||
await new Promise((r) => setTimeout(r, POLL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
const sock = getSocket(params.id);
|
||||
|
|
@ -47,8 +51,8 @@ export const Route = createFileRoute("/api/v1/sessions/$id/pair")({
|
|||
const pairingCode = await sock.requestPairingCode(phoneDigits);
|
||||
return json({ pairingCode });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return jsonError(`Pairing code request failed: ${msg}`, 502);
|
||||
console.error("[pair] pairing code request failed:", err);
|
||||
return jsonError("Pairing failed", 502);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export const Route = createFileRoute("/api/v1/sessions/$id/qr")({
|
|||
if (!qrRaw) return json({ waiting: true }, 202);
|
||||
|
||||
const qrPng = await QRCode.toDataURL(qrRaw);
|
||||
return json({ qr: qrPng, qrRaw });
|
||||
return json({ qr: qrPng });
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
|||
import { authenticateApiKey } from "#/lib/api-key-auth";
|
||||
import { json, jsonError } from "#/lib/session-api";
|
||||
import { listAppSessions, createSession } from "#/lib/actions/sessions";
|
||||
import { writeAudit } from "#/lib/audit";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/sessions/")({
|
||||
server: {
|
||||
|
|
@ -28,6 +29,16 @@ export const Route = createFileRoute("/api/v1/sessions/")({
|
|||
const result = await createSession(auth.appId, via);
|
||||
if (!result.ok) return jsonError(result.error, result.status);
|
||||
|
||||
writeAudit({
|
||||
actorType: "api_key",
|
||||
actorId: auth.keyId,
|
||||
action: "session.create",
|
||||
resourceType: "session",
|
||||
resourceId: result.session.id,
|
||||
ip: request.headers.get("x-forwarded-for")?.split(",")[0].trim(),
|
||||
userAgent: request.headers.get("user-agent") ?? undefined,
|
||||
}).catch(console.error);
|
||||
|
||||
return json(result.session, 201);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,14 @@ import { initialize } from "#/lib/baileys";
|
|||
import { startSendWorker } from "#/lib/queue/send-worker";
|
||||
import { startBroadcastWorker } from "#/lib/queue/broadcast-worker";
|
||||
import { startCleanupWorker } from "#/lib/queue/cleanup-worker";
|
||||
import type { Worker } from "bullmq";
|
||||
|
||||
const _g = globalThis as {
|
||||
__wahooSendWorker?: Worker;
|
||||
__wahooBroadcastWorker?: Worker;
|
||||
__wahooCleanupWorker?: Worker;
|
||||
__wahooShutdownRegistered?: boolean;
|
||||
};
|
||||
|
||||
export default async function () {
|
||||
try {
|
||||
|
|
@ -19,4 +27,19 @@ export default async function () {
|
|||
} catch (err) {
|
||||
console.error("[session-startup] failed to start queue workers:", err);
|
||||
}
|
||||
|
||||
if (!_g.__wahooShutdownRegistered) {
|
||||
_g.__wahooShutdownRegistered = true;
|
||||
const shutdown = async () => {
|
||||
console.log("[session-startup] closing queue workers...");
|
||||
await Promise.allSettled([
|
||||
_g.__wahooSendWorker?.close(),
|
||||
_g.__wahooBroadcastWorker?.close(),
|
||||
_g.__wahooCleanupWorker?.close(),
|
||||
]);
|
||||
process.exit(0);
|
||||
};
|
||||
process.once("SIGINT", shutdown);
|
||||
process.once("SIGTERM", shutdown);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
tests/fixtures/audio.mp3
vendored
Normal file
BIN
tests/fixtures/audio.mp3
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/document.pdf
vendored
Normal file
BIN
tests/fixtures/document.pdf
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/video.mp4
vendored
Normal file
BIN
tests/fixtures/video.mp4
vendored
Normal file
Binary file not shown.
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
exclude: ["tests/**/*.spec.ts", "node_modules/**"],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue