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",
|
"@whiskeysockets/baileys": "^7.0.0-rc11",
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"bullmq": "^5.76.8",
|
"bullmq": "^5.76.8",
|
||||||
|
"busboy": "^1.6.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
|
|
@ -49,6 +50,7 @@
|
||||||
"@tanstack/eslint-config": "latest",
|
"@tanstack/eslint-config": "latest",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@types/busboy": "^1.5.4",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/pg": "^8.15.6",
|
"@types/pg": "^8.15.6",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
|
@ -792,6 +794,8 @@
|
||||||
|
|
||||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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,
|
"when": 1778829335931,
|
||||||
"tag": "0002_orange_clint_barton",
|
"tag": "0002_orange_clint_barton",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778838712770,
|
||||||
|
"tag": "0003_clammy_azazel",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
"@whiskeysockets/baileys": "^7.0.0-rc11",
|
"@whiskeysockets/baileys": "^7.0.0-rc11",
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"bullmq": "^5.76.8",
|
"bullmq": "^5.76.8",
|
||||||
|
"busboy": "^1.6.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
|
|
@ -64,6 +65,7 @@
|
||||||
"@tanstack/eslint-config": "latest",
|
"@tanstack/eslint-config": "latest",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@types/busboy": "^1.5.4",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/pg": "^8.15.6",
|
"@types/pg": "^8.15.6",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export const users = pgTable("users", {
|
||||||
passwordHash: text("password_hash").notNull(),
|
passwordHash: text("password_hash").notNull(),
|
||||||
totpSecret: text("totp_secret"),
|
totpSecret: text("totp_secret"),
|
||||||
totpEnabled: boolean("totp_enabled").notNull().default(false),
|
totpEnabled: boolean("totp_enabled").notNull().default(false),
|
||||||
|
lastUsedTotpToken: text("last_used_totp_token"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -31,7 +32,7 @@ export const apps = pgTable("apps", {
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
dekEncrypted: text("dek_encrypted").notNull(),
|
dekEncrypted: text("dek_encrypted").notNull(),
|
||||||
maxSessions: text("max_sessions").notNull().default("10"),
|
maxSessions: integer("max_sessions").notNull().default(10),
|
||||||
deletedAt: timestamp("deleted_at"),
|
deletedAt: timestamp("deleted_at"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export async function createUserApp(
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
ownerUserId: userId,
|
ownerUserId: userId,
|
||||||
dekEncrypted,
|
dekEncrypted,
|
||||||
maxSessions: String(maxSessions ?? 10),
|
maxSessions: maxSessions ?? 10,
|
||||||
})
|
})
|
||||||
.returning({ id: apps.id, name: apps.name, createdAt: apps.createdAt });
|
.returning({ id: apps.id, name: apps.name, createdAt: apps.createdAt });
|
||||||
|
|
||||||
|
|
@ -66,14 +66,14 @@ export async function updateUserApp(
|
||||||
userId: string,
|
userId: string,
|
||||||
updates: { name?: string; maxSessions?: number },
|
updates: { name?: string; maxSessions?: number },
|
||||||
audit?: { ip?: string; userAgent?: string },
|
audit?: { ip?: string; userAgent?: string },
|
||||||
): Promise<(typeof apps.$inferSelect & { maxSessions: string }) | null> {
|
): Promise<typeof apps.$inferSelect | null> {
|
||||||
const app = await findUserApp(appId, userId);
|
const app = await findUserApp(appId, userId);
|
||||||
if (!app) return null;
|
if (!app) return null;
|
||||||
|
|
||||||
const dbUpdates: Partial<typeof apps.$inferInsert> = {};
|
const dbUpdates: Partial<typeof apps.$inferInsert> = {};
|
||||||
if (updates.name !== undefined) dbUpdates.name = updates.name.trim();
|
if (updates.name !== undefined) dbUpdates.name = updates.name.trim();
|
||||||
if (updates.maxSessions !== undefined)
|
if (updates.maxSessions !== undefined)
|
||||||
dbUpdates.maxSessions = String(updates.maxSessions);
|
dbUpdates.maxSessions = updates.maxSessions;
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(apps)
|
.update(apps)
|
||||||
|
|
@ -97,7 +97,7 @@ export async function updateUserApp(
|
||||||
diffAfter: dbUpdates,
|
diffAfter: dbUpdates,
|
||||||
});
|
});
|
||||||
|
|
||||||
return updated as App & { maxSessions: string };
|
return updated as App;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteUserApp(
|
export async function deleteUserApp(
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,16 @@ export async function queryAuditLog(
|
||||||
if (filters.action) conditions.push(eq(auditLog.action, filters.action));
|
if (filters.action) conditions.push(eq(auditLog.action, filters.action));
|
||||||
if (filters.resourceType)
|
if (filters.resourceType)
|
||||||
conditions.push(eq(auditLog.resourceType, filters.resourceType));
|
conditions.push(eq(auditLog.resourceType, filters.resourceType));
|
||||||
if (filters.since)
|
if (filters.since) {
|
||||||
conditions.push(gte(auditLog.createdAt, new Date(filters.since)));
|
const d = new Date(filters.since);
|
||||||
if (filters.until)
|
if (isNaN(d.getTime())) return { data: [], page, limit };
|
||||||
conditions.push(lte(auditLog.createdAt, new Date(filters.until)));
|
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({
|
const rows = await db.query.auditLog.findMany({
|
||||||
where: and(...conditions),
|
where: and(...conditions),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "#/db/index";
|
import { db } from "#/db/index";
|
||||||
import { users } from "#/db/schema";
|
import { users } from "#/db/schema";
|
||||||
import { generateTotpSecret, getTotpUri, verifyTotp } from "#/lib/totp";
|
import { generateTotpSecret, getTotpUri, verifyTotp } from "#/lib/totp";
|
||||||
|
import {
|
||||||
|
decryptWithMasterKey,
|
||||||
|
encryptWithMasterKey,
|
||||||
|
} from "#/lib/crypto";
|
||||||
|
|
||||||
export async function findUserByEmail(email: string) {
|
export async function findUserByEmail(email: string) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -31,7 +36,7 @@ export async function enrollTotpSecret(
|
||||||
const secret = generateTotpSecret();
|
const secret = generateTotpSecret();
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ totpSecret: secret })
|
.set({ totpSecret: encryptWithMasterKey(secret) })
|
||||||
.where(eq(users.id, userId));
|
.where(eq(users.id, userId));
|
||||||
return { ok: true, secret, uri: getTotpUri(user.email, secret) };
|
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 };
|
return { ok: false, error: "Enroll first", status: 400 };
|
||||||
if (user.totpEnabled)
|
if (user.totpEnabled)
|
||||||
return { ok: false, error: "TOTP already enabled", status: 409 };
|
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 };
|
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 };
|
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> {
|
export async function disableTotp(userId: string): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.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 { db } from "#/db/index";
|
||||||
import { apps, sessions, sessionKeys } from "#/db/schema";
|
import { apps, sessions, sessionKeys } from "#/db/schema";
|
||||||
import { startSession, stopSession, emitWebhook } from "#/lib/baileys";
|
import { startSession, stopSession, emitWebhook } from "#/lib/baileys";
|
||||||
|
|
@ -98,28 +98,34 @@ export async function createSession(
|
||||||
|
|
||||||
const app = await db.query.apps.findFirst({
|
const app = await db.query.apps.findFirst({
|
||||||
where: appWhere,
|
where: appWhere,
|
||||||
columns: { id: true, maxSessions: true },
|
columns: { id: true },
|
||||||
});
|
});
|
||||||
if (!app) return { ok: false, error: "App not found", status: 404 };
|
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 activeCount = existing.filter((s) =>
|
|
||||||
(ACTIVE_STATUSES as readonly string[]).includes(s.status),
|
|
||||||
).length;
|
|
||||||
if (activeCount >= Number(app.maxSessions)) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: `Session limit reached (max ${app.maxSessions})`,
|
|
||||||
status: 429,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialStatus: SessionStatus =
|
const initialStatus: SessionStatus =
|
||||||
via === "pairing" ? "PENDING_PAIRING" : "PENDING_QR";
|
via === "pairing" ? "PENDING_PAIRING" : "PENDING_QR";
|
||||||
const [session] = await db
|
|
||||||
|
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)
|
.insert(sessions)
|
||||||
.values({ appId, status: initialStatus })
|
.values({ appId, status: initialStatus })
|
||||||
.returning({
|
.returning({
|
||||||
|
|
@ -128,9 +134,20 @@ export async function createSession(
|
||||||
createdAt: sessions.createdAt,
|
createdAt: sessions.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (via === "qr") startSession(session.id, appId).catch(console.error);
|
return { limited: false, session };
|
||||||
|
});
|
||||||
|
|
||||||
return { ok: true, session };
|
if (txResult.limited) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Session limit reached (max ${txResult.max})`,
|
||||||
|
status: 429,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (via === "qr") startSession(txResult.session.id, appId).catch(console.error);
|
||||||
|
|
||||||
|
return { ok: true, session: txResult.session };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setSessionStatus(
|
export async function setSessionStatus(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
BufferJSON,
|
BufferJSON,
|
||||||
initAuthCreds,
|
initAuthCreds,
|
||||||
|
|
@ -56,40 +56,58 @@ export async function usePostgresAuthState(
|
||||||
},
|
},
|
||||||
|
|
||||||
async set(data: SignalDataSet) {
|
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 [type, typeData] of Object.entries(data)) {
|
||||||
for (const [id, value] of Object.entries(typeData)) {
|
for (const [id, value] of Object.entries(typeData)) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
await db
|
if (!toDelete.has(type)) toDelete.set(type, []);
|
||||||
.delete(sessionKeys)
|
toDelete.get(type)!.push(id);
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(sessionKeys.sessionId, sessionId),
|
|
||||||
eq(sessionKeys.keyType, type),
|
|
||||||
eq(sessionKeys.keyId, id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
const json = JSON.stringify(value, BufferJSON.replacer);
|
const serialized = JSON.stringify(value, BufferJSON.replacer);
|
||||||
const encrypted = encryptColumn(json, appDek);
|
toUpsert.push({
|
||||||
await db
|
|
||||||
.insert(sessionKeys)
|
|
||||||
.values({
|
|
||||||
sessionId,
|
sessionId,
|
||||||
keyType: type,
|
keyType: type,
|
||||||
keyId: id,
|
keyId: id,
|
||||||
valueEncrypted: encrypted,
|
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({
|
.onConflictDoUpdate({
|
||||||
target: [
|
target: [
|
||||||
sessionKeys.sessionId,
|
sessionKeys.sessionId,
|
||||||
sessionKeys.keyType,
|
sessionKeys.keyType,
|
||||||
sessionKeys.keyId,
|
sessionKeys.keyId,
|
||||||
],
|
],
|
||||||
set: { valueEncrypted: encrypted, updatedAt: new Date() },
|
set: {
|
||||||
|
valueEncrypted: sql`EXCLUDED.value_encrypted`,
|
||||||
|
updatedAt: sql`NOW()`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async clear() {
|
async clear() {
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,8 @@ async function scheduleReconnect(
|
||||||
.where(eq(sessions.id, sessionId));
|
.where(eq(sessions.id, sessionId));
|
||||||
|
|
||||||
rec.timer = setTimeout(() => {
|
rec.timer = setTimeout(() => {
|
||||||
reconnecting.get(sessionId)!.timer = null;
|
const r = reconnecting.get(sessionId);
|
||||||
|
if (r) r.timer = null;
|
||||||
startSession(sessionId, appId).catch(console.error);
|
startSession(sessionId, appId).catch(console.error);
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
@ -462,15 +463,30 @@ export function getAppDekForSession(sessionId: string): Buffer | null {
|
||||||
return active.get(sessionId)?.dek ?? null;
|
return active.get(sessionId)?.dek ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDecryptedField(
|
export async function getDecryptedField(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
appId: string,
|
||||||
encryptedValue: string | null,
|
encryptedValue: string | null,
|
||||||
): string | null {
|
): Promise<string | null> {
|
||||||
if (!encryptedValue) return null;
|
if (!encryptedValue) return null;
|
||||||
const entry = active.get(sessionId);
|
|
||||||
if (!entry) return null;
|
let dek = active.get(sessionId)?.dek ?? null;
|
||||||
|
|
||||||
|
if (!dek) {
|
||||||
try {
|
try {
|
||||||
return decryptColumn(encryptedValue, entry.dek);
|
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, dek);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,12 @@ export function decryptColumn(ciphertext: string, dek: Buffer): string {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function blindIndex(value: string, dek: Buffer): string {
|
export function encryptWithMasterKey(plaintext: string): string {
|
||||||
return createHmac("sha256", dek).update(value, "utf8").digest("hex");
|
return encryptColumn(plaintext, getMasterKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptWithMasterKey(ciphertext: string): string {
|
||||||
|
return decryptColumn(ciphertext, getMasterKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateMasterKey(): void {
|
export function validateMasterKey(): void {
|
||||||
|
|
|
||||||
|
|
@ -81,12 +81,12 @@ function buildWaContent(
|
||||||
return { url: content.mediaUrl, caption: content.caption };
|
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 {
|
export function startBroadcastWorker(): void {
|
||||||
if (_worker) return;
|
if (_g.__wahooBroadcastWorker) return;
|
||||||
_worker = createWorker(QUEUE_BROADCAST, processBroadcast as Processor);
|
_g.__wahooBroadcastWorker = createWorker(QUEUE_BROADCAST, processBroadcast as Processor);
|
||||||
_worker.on("failed", (job, err) => {
|
_g.__wahooBroadcastWorker.on("failed", (job, err) => {
|
||||||
console.error("[broadcast-worker] job failed", job?.id, err.message);
|
console.error("[broadcast-worker] job failed", job?.id, err.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,12 @@ async function processCleanup(): Promise<void> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let _worker: Worker | null = null;
|
const _g = globalThis as { __wahooCleanupWorker?: Worker; __wahooCleanupQueue?: Queue };
|
||||||
let _queue: Queue | null = null;
|
|
||||||
|
|
||||||
export async function startCleanupWorker(): Promise<void> {
|
export async function startCleanupWorker(): Promise<void> {
|
||||||
if (_worker) return;
|
if (_g.__wahooCleanupWorker) return;
|
||||||
_queue = createQueue(QUEUE_CLEANUP);
|
_g.__wahooCleanupQueue = createQueue(QUEUE_CLEANUP);
|
||||||
await _queue.add(
|
await _g.__wahooCleanupQueue.add(
|
||||||
"cleanup",
|
"cleanup",
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
|
|
@ -26,8 +25,8 @@ export async function startCleanupWorker(): Promise<void> {
|
||||||
jobId: "media-cleanup-repeatable",
|
jobId: "media-cleanup-repeatable",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
_worker = createWorker(QUEUE_CLEANUP, processCleanup);
|
_g.__wahooCleanupWorker = createWorker(QUEUE_CLEANUP, processCleanup);
|
||||||
_worker.on("failed", (job, err) => {
|
_g.__wahooCleanupWorker.on("failed", (job, err) => {
|
||||||
console.error("[cleanup-worker] job failed", job?.id, err.message);
|
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> {
|
async function processSend(job: Job<SendJob>): Promise<void> {
|
||||||
const { messageId, sessionId, appId, waMessage, whatsappMsgId } = job.data;
|
const { messageId, sessionId, appId, waMessage, whatsappMsgId } = job.data;
|
||||||
|
|
||||||
|
const mapSize = (globalThis as { __wahooActiveSessions?: Map<string, unknown> }).__wahooActiveSessions?.size ?? -1;
|
||||||
const sock = resolveSocket(sessionId);
|
const sock = resolveSocket(sessionId);
|
||||||
|
console.log(`[send-worker] resolveSocket(${sessionId}) → ${sock ? "found" : "null"} | map size: ${mapSize}`);
|
||||||
if (!sock) {
|
if (!sock) {
|
||||||
await updateMessageStatus(whatsappMsgId, sessionId, "failed");
|
await updateMessageStatus(whatsappMsgId, sessionId, "failed");
|
||||||
emitWebhook(appId, "message.failed", {
|
emitWebhook(appId, "message.failed", {
|
||||||
|
|
@ -46,22 +48,25 @@ async function processSend(job: Job<SendJob>): Promise<void> {
|
||||||
await updateMessageStatus(whatsappMsgId, sessionId, "sent");
|
await updateMessageStatus(whatsappMsgId, sessionId, "sent");
|
||||||
emitWebhook(appId, "message.sent", { messageId, sessionId, whatsappMsgId });
|
emitWebhook(appId, "message.sent", { messageId, sessionId, whatsappMsgId });
|
||||||
} catch (err) {
|
} 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");
|
await updateMessageStatus(whatsappMsgId, sessionId, "failed");
|
||||||
emitWebhook(appId, "message.failed", {
|
emitWebhook(appId, "message.failed", { messageId, sessionId, reason });
|
||||||
messageId,
|
throw err instanceof Error ? err : new Error(reason);
|
||||||
sessionId,
|
|
||||||
reason: err instanceof Error ? err.message : "unknown",
|
|
||||||
});
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _worker: ReturnType<typeof createWorker> | null = null;
|
const _g = globalThis as { __wahooSendWorker?: ReturnType<typeof createWorker> };
|
||||||
|
|
||||||
export function startSendWorker(): void {
|
export function startSendWorker(): void {
|
||||||
if (_worker) return;
|
if (_g.__wahooSendWorker) return;
|
||||||
_worker = createWorker(QUEUE_SEND, processSend as Processor);
|
_g.__wahooSendWorker = createWorker(QUEUE_SEND, processSend as Processor);
|
||||||
_worker.on("failed", (job, err) => {
|
_g.__wahooSendWorker.on("failed", (job, err) => {
|
||||||
console.error("[send-worker] job failed", job?.id, err.message);
|
console.error("[send-worker] job failed", job?.id, err.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ const TTL = 60 * 60 * 24 * 7;
|
||||||
|
|
||||||
function getPassword(): string {
|
function getPassword(): string {
|
||||||
const s = process.env.SESSION_SECRET;
|
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;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
const ISSUER = "Wahoo";
|
const ISSUER = "Wahoo";
|
||||||
|
|
||||||
|
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
|
||||||
export function generateTotpSecret(): string {
|
export function generateTotpSecret(): string {
|
||||||
return randomBytes(20)
|
const bytes = randomBytes(20);
|
||||||
.toString("base64")
|
return Array.from(bytes)
|
||||||
.replace(/[^A-Z2-7]/gi, "A")
|
.map((b) => B32[b & 0x1f])
|
||||||
.slice(0, 32)
|
.join("");
|
||||||
.toUpperCase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTotpUri(email: string, secret: string): string {
|
export function getTotpUri(email: string, secret: string): string {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,39 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { verifyPassword } from "#/lib/password";
|
import { verifyPassword } from "#/lib/password";
|
||||||
import { verifyTotp } from "#/lib/totp";
|
|
||||||
import { createSessionCookie, json, jsonError } from "#/lib/session-api";
|
import { createSessionCookie, json, jsonError } from "#/lib/session-api";
|
||||||
import { writeAudit } from "#/lib/audit";
|
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")({
|
export const Route = createFileRoute("/api/auth/login")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ request }) => {
|
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 };
|
let body: { email: string; password: string; totpToken?: string };
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
|
|
@ -28,21 +53,17 @@ export const Route = createFileRoute("/api/auth/login")({
|
||||||
|
|
||||||
if (user.totpEnabled) {
|
if (user.totpEnabled) {
|
||||||
if (!totpToken) return json({ requireTotp: true }, 200);
|
if (!totpToken) return json({ requireTotp: true }, 200);
|
||||||
if (!verifyTotp(user.totpSecret!, totpToken))
|
const totpOk = await verifyTotpToken(user, totpToken);
|
||||||
return jsonError("Invalid TOTP token", 401);
|
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({
|
await writeAudit({
|
||||||
actorType: "user",
|
actorType: "user",
|
||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
action: "login",
|
action: "login",
|
||||||
resourceType: "user",
|
resourceType: "user",
|
||||||
resourceId: user.id,
|
resourceId: user.id,
|
||||||
ip,
|
ip: ip === "unknown" ? undefined : ip,
|
||||||
userAgent: request.headers.get("user-agent") ?? undefined,
|
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 });
|
const row = await findSession(params.id, { userId: sess.userId });
|
||||||
if (!row) return jsonError("Not found", 404);
|
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({
|
return json({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
appId: row.appId,
|
appId: row.appId,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
phoneNumber: getDecryptedField(row.id, row.phoneNumber),
|
phoneNumber,
|
||||||
displayName: getDecryptedField(row.id, row.displayName),
|
displayName,
|
||||||
reconnectCount: row.reconnectCount,
|
reconnectCount: row.reconnectCount,
|
||||||
lastSeen: row.lastSeen,
|
lastSeen: row.lastSeen,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { json, jsonError, readSession } from "#/lib/session-api";
|
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||||
import { listUserSessions, createSession } from "#/lib/actions/sessions";
|
import { listUserSessions, createSession } from "#/lib/actions/sessions";
|
||||||
|
import { writeAudit } from "#/lib/audit";
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/v1/admin/sessions/")({
|
export const Route = createFileRoute("/api/v1/admin/sessions/")({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -29,6 +30,17 @@ export const Route = createFileRoute("/api/v1/admin/sessions/")({
|
||||||
const result = await createSession(body.appId, via, sess.userId);
|
const result = await createSession(body.appId, via, sess.userId);
|
||||||
|
|
||||||
if (!result.ok) return jsonError(result.error, result.status);
|
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);
|
return json(result.session, 201);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { jsonError } from "#/lib/session-api";
|
import { jsonError } from "#/lib/session-api";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join, extname } from "node:path";
|
||||||
import { LocalStorageDriver } from "#/lib/storage/local";
|
import { LocalStorageDriver } from "#/lib/storage/local";
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/v1/media/")({
|
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";
|
const basePath = process.env.STORAGE_LOCAL_PATH ?? "./uploads";
|
||||||
try {
|
try {
|
||||||
const data = await readFile(join(basePath, key));
|
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, {
|
return new Response(data, {
|
||||||
headers: { "content-type": "application/octet-stream" },
|
headers: { "content-type": contentType },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return jsonError("Media not found", 404);
|
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 });
|
const session = await findSession(params.id, { appId: auth.appId });
|
||||||
if (!session) return jsonError("Not found", 404);
|
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({
|
return json({
|
||||||
id: session.id,
|
id: session.id,
|
||||||
status: session.status,
|
status: session.status,
|
||||||
phoneNumber: getDecryptedField(session.id, session.phoneNumber),
|
phoneNumber,
|
||||||
displayName: getDecryptedField(session.id, session.displayName),
|
displayName,
|
||||||
reconnectCount: session.reconnectCount,
|
reconnectCount: session.reconnectCount,
|
||||||
lastSeen: session.lastSeen,
|
lastSeen: session.lastSeen,
|
||||||
createdAt: session.createdAt,
|
createdAt: session.createdAt,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,41 @@ import { getSessionDek } from "#/lib/dek-helper";
|
||||||
import { getStorageDriver } from "#/lib/storage";
|
import { getStorageDriver } from "#/lib/storage";
|
||||||
import { createQueue, QUEUE_SEND } from "#/lib/queue";
|
import { createQueue, QUEUE_SEND } from "#/lib/queue";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
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")({
|
export const Route = createFileRoute("/api/v1/sessions/$id/messages/audio")({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -28,21 +63,22 @@ export const Route = createFileRoute("/api/v1/sessions/$id/messages/audio")({
|
||||||
voiceNote = false,
|
voiceNote = false,
|
||||||
mediaUrl: string | undefined;
|
mediaUrl: string | undefined;
|
||||||
let uploadedKey: string | undefined;
|
let uploadedKey: string | undefined;
|
||||||
const mime = "audio/ogg; codecs=opus";
|
let mime = "audio/ogg; codecs=opus";
|
||||||
|
|
||||||
if (ct.includes("multipart/form-data")) {
|
if (ct.includes("multipart/form-data")) {
|
||||||
const form = await request.formData().catch(() => null);
|
if (!request.body) return jsonError("Missing body", 400);
|
||||||
if (!form) return jsonError("Invalid multipart body", 400);
|
const { fields, file } = await parseMultipart(ct, request.body);
|
||||||
to = form.get("to") as string;
|
if (!fields.to) return jsonError("Missing field: to", 400);
|
||||||
voiceNote = form.get("voice_note") === "true";
|
|
||||||
const file = form.get("file") as File | null;
|
|
||||||
if (!file) return jsonError("Missing file field", 400);
|
if (!file) return jsonError("Missing file field", 400);
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
to = fields.to;
|
||||||
const fileMime = file.type || "audio/ogg";
|
voiceNote = fields.voice_note === "true";
|
||||||
const ext = fileMime.split("/")[1]?.split(";")[0] ?? "ogg";
|
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 key = `media/${auth.appId}/${session.id}/${uuidv4()}.${ext}`;
|
||||||
const driver = await getStorageDriver();
|
const driver = await getStorageDriver();
|
||||||
await driver.upload(key, buffer, fileMime);
|
await driver.upload(key, file.buffer, mime);
|
||||||
uploadedKey = key;
|
uploadedKey = key;
|
||||||
mediaUrl = await driver.signedUrl(key, 3600);
|
mediaUrl = await driver.signedUrl(key, 3600);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,41 @@ import { getSessionDek } from "#/lib/dek-helper";
|
||||||
import { getStorageDriver } from "#/lib/storage";
|
import { getStorageDriver } from "#/lib/storage";
|
||||||
import { createQueue, QUEUE_SEND } from "#/lib/queue";
|
import { createQueue, QUEUE_SEND } from "#/lib/queue";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
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")({
|
export const Route = createFileRoute("/api/v1/sessions/$id/messages/document")({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -31,19 +66,17 @@ export const Route = createFileRoute("/api/v1/sessions/$id/messages/document")({
|
||||||
let mime = "application/octet-stream";
|
let mime = "application/octet-stream";
|
||||||
|
|
||||||
if (ct.includes("multipart/form-data")) {
|
if (ct.includes("multipart/form-data")) {
|
||||||
const form = await request.formData().catch(() => null);
|
if (!request.body) return jsonError("Missing body", 400);
|
||||||
if (!form) return jsonError("Invalid multipart body", 400);
|
const { fields, file } = await parseMultipart(ct, request.body);
|
||||||
to = form.get("to") as string;
|
if (!fields.to) return jsonError("Missing field: to", 400);
|
||||||
filename = (form.get("filename") as string | null) ?? undefined;
|
|
||||||
const file = form.get("file") as File | null;
|
|
||||||
if (!file) return jsonError("Missing file field", 400);
|
if (!file) return jsonError("Missing file field", 400);
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
to = fields.to;
|
||||||
mime = file.type.length > 0 ? file.type : mime;
|
filename = fields.filename ?? file.name;
|
||||||
filename = filename ?? file.name;
|
mime = file.mime.length > 0 ? file.mime : mime;
|
||||||
const ext = filename.split(".").pop() ?? "bin";
|
const ext = filename.split(".").pop() ?? "bin";
|
||||||
const key = `media/${auth.appId}/${session.id}/${uuidv4()}.${ext}`;
|
const key = `media/${auth.appId}/${session.id}/${uuidv4()}.${ext}`;
|
||||||
const driver = await getStorageDriver();
|
const driver = await getStorageDriver();
|
||||||
await driver.upload(key, buffer, mime);
|
await driver.upload(key, file.buffer, mime);
|
||||||
uploadedKey = key;
|
uploadedKey = key;
|
||||||
mediaUrl = await driver.signedUrl(key, 3600);
|
mediaUrl = await driver.signedUrl(key, 3600);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,41 @@ import { getSessionDek } from "#/lib/dek-helper";
|
||||||
import { getStorageDriver } from "#/lib/storage";
|
import { getStorageDriver } from "#/lib/storage";
|
||||||
import { createQueue, QUEUE_SEND } from "#/lib/queue";
|
import { createQueue, QUEUE_SEND } from "#/lib/queue";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
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")({
|
export const Route = createFileRoute("/api/v1/sessions/$id/messages/image")({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -30,18 +65,17 @@ export const Route = createFileRoute("/api/v1/sessions/$id/messages/image")({
|
||||||
let uploadedKey: string | undefined;
|
let uploadedKey: string | undefined;
|
||||||
|
|
||||||
if (ct.includes("multipart/form-data")) {
|
if (ct.includes("multipart/form-data")) {
|
||||||
const form = await request.formData().catch(() => null);
|
if (!request.body) return jsonError("Missing body", 400);
|
||||||
if (!form) return jsonError("Invalid multipart body", 400);
|
const { fields, file } = await parseMultipart(ct, request.body);
|
||||||
to = form.get("to") as string;
|
if (!fields.to) return jsonError("Missing field: to", 400);
|
||||||
caption = (form.get("caption") as string | null) ?? undefined;
|
|
||||||
const file = form.get("file") as File | null;
|
|
||||||
if (!file) return jsonError("Missing file field", 400);
|
if (!file) return jsonError("Missing file field", 400);
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
to = fields.to;
|
||||||
const mime = file.type.length > 0 ? file.type : "image/jpeg";
|
caption = fields.caption;
|
||||||
|
const mime = file.mime.length > 0 ? file.mime : "image/jpeg";
|
||||||
const ext = mime.split("/")[1] ?? "jpg";
|
const ext = mime.split("/")[1] ?? "jpg";
|
||||||
const key = `media/${auth.appId}/${session.id}/${uuidv4()}.${ext}`;
|
const key = `media/${auth.appId}/${session.id}/${uuidv4()}.${ext}`;
|
||||||
const driver = await getStorageDriver();
|
const driver = await getStorageDriver();
|
||||||
await driver.upload(key, buffer, mime);
|
await driver.upload(key, file.buffer, mime);
|
||||||
uploadedKey = key;
|
uploadedKey = key;
|
||||||
mediaUrl = await driver.signedUrl(key, 3600);
|
mediaUrl = await driver.signedUrl(key, 3600);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,41 @@ import { getSessionDek } from "#/lib/dek-helper";
|
||||||
import { getStorageDriver } from "#/lib/storage";
|
import { getStorageDriver } from "#/lib/storage";
|
||||||
import { createQueue, QUEUE_SEND } from "#/lib/queue";
|
import { createQueue, QUEUE_SEND } from "#/lib/queue";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
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")({
|
export const Route = createFileRoute("/api/v1/sessions/$id/messages/video")({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -30,18 +65,17 @@ export const Route = createFileRoute("/api/v1/sessions/$id/messages/video")({
|
||||||
let uploadedKey: string | undefined;
|
let uploadedKey: string | undefined;
|
||||||
|
|
||||||
if (ct.includes("multipart/form-data")) {
|
if (ct.includes("multipart/form-data")) {
|
||||||
const form = await request.formData().catch(() => null);
|
if (!request.body) return jsonError("Missing body", 400);
|
||||||
if (!form) return jsonError("Invalid multipart body", 400);
|
const { fields, file } = await parseMultipart(ct, request.body);
|
||||||
to = form.get("to") as string;
|
if (!fields.to) return jsonError("Missing field: to", 400);
|
||||||
caption = (form.get("caption") as string | null) ?? undefined;
|
|
||||||
const file = form.get("file") as File | null;
|
|
||||||
if (!file) return jsonError("Missing file field", 400);
|
if (!file) return jsonError("Missing file field", 400);
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
to = fields.to;
|
||||||
const mime = file.type.length > 0 ? file.type : "video/mp4";
|
caption = fields.caption;
|
||||||
|
const mime = file.mime.length > 0 ? file.mime : "video/mp4";
|
||||||
const ext = mime.split("/")[1] ?? "mp4";
|
const ext = mime.split("/")[1] ?? "mp4";
|
||||||
const key = `media/${auth.appId}/${session.id}/${uuidv4()}.${ext}`;
|
const key = `media/${auth.appId}/${session.id}/${uuidv4()}.${ext}`;
|
||||||
const driver = await getStorageDriver();
|
const driver = await getStorageDriver();
|
||||||
await driver.upload(key, buffer, mime);
|
await driver.upload(key, file.buffer, mime);
|
||||||
uploadedKey = key;
|
uploadedKey = key;
|
||||||
mediaUrl = await driver.signedUrl(key, 3600);
|
mediaUrl = await driver.signedUrl(key, 3600);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -80,7 +114,7 @@ export const Route = createFileRoute("/api/v1/sessions/$id/messages/video")({
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
appId: auth.appId,
|
appId: auth.appId,
|
||||||
whatsappMsgId,
|
whatsappMsgId,
|
||||||
waMessage: { to, content: { video: { url: mediaUrl }, caption } },
|
waMessage: { to, content: { video: { url: mediaUrl }, caption, mimetype: "video/mp4" } },
|
||||||
});
|
});
|
||||||
await queue.close();
|
await queue.close();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,11 @@ export const Route = createFileRoute("/api/v1/sessions/$id/pair")({
|
||||||
|
|
||||||
if (!getSocket(params.id)) {
|
if (!getSocket(params.id)) {
|
||||||
await startSession(params.id, auth.appId);
|
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);
|
const sock = getSocket(params.id);
|
||||||
|
|
@ -47,8 +51,8 @@ export const Route = createFileRoute("/api/v1/sessions/$id/pair")({
|
||||||
const pairingCode = await sock.requestPairingCode(phoneDigits);
|
const pairingCode = await sock.requestPairingCode(phoneDigits);
|
||||||
return json({ pairingCode });
|
return json({ pairingCode });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
console.error("[pair] pairing code request failed:", err);
|
||||||
return jsonError(`Pairing code request failed: ${msg}`, 502);
|
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);
|
if (!qrRaw) return json({ waiting: true }, 202);
|
||||||
|
|
||||||
const qrPng = await QRCode.toDataURL(qrRaw);
|
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 { authenticateApiKey } from "#/lib/api-key-auth";
|
||||||
import { json, jsonError } from "#/lib/session-api";
|
import { json, jsonError } from "#/lib/session-api";
|
||||||
import { listAppSessions, createSession } from "#/lib/actions/sessions";
|
import { listAppSessions, createSession } from "#/lib/actions/sessions";
|
||||||
|
import { writeAudit } from "#/lib/audit";
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/v1/sessions/")({
|
export const Route = createFileRoute("/api/v1/sessions/")({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -28,6 +29,16 @@ export const Route = createFileRoute("/api/v1/sessions/")({
|
||||||
const result = await createSession(auth.appId, via);
|
const result = await createSession(auth.appId, via);
|
||||||
if (!result.ok) return jsonError(result.error, result.status);
|
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);
|
return json(result.session, 201);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,14 @@ import { initialize } from "#/lib/baileys";
|
||||||
import { startSendWorker } from "#/lib/queue/send-worker";
|
import { startSendWorker } from "#/lib/queue/send-worker";
|
||||||
import { startBroadcastWorker } from "#/lib/queue/broadcast-worker";
|
import { startBroadcastWorker } from "#/lib/queue/broadcast-worker";
|
||||||
import { startCleanupWorker } from "#/lib/queue/cleanup-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 () {
|
export default async function () {
|
||||||
try {
|
try {
|
||||||
|
|
@ -19,4 +27,19 @@ export default async function () {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[session-startup] failed to start queue workers:", 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