refactor: improvement code review

This commit is contained in:
Edqe14 2026-05-15 20:25:20 +07:00
parent 3bbe2ac108
commit e634ffddf2
Signed by: yukabi
GPG key ID: A1AAC4DC1157D70D
35 changed files with 1360 additions and 154 deletions

View file

@ -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=="],

View 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;

View 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": {}
}
}

View file

@ -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
} }
] ]
} }

View file

@ -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",

View file

@ -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(),
}); });

View file

@ -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(

View file

@ -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),

View file

@ -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)

View file

@ -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,39 +98,56 @@ 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({ const initialStatus: SessionStatus =
where: eq(sessions.appId, appId), via === "pairing" ? "PENDING_PAIRING" : "PENDING_QR";
columns: { status: true },
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), if (txResult.limited) {
).length;
if (activeCount >= Number(app.maxSessions)) {
return { return {
ok: false, ok: false,
error: `Session limit reached (max ${app.maxSessions})`, error: `Session limit reached (max ${txResult.max})`,
status: 429, status: 429,
}; };
} }
const initialStatus: SessionStatus = if (via === "qr") startSession(txResult.session.id, appId).catch(console.error);
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(session.id, appId).catch(console.error); return { ok: true, session: txResult.session };
return { ok: true, session };
} }
export async function setSessionStatus( export async function setSessionStatus(

View file

@ -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 sessionId,
.insert(sessionKeys) keyType: type,
.values({ keyId: id,
sessionId, valueEncrypted: encryptColumn(serialized, appDek),
keyType: type, });
keyId: id,
valueEncrypted: encrypted,
})
.onConflictDoUpdate({
target: [
sessionKeys.sessionId,
sessionKeys.keyType,
sessionKeys.keyId,
],
set: { valueEncrypted: encrypted, updatedAt: new Date() },
});
} }
} }
} }
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() { async clear() {

View file

@ -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 {
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 { try {
return decryptColumn(encryptedValue, entry.dek); return decryptColumn(encryptedValue, dek);
} catch { } catch {
return null; return null;
} }

View file

@ -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 {

View file

@ -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);
}); });
} }

View file

@ -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);
}); });
} }

View file

@ -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);
}); });
} }

View file

@ -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;
} }

View file

@ -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 {

View file

@ -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,
}); });

View file

@ -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,

View file

@ -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);
}, },
}, },

View file

@ -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);

View file

@ -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,

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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();

View file

@ -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);
} }
}, },
}, },

View file

@ -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 });
}, },
}, },
}, },

View file

@ -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);
}, },
}, },

View file

@ -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

Binary file not shown.

BIN
tests/fixtures/document.pdf vendored Normal file

Binary file not shown.

BIN
tests/fixtures/video.mp4 vendored Normal file

Binary file not shown.

8
vitest.config.ts Normal file
View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
exclude: ["tests/**/*.spec.ts", "node_modules/**"],
},
});