Compare commits

...
27 Commits
Author SHA1 Message Date
devlikepro b0f883c6a8 [core] ChatWoot - no POSTGRES_HOST_AUTH_METHOD
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
2026-01-26 11:06:54 +07:00
devlikepro 4cddb766c2 [core] 2026.1.4 2026-01-26 10:14:19 +07:00
devlikepro 6675492901 [core] Optimize get me 2026-01-26 10:14:19 +07:00
devlikepro a9d030af2a [core] Up NOWEB 2026-01-26 10:11:07 +07:00
devlikepro 2782a188a9 [core] Webhooks - add worker.id
fix #1840
2026-01-26 10:11:07 +07:00
devlikepro 256688497e [core] WEBJS - get 500 error on getChats
fix #1834
2026-01-26 10:11:07 +07:00
devlikepro 4def193c57 [core] WEBJS - inject on script READY too 2026-01-26 10:08:52 +07:00
devlikepro 7f3a2e6aa5 [core] AGENTS.md 2026-01-26 10:08:52 +07:00
devlikepro 60e4f13b67 [core] Fix DeprecationWarning: url.parse() 2026-01-26 10:08:51 +07:00
devlikepro 2d6cfeb0fa [core] ChatWoot - use chat id from incoming message to use either @lid or @c.us (likely @lid in MOST cases now) 2026-01-26 10:08:50 +07:00
devlikepro 03e85434df [core] GOWS subprocess 2026-01-26 10:08:49 +07:00
devlikepro 0ce4c01c48 [core] Optimize get config 2026-01-26 10:08:49 +07:00
devlikepro 0e95890ca4 [core] Optimize get me 2026-01-26 10:08:48 +07:00
devlikepro 072f4b2ab0 [core] Up GOWS 2026-01-26 10:08:48 +07:00
devlikepro 47a91ef669 [core] websocket - use setImmediate 2026-01-26 10:08:47 +07:00
devlikepro 5143bd1ef8 [core] Webhooks - use setImmediate 2026-01-26 10:08:46 +07:00
devlikepro 0efc0751c9 [core] GOWS - send data to subscribers in a setImmediate 2026-01-26 10:08:45 +07:00
devlikepro 51dda739cb [core] ChatWoot - add delay config
WAHA_APPS_JOBS_ATTEMPTS
WAHA_APPS_JOBS_DELAY
WAHA_APPS_JOBS_BACKOFF_TYPE
WAHA_APPS_JOBS_BACKOFF_DELAY

fix #1828
2026-01-26 10:08:45 +07:00
devlikepro 3b28826679 [core] dev 2026-01-26 10:08:45 +07:00
devlikepro 81646bbaa2 [core] Up Dashboard 2026-01-26 10:08:44 +07:00
devlikepro d8d84107fb [core] yarn:test in ci 2026-01-26 10:08:43 +07:00
devlikepro 58d917d374 [core] Api Keys - admin, per sessions
fix #387
fix #937
fix #1642
fix #1772
2026-01-26 10:08:41 +07:00
devlikepro f8f0063975 [core] Openapi - use pairing tag for qr/code/screenshot, move apps below 2026-01-26 10:08:40 +07:00
devlikepro 73faf330e3 [core] AGENTS.md 2026-01-26 10:08:40 +07:00
devlikepro 5a1ebb186d [core] import crypto 2026-01-26 10:08:40 +07:00
devlikepro 54d6d715b0 [core] Add "chrome" to dev build 2026-01-26 10:08:40 +07:00
devlikepro 5d9d6e29e2 [core] Up WEBJS - fix SendSeen not working
fix #1818
2026-01-26 10:08:40 +07:00
72 changed files with 1685 additions and 205 deletions

No files matched your search

+40
View File
@@ -6,8 +6,37 @@ on:
workflow_dispatch:
jobs:
unit-tests:
if: github.repository == 'devlikeapro/waha-plus'
runs-on: ubuntu-22.04
name: Unit tests
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable Corepack
run: |
corepack enable
corepack prepare yarn@4.9.2 --activate
- name: Install dependencies
run: |
unset GIT_CONFIG_PARAMETERS
yarn install --immutable
- name: Run unit tests
run: yarn test:unit
docker-build:
if: github.repository == 'devlikeapro/waha-plus'
needs: unit-tests
runs-on: ${{ matrix.runner }}
name:
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
@@ -15,12 +44,23 @@ jobs:
strategy:
matrix:
include:
# Chromium - x86
- runner: 'buildjet-4vcpu-ubuntu-2204'
tag: 'dev'
platform: 'amd64'
browser: 'chromium'
engine: 'WEBJS'
goss: 'goss-linux-amd64'
# Chrome - x86
- runner: 'buildjet-4vcpu-ubuntu-2204'
tag: 'dev-chrome'
platform: 'amd64'
browser: 'chrome'
engine: 'WEBJS'
goss: 'goss-linux-amd64'
# Chromium - ARM
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
tag: 'dev-arm'
platform: 'linux/arm64'
+6
View File
@@ -3,6 +3,11 @@
This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
(WAHA) codebase when assisting as an automation or coding agent.
- When you asked to refactor or "apply new lib" instead of current one - do not
change the behavior, make a minimum amount of changes possible. If you see
some edge case during that process that haven't been covered - tell it,
suggest fix, but don't change the code.
## Product & Variants
- WAHA ships in **Core** and **Plus** editions. Core lives under `src/core` and
@@ -70,6 +75,7 @@ This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
`src/utils/logging.ts`. `console.log` is blocked by pre-commit.
- Respect path aliases (`@waha/...`) defined in `tsconfig.json`; keep imports
consistent (use absolute aliases, not relative `../../../`).
- Prefer named function declarations over `const` arrow functions when possible.
- Avoid naming unused variables with a leading underscore; if a parameter is
required by a signature, explicitly `void` it instead.
- For configs, prefer runtime configurability over constants. Environment keys
+1 -1
View File
@@ -35,7 +35,7 @@ for-swagger:
WHATSAPP_SWAGGER_CONFIG_ADVANCED=true WHATSAPP_SWAGGER_PASSWORD=666 nvm exec yarn start
up-noweb:
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-12-17
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2026-01-25
up-noweb-libsignal:
yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
@@ -75,7 +75,6 @@ services:
- POSTGRES_USER=postgres
# Please provide your own password.
- POSTGRES_PASSWORD=postgres
- POSTGRES_HOST_AUTH_METHOD=trust
redis:
image: redis:alpine
+51 -21
View File
@@ -16,23 +16,27 @@
"start:prod-exit": "node dist/main",
"lint": "oxlint --deny-warnings",
"lint-fix": "oxlint --fix --deny-warnings",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"test": "yarn test:unit",
"test:watch": "jest --selectProjects unit --watch",
"test:cov": "jest --selectProjects unit --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --selectProjects unit --runInBand",
"test:unit": "jest --selectProjects unit",
"test:e2e": "jest --selectProjects e2e",
"test:e8e": "yarn test:e2e",
"test:all": "yarn test:unit && yarn test:e8e",
"gows:proto:fetch": "node scripts/gows-proto.js fetch",
"gows:proto:build": "node scripts/gows-proto.js build",
"gows:proto": "yarn gows:proto:fetch && yarn gows:proto:build"
},
"dependencies": {
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2025-12-17",
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2026-01-25",
"@adiwajshing/keyed-db": "^0.2.4",
"@aws-sdk/client-s3": "^3.633.0",
"@aws-sdk/s3-request-presigner": "^3.633.0",
"@bull-board/api": "^6.9.1",
"@bull-board/express": "^6.9.1",
"@bull-board/nestjs": "^6.9.1",
"@casl/ability": "^6.8.0",
"@figuro/chatwoot-sdk": "^1.1.17",
"@liaoliaots/nestjs-redis": "^9",
"@nestjs/axios": "^3.0.2",
@@ -148,21 +152,47 @@
"typescript": "5.9.2"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^@waha/(.*)$": "<rootDir>/$1"
},
"testRegex": ".test.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
"projects": [
{
"displayName": "unit",
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^@waha/(.*)$": "<rootDir>/$1"
},
"testRegex": ".test.ts$",
"testPathIgnorePatterns": [
"/__tests__/e2e/"
],
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
{
"displayName": "e2e",
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^@waha/(.*)$": "<rootDir>/$1"
},
"testRegex": "__tests__/e2e/.*\\.test\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
]
},
"packageManager": "yarn@4.9.2"
}
+391
View File
@@ -0,0 +1,391 @@
import axios from 'axios';
import WebSocket = require('ws');
const VALID_API_KEY = '666';
const VALID_USERNAME = 'admin';
const VALID_PASSWORD = '666';
const VALID_SESSION_API_KEY = 'key_0fQfI3n3rFVsczBbBfknLXKUreY2my6J';
const BASE_URL = (process.env.WAHA_BASE_URL ?? 'http://localhost:3000').replace(
/\/$/,
'',
);
const WS_BASE_URL = BASE_URL.replace(/^http/, 'ws');
const HTTP_OK = 200;
const HTTP_CREATED = 201;
const HTTP_UNAUTHORIZED = 401;
const HTTP_FORBIDDEN = 403;
const HTTP_NOT_FOUND = 404;
const HTTP_UNPROCESSABLE = 422;
const WS_POLICY_VIOLATION = 1008;
const WS_OK_CODES = [1000, 1005];
describe('admin - GET /api/sessions/{name}', () => {
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('invalid api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
headers: { 'X-Api-Key': '123' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('admin - POST /api/sessions', () => {
test('admin key can create a session without name and remove it', async () => {
const createResponse = await axios.post(
`${BASE_URL}/api/sessions`,
{},
{
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
},
);
expect(createResponse.status).toBe(HTTP_CREATED);
expect(createResponse.data?.name).toBeTruthy();
const deleteResponse = await axios.delete(
`${BASE_URL}/api/sessions/${createResponse.data.name}`,
{
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
},
);
expect(deleteResponse.status).toBe(HTTP_OK);
});
});
describe('GET /api/sessions?all=true', () => {
beforeAll(async () => {
const createResponse = await axios.post(
`${BASE_URL}/api/sessions`,
{ name: 'another' },
{
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
},
);
if (![HTTP_CREATED, HTTP_UNPROCESSABLE].includes(createResponse.status)) {
throw new Error(
`Unexpected status for creating "another" session: ${createResponse.status}`,
);
}
});
afterAll(async () => {
const deleteResponse = await axios.delete(
`${BASE_URL}/api/sessions/another`,
{
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
},
);
if (![HTTP_OK, HTTP_NOT_FOUND].includes(deleteResponse.status)) {
throw new Error(
`Unexpected status for deleting "another" session: ${deleteResponse.status}`,
);
}
});
test('admin key returns two sessions', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions?all=true`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
expect(response.data).toHaveLength(2);
expect(response.data.map((session) => session.name).sort()).toEqual([
'another',
'default',
]);
});
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions?all=true`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('session key returns one session', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions?all=true`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
expect(response.data).toHaveLength(1);
expect(response.data[0]?.name).toBe('default');
});
});
describe('GET /api/server/version', () => {
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/server/version`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('admin api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/server/version`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('session api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/server/version`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
});
describe('GET /api/server/environment', () => {
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/server/environment`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('admin api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/server/environment`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('session api key is forbidden', async () => {
const response = await axios.get(`${BASE_URL}/api/server/environment`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_FORBIDDEN);
});
});
describe('admin - GET /health', () => {
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/health`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/health`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('invalid api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/health`, {
headers: { 'X-Api-Key': '123' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('/ws', () => {
const buildWsUrl = (apiKey?: string) => {
const wsUrl = new URL('/ws', WS_BASE_URL);
wsUrl.searchParams.set('session', '*');
wsUrl.searchParams.set('events', '*');
if (apiKey) {
wsUrl.searchParams.set('x-api-key', apiKey);
}
return wsUrl.toString();
};
const waitForOpen = (socket: WebSocket, timeoutMs = 5_000) =>
new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('WebSocket open timeout'));
}, timeoutMs);
socket.once('open', () => {
clearTimeout(timeout);
resolve();
});
socket.once('error', (error) => {
clearTimeout(timeout);
reject(error);
});
});
const waitForClose = (socket: WebSocket, timeoutMs = 5_000) =>
new Promise<{ code: number; reason: string }>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('WebSocket close timeout'));
}, timeoutMs);
socket.once('close', (code, reason) => {
clearTimeout(timeout);
resolve({ code, reason: reason.toString() });
});
socket.once('error', (error) => {
clearTimeout(timeout);
reject(error);
});
});
test('no api key is unauthorized', async () => {
const socket = new WebSocket(buildWsUrl());
const { code } = await waitForClose(socket);
expect(code).toBe(WS_POLICY_VIOLATION);
});
test('admin api key is ok', async () => {
const socket = new WebSocket(buildWsUrl(VALID_API_KEY));
await waitForOpen(socket);
socket.close();
const { code } = await waitForClose(socket);
expect(WS_OK_CODES).toContain(code);
});
});
describe('GET /api/files/test.txt', () => {
// create "test.txt" in current dir/.media/test.txt
let file = null;
beforeAll(() => {
const fs = require('fs');
const path = require('path');
const mediaDir = path.resolve('./.media');
if (!fs.existsSync(mediaDir)) {
fs.mkdirSync(mediaDir);
}
const filePath = path.join(mediaDir, 'test.txt');
fs.writeFileSync(filePath, 'This is a test file.');
file = filePath;
});
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/files/test.txt`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/files/test.txt`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('invalid api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/files/test.txt`, {
headers: { 'X-Api-Key': '123' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('GET /', () => {
test('no auth is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid auth is ok', async () => {
const response = await axios.get(`${BASE_URL}/`, {
auth: { username: VALID_USERNAME, password: VALID_PASSWORD },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('wrong auth is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/`, {
auth: { username: VALID_USERNAME, password: 'password-another' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('GET /dashboard', () => {
test('no auth is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/dashboard/`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid auth is ok', async () => {
const response = await axios.get(`${BASE_URL}/dashboard/`, {
auth: { username: VALID_USERNAME, password: VALID_PASSWORD },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('wrong auth is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/dashboard/`, {
auth: { username: VALID_USERNAME, password: 'password-another' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('session key - GET /api/sessions/{name}', () => {
test('default key - default session - is authorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('default key - create a new sessions - forbidden', async () => {
const response = await axios.post(
`${BASE_URL}/api/sessions`,
{ name: 'newone' },
{
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
},
);
expect(response.status).toBe(HTTP_FORBIDDEN);
});
test('another key - default session - unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
headers: { 'X-Api-Key': 'key_another' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('default key - another session - forbidden', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/another`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_FORBIDDEN);
});
});
+139
View File
@@ -0,0 +1,139 @@
import {
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
Post,
Put,
UnprocessableEntityException,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { Action } from '@waha/core/auth/casl.types';
import { CanServer } from '@waha/core/auth/policies';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { ApiKey, CheckInvariant } from '@waha/core/storage/IApiKeyRepository';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { ApiKeyDTO, ApiKeyRequest } from '@waha/structures/apikeys.dto';
import { generatePrefixedId, generateSecret } from '@waha/utils/ids';
@ApiSecurity('api_key')
@Controller('api/keys')
@ApiTags('🔑 Api Keys')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanServer(Action.Manage))
export class ApiKeysController {
constructor(private manager: SessionManager) {}
@Post('/')
@ApiOperation({ summary: 'Create a new API key' })
@UsePipes(new WAHAValidationPipe())
async create(@Body() body: ApiKeyRequest): Promise<ApiKeyDTO> {
CheckInvariant(body);
if (body.session) {
const exists = await this.manager.exists(body.session);
if (!exists) {
throw new UnprocessableEntityException(
`Session "${body.session}" does not exist`,
);
}
}
let apikey: ApiKey | null = null;
// Try 5 times to check there's no conflict on id and key
for (let i = 0; i < 5; i++) {
apikey = {
id: generatePrefixedId('key_id'),
key: `key_${generateSecret(32)}`,
isActive: body.isActive,
isAdmin: body.isAdmin,
session: body.session,
rules: null,
};
const idExists = await this.manager.apiKeyRepository.getById(apikey.id);
if (idExists) {
continue;
}
const keyExists = await this.manager.apiKeyRepository.getByKey(
apikey.key,
);
if (keyExists) {
continue;
}
break;
}
if (!apikey) {
throw new UnprocessableEntityException(
`Failed to generate API key, try again`,
);
}
CheckInvariant(apikey);
await this.manager.apiKeyRepository.upsert(apikey);
return ApiKeyToDTO(apikey);
}
@Get('/')
@ApiOperation({ summary: 'Get all API keys' })
async list(): Promise<ApiKeyDTO[]> {
const keys = await this.manager.apiKeyRepository.list();
return keys.map((key) => ApiKeyToDTO(key));
}
@Put('/:id')
@ApiOperation({ summary: 'Update an API key' })
@UsePipes(new WAHAValidationPipe())
async update(
@Param('id') id: string,
@Body() body: ApiKeyRequest,
): Promise<ApiKeyDTO> {
const existing = await this.manager.apiKeyRepository.getById(id);
if (!existing) {
throw new NotFoundException('API key not found');
}
const apikey: ApiKey = {
...existing,
isActive: body.isActive,
isAdmin: body.isAdmin,
session: body.session,
};
CheckInvariant(apikey);
if (apikey.session) {
const exists = await this.manager.exists(apikey.session);
if (!exists) {
throw new UnprocessableEntityException(
`Session "${apikey.session}" does not exist`,
);
}
}
CheckInvariant(apikey);
await this.manager.apiKeyRepository.upsert(apikey);
return ApiKeyToDTO(apikey);
}
@Delete('/:id')
@ApiOperation({ summary: 'Delete an API key' })
async delete(@Param('id') id: string): Promise<{ result: true }> {
const existing = await this.manager.apiKeyRepository.getById(id);
if (!existing) {
throw new NotFoundException('API key not found');
}
await this.manager.apiKeyRepository.deleteById(id);
return { result: true };
}
}
function ApiKeyToDTO(apikey: ApiKey): ApiKeyDTO {
return {
id: apikey.id,
key: apikey.key,
isActive: apikey.isActive,
isAdmin: apikey.isAdmin,
session: apikey.session,
};
}
+9 -1
View File
@@ -5,6 +5,7 @@ import {
Post,
Query,
UseInterceptors,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
@@ -24,10 +25,17 @@ import {
RequestCodeRequest,
} from '../structures/auth.dto';
import { Base64File } from '../structures/files.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/auth')
@ApiTags('🔑 Auth')
@ApiTags('📱 Pairing')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class AuthController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -2,6 +2,7 @@ import {
Body,
Controller,
Post,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -14,10 +15,17 @@ import {
import { SessionManager } from '../core/abc/manager.abc';
import { WhatsappSession } from '../core/abc/session.abc';
import { RejectCallRequest } from '../structures/calls.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/calls')
@ApiTags('📞 Calls')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class CallsController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -8,6 +8,7 @@ import {
Param,
Post,
Query,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -39,10 +40,17 @@ import {
parseChannelInviteLink,
WhatsappSession,
} from '../core/abc/session.abc';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/channels')
@ApiTags('📢 Channels')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class ChannelsController {
constructor(
private manager: SessionManager,
+8
View File
@@ -8,6 +8,7 @@ import {
Post,
Put,
Query,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -40,11 +41,18 @@ import {
} from '../structures/chats.dto';
import { EditMessageRequest } from '../structures/chatting.dto';
import { SortOrder } from '@waha/structures/pagination.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/chats')
@ApiTags('💬 Chats')
@UsePipes(new ValidationPipe({ transform: true }))
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class ChatsController {
constructor(private manager: SessionManager) {}
+31
View File
@@ -5,6 +5,7 @@ import {
Post,
Put,
Query,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -48,15 +49,22 @@ import {
mentionsAll,
validateRequestMentions,
} from '@waha/core/utils/mentions.all';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromBody, FromQuery } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api')
@ApiTags('📤 Chatting')
@UseGuards(PoliciesGuard)
export class ChattingController {
constructor(private manager: SessionManager) {}
@Post('/sendText')
@ApiOperation({ summary: 'Send a text message' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendText(@Body() request: MessageTextRequest): Promise<WAMessage> {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -72,6 +80,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendImage(@Body() request: MessageImageRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -87,6 +96,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendFile(@Body() request: MessageFileRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -102,6 +112,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendVoice(@Body() request: MessageVoiceRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendVoice(request);
@@ -113,6 +124,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendVideo(@Body() request: MessageVideoRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -128,6 +140,7 @@ export class ChattingController {
description:
'You can use regular /api/sendText if you wanna send auto-generated link preview.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendLinkCustomPreview(
@Body() request: MessageLinkCustomPreviewRequest,
@@ -147,6 +160,7 @@ export class ChattingController {
description: 'Send Buttons',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendButtons(@Body() request: SendButtonsRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -158,6 +172,7 @@ export class ChattingController {
summary: 'Send a list message (interactive)',
description: 'Send a List message with sections and rows',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendList(@Body() request: SendListRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -165,6 +180,7 @@ export class ChattingController {
}
@Post('/forwardMessage')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async forwardMessage(
@Body() request: MessageForwardRequest,
): Promise<WAMessage> {
@@ -173,6 +189,7 @@ export class ChattingController {
}
@Post('/sendSeen')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendSeen(@Body() chat: SendSeenRequest) {
const hasMessageId = chat.messageIds?.length > 0 || Boolean(chat.messageId);
if (!hasMessageId) {
@@ -188,6 +205,7 @@ export class ChattingController {
}
@Post('/startTyping')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async startTyping(@Body() chat: ChatRequest) {
// It's infinitive action
const whatsapp = await this.manager.getWorkingSession(chat.session);
@@ -196,6 +214,7 @@ export class ChattingController {
}
@Post('/stopTyping')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async stopTyping(@Body() chat: ChatRequest) {
const whatsapp = await this.manager.getWorkingSession(chat.session);
await whatsapp.stopTyping(chat);
@@ -204,6 +223,7 @@ export class ChattingController {
@Put('/reaction')
@ApiOperation({ summary: 'React to a message with an emoji' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async setReaction(@Body() request: MessageReactionRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.setReaction(request);
@@ -211,6 +231,7 @@ export class ChattingController {
@Put('/star')
@ApiOperation({ summary: 'Star or unstar a message' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async setStar(@Body() request: MessageStarRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
await whatsapp.setStar(request);
@@ -222,6 +243,7 @@ export class ChattingController {
summary: 'Send a poll with options',
description: 'You can use it as buttons or list replacement',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendPoll(@Body() request: MessagePollRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendPoll(request);
@@ -232,6 +254,7 @@ export class ChattingController {
summary: 'Vote on a poll',
description: 'Cast vote(s) on an existing poll message',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendPollVote(@Body() request: MessagePollVoteRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -239,12 +262,14 @@ export class ChattingController {
}
@Post('/sendLocation')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendLocation(@Body() request: MessageLocationRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendLocation(request);
}
@Post('/sendContactVcard')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendContactVcard(@Body() request: MessageContactVcardRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendContactVCard(request);
@@ -254,6 +279,7 @@ export class ChattingController {
@ApiOperation({
summary: 'Reply on a button message',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async sendButtonsReply(@Body() request: MessageButtonReply) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -262,6 +288,7 @@ export class ChattingController {
@Get('/sendText')
@ApiOperation({ summary: 'Send a text message', deprecated: true })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async sendTextGet(@Query() query: MessageTextQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
const msg = new MessageTextRequest();
@@ -276,6 +303,7 @@ export class ChattingController {
description: 'DEPRECATED. Use "GET /api/chats/{id}/messages" instead',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getMessages(
@Query() query: GetMessageQuery,
@@ -292,6 +320,7 @@ export class ChattingController {
description: 'DEPRECATED. Use "POST /contacts/check-exists" instead',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async DEPRECATED_checkNumberStatus(
@Query() request: CheckNumberStatusQuery,
): Promise<WANumberExistResult> {
@@ -305,6 +334,7 @@ export class ChattingController {
'DEPRECATED - you can set "reply_to" field when sending text, image, etc',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async reply(@Body() request: MessageReplyRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.reply(request);
@@ -312,6 +342,7 @@ export class ChattingController {
@Post('/sendLinkPreview')
@ApiOperation({ deprecated: true })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendLinkPreview_DEPRECATED(@Body() request: MessageLinkPreviewRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendLinkPreview(request);
+14
View File
@@ -4,6 +4,7 @@ import {
Get,
Post,
Query,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -21,15 +22,22 @@ import {
ContactRequest,
ContactsPaginationParams,
} from '../structures/contacts.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromBody, FromQuery } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/contacts')
@ApiTags('👤 Contacts')
@UseGuards(PoliciesGuard)
export class ContactsController {
constructor(private manager: SessionManager) {}
@Get('/all')
@ApiOperation({ summary: 'Get all contacts' })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getAll(
@Query() query: SessionQuery,
@@ -45,6 +53,7 @@ export class ContactsController {
description:
'The method always return result, even if the phone number is not registered in WhatsApp. For that - use /contacts/check-exists endpoint below.',
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async get(@Query() query: ContactQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
return whatsapp.getContact(query);
@@ -52,6 +61,7 @@ export class ContactsController {
@Get('/check-exists')
@ApiOperation({ summary: 'Check phone number is registered in WhatsApp.' })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async checkExists(
@Query() request: CheckNumberStatusQuery,
): Promise<WANumberExistResult> {
@@ -65,6 +75,7 @@ export class ContactsController {
description:
'Returns null if you do not have permission to read their status.',
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async getAbout(@Query() query: ContactQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
return whatsapp.getContactAbout(query);
@@ -76,6 +87,7 @@ export class ContactsController {
description:
'If privacy settings do not allow to get the picture, the method will return null.',
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getProfilePicture(@Query() query: ContactProfilePictureQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
@@ -88,6 +100,7 @@ export class ContactsController {
@Post('/block')
@ApiOperation({ summary: 'Block contact' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async block(@Body() request: ContactRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.blockContact(request);
@@ -95,6 +108,7 @@ export class ContactsController {
@Post('/unblock')
@ApiOperation({ summary: 'Unblock contact' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async unblock(@Body() request: ContactRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.unblockContact(request);
@@ -1,5 +1,15 @@
import { Body, Controller, Delete, Param, Put, UsePipes } from '@nestjs/common';
import {
Body,
Controller,
Param,
Put,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { ChatIdApiParam } from '@waha/nestjs/params/ChatIdApiParam';
import {
@@ -8,13 +18,16 @@ import {
} from '@waha/nestjs/params/SessionApiParam';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { SessionManager } from './core/abc/manager.abc';
import { Result } from './structures/base.dto';
import { ContactUpdateBody } from './structures/contacts.dto';
import { SessionManager } from '../core/abc/manager.abc';
import { Result } from '../structures/base.dto';
import { ContactUpdateBody } from '../structures/contacts.dto';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/contacts')
@ApiTags('👤 Contacts')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class ContactsSessionController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -3,6 +3,7 @@ import {
Controller,
Param,
Post,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -19,10 +20,17 @@ import {
EventMessageRequest,
} from '../structures/events.dto';
import { WAMessage } from '../structures/responses.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/events')
@ApiTags('📅 Events')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class EventsController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -9,6 +9,7 @@ import {
Post,
Put,
Query,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -40,10 +41,17 @@ import {
SettingsSecurityChangeInfo,
SubjectRequest,
} from '../structures/groups.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/groups')
@ApiTags('👥 Groups')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class GroupsController {
constructor(private manager: SessionManager) {}
+8 -1
View File
@@ -1,12 +1,19 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { HealthCheck } from '@nestjs/terminus';
import { WAHAHealthCheckService } from '../core/abc/WAHAHealthCheckService';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanServer } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('health')
@ApiTags('🔍 Observability')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanServer(Action.Manage))
export class HealthController {
constructor(private wahaHealth: WAHAHealthCheckService) {}
+8
View File
@@ -8,6 +8,7 @@ import {
Post,
Put,
UnprocessableEntityException,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -26,10 +27,17 @@ import {
import * as lodash from 'lodash';
import { SessionManager } from '../core/abc/manager.abc';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/labels')
@ApiTags('🏷️ Labels')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class LabelsController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -4,6 +4,7 @@ import {
Param,
Query,
UnprocessableEntityException,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -22,10 +23,17 @@ import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
import { SessionManager } from '../core/abc/manager.abc';
import { isLidUser } from '@waha/core/utils/jids';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/lids')
@ApiTags('👤 Contacts')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class LidsController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -3,6 +3,7 @@ import {
Controller,
Post,
UnprocessableEntityException,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -21,10 +22,17 @@ import {
import { WhatsappSession } from '../core/abc/session.abc';
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/media')
@ApiTags('🖼️ Media')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class MediaController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -5,6 +5,7 @@ import {
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ChatIdApiParam } from '@waha/nestjs/params/ChatIdApiParam';
@@ -20,10 +21,17 @@ import {
WAHAChatPresences,
WAHASessionPresence,
} from '../structures/presence.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/presence')
@ApiTags('✅ Presence')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class PresenceController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -5,6 +5,7 @@ import {
Get,
Put,
UnprocessableEntityException,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -22,10 +23,17 @@ import {
ProfilePictureRequest,
ProfileStatusRequest,
} from '@waha/structures/profile.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/profile')
@ApiTags('🆔 Profile')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class ProfileController {
constructor(private manager: SessionManager) {}
+14 -2
View File
@@ -4,23 +4,35 @@ import {
Query,
Res,
StreamableFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
import { Response } from 'express';
import { SessionManager } from '../core/abc/manager.abc';
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
import { SessionQuery } from '../structures/base.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromQuery } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api')
@ApiTags('🖼️ Screenshot')
@ApiTags('📱 Pairing')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
export class ScreenshotController {
constructor(private manager: SessionManager) {}
@Get('/screenshot')
@ApiOperation({
summary:
'Get a screenshot of the current WhatsApp session (**WEBJS** only)',
})
@UseInterceptors(new BufferResponseInterceptor('image/jpeg'))
@ApiFileAcceptHeader('image/jpeg')
async screenshot(
+11
View File
@@ -7,6 +7,7 @@ import {
Logger,
Post,
Query,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -22,10 +23,16 @@ import {
import { sleep } from '@waha/utils/promiseTimeout';
import { VERSION } from '@waha/version';
import * as lodash from 'lodash';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanServer } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/server')
@ApiTags('🔍 Observability')
@UseGuards(PoliciesGuard)
export class ServerController {
private logger: Logger;
@@ -35,12 +42,14 @@ export class ServerController {
@Get('version')
@ApiOperation({ summary: 'Get the version of the server' })
@CheckPolicies(CanServer(Action.Read))
get(): WAHAEnvironment {
return VERSION;
}
@Get('environment')
@ApiOperation({ summary: 'Get the server environment' })
@CheckPolicies(CanServer(Action.Manage))
environment(
@Query(new WAHAValidationPipe()) query: EnvironmentQuery,
// eslint-disable-next-line @typescript-eslint/ban-types
@@ -67,6 +76,7 @@ export class ServerController {
@Get('status')
@ApiOperation({ summary: 'Get the server status' })
@CheckPolicies(CanServer(Action.Read))
async status(): Promise<ServerStatusResponse> {
const now = Date.now();
const uptime = Math.floor(process.uptime() * 1000);
@@ -87,6 +97,7 @@ export class ServerController {
"If you're using docker, after calling this endpoint Docker will start a new container, " +
'so you can use this endpoint to restart the server',
})
@CheckPolicies(CanServer(Action.Manage))
@UsePipes(new WAHAValidationPipe())
async stop(@Body() request: StopRequest): Promise<StopResponse> {
const timeout = 1_000;
+8
View File
@@ -9,6 +9,7 @@ import {
NotFoundException,
Query,
StreamableFile,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -25,10 +26,17 @@ import {
CpuProfileQuery,
} from '@waha/structures/server.debug.dto';
import { createReadStream } from 'fs';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanServer } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/server/debug')
@ApiTags('🔍 Observability')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanServer(Action.Manage))
export class ServerDebugController {
private logger: Logger;
private readonly enabled: boolean;
+39 -5
View File
@@ -9,10 +9,18 @@ import {
Post,
Put,
Query,
Req,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { UnprocessableEntityException } from '@nestjs/common/exceptions/unprocessable-entity.exception';
import { ApiBody, ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import {
ApiBody,
ApiOAuth2,
ApiOperation,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import {
SessionApiParam,
SessionParam,
@@ -34,18 +42,24 @@ import { WhatsappSession } from '../core/abc/session.abc';
import {
ListSessionsQuery,
MeInfo,
SessionExpand,
SessionInfoQuery,
SessionCreateRequest,
SessionDTO,
SessionExpand,
SessionInfo,
SessionInfoQuery,
SessionUpdateRequest,
} from '../structures/sessions.dto';
import { SessionExamples } from './sessions.examples';
import { FilterSessions } from '../core/auth/casl.ability';
import { CheckPolicies } from '../core/auth/policies.decorator';
import { PoliciesGuard } from '../core/auth/policies.guard';
import { CanSession, FromBody, FromParam } from '../core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/sessions')
@ApiTags('🖥️ Sessions')
@UseGuards(PoliciesGuard)
class SessionsController {
constructor(
private manager: SessionManager,
@@ -57,11 +71,19 @@ class SessionsController {
}
@Get('/')
@ApiOperation({ summary: 'List all sessions' })
@ApiOperation({
summary: 'List all sessions',
})
@CheckPolicies(CanSession(Action.List))
@ApiOAuth2(['read:items'])
async list(
@Query(new WAHAValidationPipe()) query: ListSessionsQuery,
@Req() req,
): Promise<SessionInfo[]> {
const sessions = await this.manager.getSessions(query.all);
let sessions = await this.manager.getSessions(query.all);
if (!req.user.isAdmin) {
sessions = FilterSessions(req.ability, Action.Read, sessions);
}
if (query.expand?.includes(SessionExpand.apps)) {
for (const session of sessions) {
session.apps = await this.appsService.list(this.manager, session.name);
@@ -73,6 +95,7 @@ class SessionsController {
@Get('/:session')
@ApiOperation({ summary: 'Get session information' })
@SessionApiParam
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async get(
@Param('session') name: string,
@@ -91,6 +114,7 @@ class SessionsController {
@Get(':session/me')
@SessionApiParam
@ApiOperation({ summary: 'Get information about the authenticated account' })
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
getMe(@SessionParam session: WhatsappSession): MeInfo | null {
return session.getSessionMeInfo();
}
@@ -102,6 +126,7 @@ class SessionsController {
'Create session a new session (and start it at the same time if required).',
})
@ApiBody({ type: SessionCreateRequest, examples: SessionExamples })
@CheckPolicies(CanSession(Action.Create))
@UsePipes(new WAHAValidationPipe())
async create(@Body() request: SessionCreateRequest): Promise<SessionDTO> {
const name = request.name || generatePrefixedId('session');
@@ -139,6 +164,7 @@ class SessionsController {
})
@SessionApiParam
@ApiBody({ type: SessionUpdateRequest, examples: SessionExamples })
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe({ forbidNonWhitelisted: false }))
async update(
@Param('session') name: string,
@@ -177,6 +203,7 @@ class SessionsController {
description:
'Delete the session with the given name. Stop and logout as well. Idempotent operation.',
})
@CheckPolicies(CanSession(Action.Delete, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async delete(@Param('session') name: string): Promise<void> {
await this.withLock(name, async () => {
@@ -195,6 +222,7 @@ class SessionsController {
description:
'Start the session with the given name. The session must exist. Idempotent operation.',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async start(@Param('session') name: string): Promise<SessionDTO> {
await this.withLock(name, async () => {
@@ -214,6 +242,7 @@ class SessionsController {
summary: 'Stop the session',
description: 'Stop the session with the given name. Idempotent operation.',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async stop(@Param('session') name: string): Promise<SessionDTO> {
await this.withLock(name, async () => {
@@ -229,6 +258,7 @@ class SessionsController {
summary: 'Logout from the session',
description: 'Logout the session, restart a session if it was not STOPPED',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async logout(@Param('session') name: string): Promise<SessionDTO> {
await this.withLock(name, async () => {
@@ -253,6 +283,7 @@ class SessionsController {
summary: 'Restart the session',
description: 'Restart the session with the given name.',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async restart(@Param('session') name: string): Promise<SessionDTO> {
await this.manager.restart(name);
@@ -266,6 +297,7 @@ class SessionsController {
'Create session (if not exists) or update a config (if exists) and start it.',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
async DEPRACATED_start(
@Body() request: SessionStartDeprecatedRequest,
): Promise<SessionDTO> {
@@ -292,6 +324,7 @@ class SessionsController {
description: 'Stop session and Logout by default.',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
async DEPRECATED_stop(
@Body() request: SessionStopDeprecatedRequest,
): Promise<void> {
@@ -323,6 +356,7 @@ class SessionsController {
description: 'Stop, Logout and Delete session.',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
async DEPRECATED_logout(
@Body() request: SessionLogoutDeprecatedRequest,
): Promise<void> {
+8 -1
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import {
SessionApiParam,
@@ -15,10 +15,17 @@ import {
VideoStatus,
VoiceStatus,
} from '../structures/status.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/status')
@ApiTags('🟢 Status')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class StatusController {
constructor(private manager: SessionManager) {}
+9 -7
View File
@@ -1,17 +1,19 @@
import { Controller, Get } from '@nestjs/common';
import {
ApiExtraModels,
ApiOperation,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { WAHAEnvironment } from '../structures/environment.dto';
import { VERSION } from '../version';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanServer } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/version')
@ApiTags('🔍 Observability')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanServer(Action.Read))
export class VersionController {
@Get('')
@ApiOperation({
+41 -19
View File
@@ -19,8 +19,10 @@ import { WAHAEvents, WAHAEventsWild } from '@waha/structures/enums.dto';
import { EventWildUnmask } from '@waha/utils/events';
import { generatePrefixedId } from '@waha/utils/ids';
import { IncomingMessage } from 'http';
import * as url from 'url';
import { URL } from 'url';
import { Server } from 'ws';
import { CaslAbilityFactory } from '@waha/core/auth/casl.ability';
import { Action, session as SessionName } from '@waha/core/auth/casl.types';
export enum WebSocketCloseCode {
NORMAL = 1000,
@@ -54,6 +56,7 @@ export class WebsocketGatewayCore
constructor(
private manager: SessionManager,
private auth: WebSocketAuth,
private readonly casl: CaslAbilityFactory,
) {
this.logger = new Logger('WebsocketGateway');
this.heartbeat = new WebsocketHeartbeatJob(
@@ -62,11 +65,16 @@ export class WebsocketGatewayCore
);
}
handleConnection(socket: WebSocket, request: IncomingMessage, ...args): any {
async handleConnection(
socket: WebSocket,
request: IncomingMessage,
...args
): Promise<any> {
// wsc - websocket client
socket.id = generatePrefixedId('wsc');
if (!this.auth.validateRequest(request)) {
const user = await this.auth.validateRequest(request);
if (!user) {
// Not authorized - close connection
socket.close(WebSocketCloseCode.POLICY_VIOLATION, 'Unauthorized');
this.logger.debug(
@@ -75,9 +83,19 @@ export class WebsocketGatewayCore
return;
}
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
const params = this.getParams(request);
const session: string = params.session;
let session: string = params.session;
const ability = this.casl.createForUser(user);
if (session == '*' && !ability.can(Action.Use, 'all')) {
// Limit user to listen only the session events
session = user.session;
}
if (!ability.can(Action.Use, new SessionName(session))) {
socket.close(WebSocketCloseCode.POLICY_VIOLATION, 'Forbidden');
}
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
const events: WAHAEvents[] = params.events;
this.logger.debug(
`Client connected to session: '${session}', events: ${events}, ${socket.id}`,
@@ -86,12 +104,17 @@ export class WebsocketGatewayCore
const sub = this.manager
.getSessionEvents(session, events)
.subscribe((data) => {
this.logger.debug(`Sending data to client, event.id: ${data.id}`, data);
socket.send(JSON.stringify(data), (err) => {
if (!err) {
return;
}
this.logger.error(`Error sending data to client: ${err}`);
setImmediate(() => {
this.logger.debug(
`Sending data to client, event.id: ${data.id}`,
data,
);
socket.send(JSON.stringify(data), (err) => {
if (!err) {
return;
}
this.logger.error(`Error sending data to client: ${err}`);
});
});
});
socket.on('close', () => {
@@ -101,14 +124,13 @@ export class WebsocketGatewayCore
}
private getParams(request: IncomingMessage) {
const query = url.parse(request.url, true).query;
const session = (query.session as string) || '*';
let paramsEvents = (query.events as string[]) || '*';
// if params events string - split by ","
if (typeof paramsEvents === 'string') {
paramsEvents = paramsEvents.split(',');
}
const events = this.eventUnmask.unmask(paramsEvents);
// We need only search params, so localhost is fine here
const query = new URL(request.url, 'http://localhost').searchParams;
const session = query.get('session') || '*';
const paramsEvents = query.getAll('events');
const eventsRaw = paramsEvents.length > 0 ? paramsEvents : ['*'];
const eventsList = eventsRaw.flatMap((value) => value.split(','));
const events = this.eventUnmask.unmask(eventsList);
return { session, events };
}
+43 -4
View File
@@ -2,6 +2,7 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Inject,
NotFoundException,
@@ -9,6 +10,8 @@ import {
Post,
Put,
Query,
Req,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -17,6 +20,10 @@ import {
IAppsService,
} from '@waha/apps/app_sdk/services/IAppsService';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CanSession, FromBody, FromQuery } from '@waha/core/auth/policies';
import { Action, session as SessionName } from '@waha/core/auth/casl.types';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { App } from '../dto/app.dto';
@@ -25,6 +32,7 @@ import { ListAppsQuery } from '../dto/query.dto';
@ApiSecurity('api_key')
@Controller('api/apps')
@ApiTags('🧩 Apps')
@UseGuards(PoliciesGuard)
export class AppsController {
constructor(
@Inject(AppsService)
@@ -34,6 +42,7 @@ export class AppsController {
@Get('/')
@ApiOperation({ summary: 'List all apps for a session' })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@UsePipes(new WAHAValidationPipe())
async list(
@Query(new WAHAValidationPipe()) query: ListAppsQuery,
@@ -43,6 +52,7 @@ export class AppsController {
@Post('/')
@ApiOperation({ summary: 'Create a new app' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async create(@Body() app: App): Promise<App> {
const result = await this.appsService.create(this.manager, app);
@@ -56,14 +66,36 @@ export class AppsController {
@Get('/:id')
@ApiOperation({ summary: 'Get app by ID' })
@UsePipes(new WAHAValidationPipe())
async get(@Param('id') id: string): Promise<App> {
return await this.appsService.get(this.manager, id);
async get(@Param('id') id: string, @Req() req: any): Promise<App> {
const app = await this.appsService.get(this.manager, id);
if (!app) {
throw new NotFoundException(`App '${id}' not found`);
}
if (!req.ability?.can(Action.Use, new SessionName(app.session))) {
throw new ForbiddenException();
}
return app;
}
@Put('/:id')
@ApiOperation({ summary: 'Update an existing app' })
@UsePipes(new WAHAValidationPipe())
async update(@Param('id') id: string, @Body() app: App): Promise<App> {
async update(
@Param('id') id: string,
@Body() app: App,
@Req() req: any,
): Promise<App> {
const existing = await this.appsService.get(this.manager, id);
if (existing) {
if (!req.ability?.can(Action.Use, new SessionName(existing.session))) {
throw new ForbiddenException();
}
} else {
if (!req.ability?.can(Action.Use, new SessionName(app.session))) {
throw new ForbiddenException();
}
}
if (!app.id) {
app.id = id;
} else if (app.id !== id) {
@@ -83,7 +115,14 @@ export class AppsController {
@Delete('/:id')
@ApiOperation({ summary: 'Delete an app' })
@UsePipes(new WAHAValidationPipe())
async delete(@Param('id') id: string): Promise<void> {
async delete(@Param('id') id: string, @Req() req: any): Promise<void> {
const existing = await this.appsService.get(this.manager, id);
if (!existing) {
throw new NotFoundException(`App '${id}' not found`);
}
if (!req.ability?.can(Action.Use, new SessionName(existing.session))) {
throw new ForbiddenException();
}
const app = await this.appsService.delete(this.manager, id);
const isRunning = this.manager.isRunning(app.session);
if (isRunning) {
+12 -7
View File
@@ -25,13 +25,18 @@ function jobRemoveOptions() {
export const JobRemoveOptions = jobRemoveOptions();
export const ExponentialRetriesJobOptions: DefaultJobOptions = {
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
};
function exponentialRetriesJobOptions(): DefaultJobOptions {
return {
attempts: parseInt(process.env.WAHA_APPS_JOBS_ATTEMPTS) || 3,
delay: parseInt(process.env.WAHA_APPS_JOBS_DELAY) || 0,
backoff: {
type: process.env.WAHA_APPS_JOBS_BACKOFF_TYPE || 'exponential',
delay: parseInt(process.env.WAHA_APPS_JOBS_BACKOFF_DELAY) || 1000,
},
};
}
export const ExponentialRetriesJobOptions = exponentialRetriesJobOptions();
export const NoRetriesJobOptions: DefaultJobOptions = {
attempts: 1,
@@ -21,7 +21,7 @@ export class AppsDisabledService implements IAppsService {
throw new AppsIsDisabledError();
}
async get(manager: SessionManager, appId: string): Promise<App> {
async get(manager: SessionManager, appId: string): Promise<App | null> {
throw new AppsIsDisabledError();
}
@@ -104,12 +104,12 @@ export class AppsEnabledService implements IAppsService {
return result;
}
async get(manager: SessionManager, appId: string): Promise<App> {
async get(manager: SessionManager, appId: string): Promise<App | null> {
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
const app = await repo.getById(appId);
if (!app) {
throw new NotFoundException(`App '${appId}' not found`);
return null;
}
delete (app as any).pk;
return app;
+1 -1
View File
@@ -7,7 +7,7 @@ import { Knex } from 'knex';
export interface IAppsService {
list(manager: SessionManager, session: string): Promise<App[]>;
get(manager: SessionManager, appId: string): Promise<App>;
get(manager: SessionManager, appId: string): Promise<App | null>;
create(manager: SessionManager, app: App): Promise<App>;
@@ -19,6 +19,7 @@ import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { CacheForConfig } from '../cache/ConversationCache';
import { IConversationCache } from '../cache/IConversationCache';
import { AttributeKey } from '@waha/apps/chatwoot/const';
export interface ContactInfo {
ChatId(): string;
@@ -63,6 +64,8 @@ export class ContactConversationService {
`Updating if required contact custom attributes for chat.id: ${chatId}, contact.id: ${cwContact.data.id}`,
);
const attributes = await contactInfo.Attributes();
// Keep the current chat id in sync to reply using the latest address.
attributes[AttributeKey.WA_CHAT_ID] = chatId;
await this.contactService.upsertCustomAttributes(
cwContact.data,
attributes,
+3 -3
View File
@@ -27,15 +27,15 @@ export function GetAllChatIDs(contact: any): Array<string> {
}
export function FindChatID(contact: any): string | null {
if (GetChatID(contact)) {
return GetChatID(contact);
}
if (GetJID(contact)) {
return GetJID(contact);
}
if (GetLID(contact)) {
return GetLID(contact);
}
if (GetChatID(contact)) {
return GetChatID(contact);
}
return null;
}
+5 -5
View File
@@ -74,15 +74,14 @@ export class SwaggerConfiguratorCore {
.setExternalDoc(this.title, this.externalDocUrl)
.setVersion(VERSION.version)
.addTag('🖥️ Sessions', 'Control WhatsApp sessions (accounts)')
.addTag('🧩 Apps', 'Applications (built-in integrations)')
.addTag('🔑 Auth', 'Authentication')
.addTag('📱 Pairing', 'Pair a session with WhatsApp on your phone.')
.addTag('🆔 Profile', 'Your profile information')
.addTag('🖼️ Screenshot', 'Get screenshot of WhatsApp and show QR code')
.addTag('📤 Chatting', 'Chatting methods')
.addTag('📞 Calls', 'Call handling methods')
.addTag('✅ Presence', `Presence information`)
.addTag('📢 Channels', 'Channels (newsletters) methods')
.addTag('🟢 Status', 'Status (aka stories) methods')
.addTag('💬 Chats', `Chats methods`)
.addTag('🔑 Api Keys', 'API Keys management')
.addTag(
'👤 Contacts',
`Contacts methods.<br>
@@ -90,13 +89,14 @@ export class SwaggerConfiguratorCore {
'E.g: \`12312312310\` OR \`12312312310@c.us\`<br>`,
)
.addTag('👥 Groups', `Groups methods.<br>`)
.addTag('✅ Presence', `Presence information`)
.addTag('📞 Calls', 'Call handling methods')
.addTag('📅 Events', `Event Message`)
.addTag(
'🏷️ Labels',
'Labels - available only for WhatsApp Business accounts',
)
.addTag('🖼️ Media', 'Media methods')
.addTag('🧩 Apps', 'Applications (built-in integrations)')
.addTag('🔍 Observability', 'Other methods')
.addTag('🗄️ Storage', 'Storage methods')
.addApiKey({
+2
View File
@@ -36,6 +36,7 @@ import {
import { ISessionAuthRepository } from '../storage/ISessionAuthRepository';
import { ISessionConfigRepository } from '../storage/ISessionConfigRepository';
import { WhatsappSession } from './session.abc';
import { IApiKeyRepository } from '@waha/core/storage/IApiKeyRepository';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const AsyncLock = require('async-lock');
@@ -48,6 +49,7 @@ export abstract class SessionManager
public sessionConfigRepository: ISessionConfigRepository;
protected sessionMeRepository: ISessionMeRepository;
protected sessionWorkerRepository: ISessionWorkerRepository;
public apiKeyRepository: IApiKeyRepository;
private lock: any;
WAIT_SESSION_RUNNING_INTERVAL = 500;
+16 -4
View File
@@ -9,15 +9,16 @@ import { ServeStaticModule } from '@nestjs/serve-static';
import { TerminusModule } from '@nestjs/terminus';
import { ChannelsController } from '@waha/api/channels.controller';
import { LidsController } from '@waha/api/lids.controller';
import { ApiKeysController } from '@waha/api/apikeys.controller';
import { ProfileController } from '@waha/api/profile.controller';
import { ServerController } from '@waha/api/server.controller';
import { ServerDebugController } from '@waha/api/server.debug.controller';
import { WebsocketGatewayCore } from '@waha/api/websocket.gateway.core';
import { AppsModuleExports } from '@waha/apps/apps.module';
import { ContactsSessionController } from '@waha/contacts.session.controller';
import { ContactsSessionController } from '@waha/api/contacts.session.controller';
import { ApiKeyStrategy } from '@waha/core/auth/apiKey.strategy';
import { IApiKeyAuth } from '@waha/core/auth/auth';
import { AuthMiddleware } from '@waha/core/auth/auth.middleware';
import { ApiKeyAuthMiddleware } from '@waha/core/auth/api-key-auth.middleware';
import { BasicAuthFunction } from '@waha/core/auth/basicAuth';
import { WebSocketAuth } from '@waha/core/auth/WebSocketAuth';
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
@@ -64,6 +65,9 @@ import { EngineConfigService } from './config/EngineConfigService';
import { SwaggerConfigServiceCore } from './config/SwaggerConfigServiceCore';
import { WAHAHealthCheckServiceCore } from './health/WAHAHealthCheckServiceCore';
import { SessionManagerCore } from './manager.core';
import { CaslAbilityFactory } from '@waha/core/auth/casl.ability';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { ApiKeyService } from '@waha/core/auth/ApiKeyService';
export const IMPORTS_CORE = [
...AppsModuleExports.imports,
@@ -140,6 +144,7 @@ const IMPORTS = [...IMPORTS_CORE, ...IMPORTS_MEDIA];
export const CONTROLLERS = [
AuthController,
ApiKeysController,
SessionsController,
ProfileController,
ChattingController,
@@ -178,6 +183,9 @@ export const PROVIDERS_BASE: Provider[] = [
MediaLocalStorageConfig,
WebSocketAuth,
ApiKeyStrategy,
ApiKeyService,
CaslAbilityFactory,
PoliciesGuard,
{
provide: IApiKeyAuth,
useFactory: ApiKeyAuthFactory,
@@ -234,11 +242,15 @@ export class AppModuleCore {
}
configure(consumer: MiddlewareConsumer) {
// Because we use ServeStaticModule, we need to inject a middleware
// ServeStaticModule does not support @UseGuards
const exclude = this.config.getExcludedPaths();
consumer
.apply(AuthMiddleware)
.apply(ApiKeyAuthMiddleware)
.exclude(...exclude)
.forRoutes('api', 'health', 'ws');
.forRoutes('api', 'health');
// Dashboard
const dashboardCredentials = this.dashboardConfig.credentials;
if (dashboardCredentials) {
const username = dashboardCredentials[0];
+22
View File
@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { User } from '@waha/core/auth/apiKey.strategy';
import { SessionManager } from '@waha/core/abc/manager.abc';
@Injectable()
export class ApiKeyService {
constructor(private manager: SessionManager) {}
async get(apikey: string): Promise<User | null> {
if (!apikey) {
return null;
}
const key = await this.manager.apiKeyRepository.getActiveByKey(apikey);
if (!key) {
return null;
}
return {
isAdmin: key.isAdmin,
session: key.session,
};
}
}
+20 -22
View File
@@ -1,34 +1,32 @@
import { Injectable } from '@nestjs/common';
import { IncomingMessage } from 'http';
import * as url from 'url';
import { IApiKeyAuth } from './auth';
import { URL } from 'url';
import { ApiKeyStrategy } from '@waha/core/auth/apiKey.strategy';
@Injectable()
export class WebSocketAuth {
constructor(private auth: IApiKeyAuth) {}
constructor(private strategy: ApiKeyStrategy) {}
validateRequest(request: IncomingMessage) {
if (this.auth.skipAuth()) {
return true;
}
const provided = this.getKeyFromQueryParams(request);
return this.auth.isValid(provided);
async validateRequest(request: IncomingMessage) {
const apikey = this.getKeyFromQueryParams('x-api-key', request);
return await this.strategy.user(apikey);
}
private getKeyFromQueryParams(request: IncomingMessage) {
let query = url.parse(request.url, true).query;
// case-insensitive query params
query = Object.keys(query).reduce((acc, key) => {
acc[key.toLowerCase()] = query[key];
return acc;
}, {});
private getKeyFromQueryParams(name: string, request: IncomingMessage) {
// Case-insensitive
name = name.toLowerCase();
const query = new URL(request.url || '', 'http://localhost').searchParams;
const matches: string[] = [];
const provided = query['x-api-key'];
// Check if it's array - return first
if (Array.isArray(provided)) {
return provided[0];
for (const [key, value] of query.entries()) {
if (key.toLowerCase() === name) {
matches.push(value);
}
}
return provided;
if (matches.length === 0) {
return undefined;
}
return matches[0];
}
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class ApiKeyAuthGuard extends AuthGuard('headerapikey') {}
+32
View File
@@ -0,0 +1,32 @@
import {
Injectable,
InternalServerErrorException,
NestMiddleware,
UnauthorizedException,
} from '@nestjs/common';
import * as passport from 'passport';
@Injectable()
export class ApiKeyAuthMiddleware implements NestMiddleware {
constructor() {}
use(req: any, res: any, next: () => void) {
passport.authenticate('headerapikey', { session: false }, (err, user?) => {
if (err) {
const exception =
err instanceof UnauthorizedException
? err
: new InternalServerErrorException();
res.status(exception.getStatus()).json(exception.getResponse());
return;
}
if (!user) {
const exception = new UnauthorizedException();
res.status(exception.getStatus()).json(exception.getResponse());
return;
}
req.user = user;
next();
})(req, res, next);
}
}
+29 -6
View File
@@ -2,19 +2,42 @@ import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { IApiKeyAuth } from '@waha/core/auth/auth';
import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
import { ApiKeyService } from '@waha/core/auth/ApiKeyService';
export interface User {
isAdmin: boolean;
session?: string;
}
function AdminUser(): User {
return {
isAdmin: true,
session: null,
};
}
@Injectable()
export class ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy) {
constructor(private auth: IApiKeyAuth) {
constructor(
private auth: IApiKeyAuth,
private apiKeyService: ApiKeyService,
) {
// @ts-ignore
super({ header: 'X-Api-Key', prefix: '' }, true, (apikey, done) => {
const isValid = this.auth.isValid(apikey);
return done(isValid);
return this.validate(apikey, done);
});
}
validate(apikey: string, done: (result: boolean) => void): void {
const isValid = this.auth.isValid(apikey);
return done(isValid);
validate(apikey: string, done: (err?, user?: User) => void): void {
this.user(apikey)
.then((user) => done(null, user))
.catch((err) => done(err, null));
}
async user(apikey: string): Promise<User> {
if (this.auth.isValid(apikey)) {
return AdminUser();
}
return this.apiKeyService.get(apikey);
}
}
-28
View File
@@ -1,28 +0,0 @@
import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from '@nestjs/common';
import * as passport from 'passport';
import { IApiKeyAuth } from './auth';
@Injectable()
export class AuthMiddleware implements NestMiddleware {
constructor(private auth: IApiKeyAuth) {}
use(req: any, res: any, next: () => void) {
// Skip authentication if auth says so
if (this.auth.skipAuth()) {
next();
return;
}
passport.authenticate('headerapikey', { session: false }, (value) => {
if (!value) {
throw new UnauthorizedException();
}
next();
})(req, res, next);
}
}
-14
View File
@@ -2,18 +2,12 @@ import * as crypto from 'crypto';
export abstract class IApiKeyAuth {
abstract isValid(plain: string): boolean;
abstract skipAuth(): boolean;
}
export class NoAuth implements IApiKeyAuth {
isValid(plain: string): boolean {
return true;
}
skipAuth(): boolean {
return true;
}
}
export class PlainApiKeyAuth implements IApiKeyAuth {
@@ -22,10 +16,6 @@ export class PlainApiKeyAuth implements IApiKeyAuth {
isValid(plain: string): boolean {
return compare(plain, this.key);
}
skipAuth(): boolean {
return false;
}
}
export class HashAuth implements IApiKeyAuth {
@@ -41,10 +31,6 @@ export class HashAuth implements IApiKeyAuth {
const hash = crypto.createHash(this.algorithm).update(plain).digest('hex');
return compare(hash, this.hash);
}
skipAuth(): boolean {
return false;
}
}
/**
+32
View File
@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { User } from './apiKey.strategy';
import { createMongoAbility } from '@casl/ability';
import { AdminRules, SessionRules } from './casl.rules';
import { Action, AppAbility, session } from './casl.types';
@Injectable()
export class CaslAbilityFactory {
createForUser(user: User): AppAbility {
// when we disable auth using WHATSAPP_API_KEY_EXCLUDE_PATH
// req.use will be null, so we allow any request
if (!user) {
return createMongoAbility(AdminRules());
}
// Admin user
if (user.isAdmin) {
return createMongoAbility(AdminRules());
}
if (user.session) {
return createMongoAbility(SessionRules(user.session));
}
return createMongoAbility([]);
}
}
export function FilterSessions<T extends { name: string }>(
ability: AppAbility,
action: Action,
sessions: T[],
) {
return sessions.filter((s) => ability.can(action, new session(s.name)));
}
+45
View File
@@ -0,0 +1,45 @@
import { RawRuleOf } from '@casl/ability';
import { Action, AppAbility } from './casl.types';
export function AdminRules(): RawRuleOf<AppAbility>[] {
return [
{
action: Action.Manage,
subject: 'all',
},
];
}
export function SessionRules(name: string): RawRuleOf<AppAbility>[] {
return [
//
// Server
//
{
action: 'read',
subject: 'server',
},
//
// Session
//
{
action: Action.List,
subject: 'session',
},
{
action: Action.Read,
subject: 'session',
conditions: { name: name },
},
// {
// action: Action.Delete,
// subject: 'session',
// conditions: { name: name },
// },
{
action: Action.Use,
subject: 'session',
conditions: { name: name },
},
];
}
+28
View File
@@ -0,0 +1,28 @@
import { InferSubjects, MongoAbility } from '@casl/ability';
export class session {
constructor(public name: string) {}
}
export class server {}
export enum Action {
//
// CASL
//
Manage = 'manage', // it's a special action in casl, meaning "any actions"
//
// Session
//
List = 'list', // list sessions
Read = 'read', // read session info (not messages)
Create = 'create', // create a new session
Delete = 'delete', // delete a session
Use = 'use', // all actions - send a message, retrieve, manage session status
}
type Subjects =
| InferSubjects<typeof session | typeof server | 'session' | 'server'>
| 'all';
type Actions = keyof typeof Action | Action | `${Action}`;
export type AppAbility = MongoAbility<[Actions, Subjects]>;
+1
View File
@@ -1,4 +1,5 @@
import { parseBool } from '@waha/helpers';
import * as crypto from 'crypto';
export interface SValue {
param: string;
+15
View File
@@ -0,0 +1,15 @@
import { SetMetadata } from '@nestjs/common';
import { ExecutionContext } from '@nestjs/common';
import { AppAbility } from './casl.types';
export const CHECK_POLICIES_KEY = 'check_policies';
export type PolicyHandlerCallback = (
ability: AppAbility,
context: ExecutionContext,
) => boolean;
export type PolicyHandler = PolicyHandlerCallback;
export const CheckPolicies = (...handlers: PolicyHandler[]) =>
SetMetadata(CHECK_POLICIES_KEY, handlers);
+39
View File
@@ -0,0 +1,39 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CaslAbilityFactory } from '@waha/core/auth/casl.ability';
import {
CHECK_POLICIES_KEY,
PolicyHandler,
} from '@waha/core/auth/policies.decorator';
@Injectable()
export class PoliciesGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly caslAbilityFactory: CaslAbilityFactory,
) {}
canActivate(context: ExecutionContext): boolean {
const handlers =
this.reflector.getAllAndOverride<PolicyHandler[]>(CHECK_POLICIES_KEY, [
context.getHandler(),
context.getClass(),
]) || [];
const req = context.switchToHttp().getRequest();
const user = req.user;
const ability = this.caslAbilityFactory.createForUser(user);
req.ability = ability;
const ok = handlers.every((handler) => handler(ability, context));
if (!ok) {
throw new ForbiddenException();
}
return true;
}
}
+46
View File
@@ -0,0 +1,46 @@
import { BadRequestException, ExecutionContext } from '@nestjs/common';
import {
Action,
AppAbility,
server,
session,
} from '@waha/core/auth/casl.types';
type GetSession = (req: any) => unknown;
function requireSessionName(value: unknown) {
if (typeof value !== 'string' || !value.trim()) {
throw new BadRequestException('Session name is required');
}
return value;
}
export function CanSession(action: Action, name?: GetSession) {
return (ability: AppAbility, context: ExecutionContext) => {
const req = context.switchToHttp().getRequest();
if (name) {
const sessionName = requireSessionName(name(req));
return ability.can(action, new session(sessionName));
}
return ability.can(action, 'session');
};
}
export function FromParam(key = 'session'): GetSession {
return (req) => req.params?.[key];
}
export function FromBody(key = 'session'): GetSession {
return (req) => req.body?.[key];
}
export function FromQuery(key = 'session'): GetSession {
return (req) => req.query?.[key];
}
export function CanServer(action: Action) {
return (ability: AppAbility, context: ExecutionContext) => {
return ability.can(action, 'server');
};
}
@@ -52,9 +52,11 @@ export class GowsEventStreamObservable extends Observable<EnginePayload> {
};
stream.on('data', (raw) => {
const obj = raw.toObject();
obj.data = JSON.parse(obj.data);
subscriber?.next(obj);
setImmediate(() => {
const obj = raw.toObject();
obj.data = JSON.parse(obj.data);
subscriber?.next(obj);
});
});
stream.on('end', (...args) => {
+34 -25
View File
@@ -9,6 +9,7 @@ export class GowsSubprocess {
private child: any;
private ready: boolean = false;
private stdoutBuffer: string = '';
constructor(
private logger: Logger,
@@ -17,7 +18,7 @@ export class GowsSubprocess {
readonly pprof: boolean = false,
) {}
start(onExit: (code: number) => void) {
start(onExit: (code: number | null, signal: NodeJS.Signals | null) => void) {
this.logger.info('Starting GOWS subprocess...');
this.logger.debug(`GOWS path '${this.path}', socket: '${this.socket}'...`);
@@ -33,43 +34,51 @@ export class GowsSubprocess {
detached: true,
});
this.logger.debug(`GOWS started with PID: ${this.child.pid}`);
this.child.on('close', async (code, singal) => {
const msg = code
? `GOWS subprocess closed with code ${code}`
: `GOWS subprocess closed by signal ${singal}`;
this.child.on('close', (code, signal) => {
const msg =
code !== null
? `GOWS subprocess closed with code ${code}`
: `GOWS subprocess closed by signal ${signal}`;
this.logger.debug(msg);
onExit(code);
onExit(code, signal);
});
this.child.on('error', (err) => {
this.logger.error(`GOWS subprocess error: ${err}`);
});
this.child.stderr.setEncoding('utf8');
this.child.stderr.on('data', (data) => {
this.logger.error(data);
this.child.stderr?.setEncoding('utf8');
this.child.stderr?.on('data', (data) => {
this.logger.error(data.toString().trim());
});
this.child.stdout.setEncoding('utf8');
this.child.stdout.on('data', async (data) => {
// remove empty line at the end, split by \n
const lines = data.trim().split('\n');
lines.forEach((line) => this.log(line));
this.child.stdout?.setEncoding('utf8');
this.child.stdout?.on('data', (data) => {
this.handleStdout(data.toString());
});
this.listenReady();
}
listenReady() {
this.child.stdout.on('data', async (data) => {
if (this.ready) {
private handleStdout(chunk: string) {
this.stdoutBuffer += chunk;
const parts = this.stdoutBuffer.split('\n');
this.stdoutBuffer = parts.pop() ?? '';
parts.forEach((line) => {
const trimmed = line.trim();
if (!trimmed) {
return;
}
if (!data.includes(this.readyText)) {
return;
}
await sleep(this.readyDelayMs);
this.ready = true;
this.logger.info('GOWS is ready');
this.log(trimmed);
void this.checkReady(trimmed);
});
void this.checkReady(this.stdoutBuffer);
}
private async checkReady(text: string) {
if (this.ready || !text.includes(this.readyText)) {
return;
}
await sleep(this.readyDelayMs);
this.ready = true;
this.logger.info('GOWS is ready');
}
async waitWhenReady(timeout: number) {
@@ -79,7 +88,7 @@ export class GowsSubprocess {
timeout,
);
if (!started) {
const msg = 'GOWS did not start after 10 seconds';
const msg = `GOWS did not start after ${timeout} ms`;
this.logger.error(msg);
throw new Error(msg);
}
+20
View File
@@ -40,6 +40,10 @@ export class WebjsClientCore extends Client {
await this.attachCustomEventListeners();
await this.injectWaha();
});
this.on(Events.READY, async () => {
await this.attachCustomEventListeners();
await this.injectWaha();
});
}
async initialize() {
@@ -124,6 +128,7 @@ export class WebjsClientCore extends Client {
}
async setPushName(name: string) {
await this.ensureWahaInjected();
await this.pupPage.evaluate(async (pushName) => {
return await window['WAHA'].WAWebSetPushnameConnAction.setPushname(
pushName,
@@ -151,6 +156,7 @@ export class WebjsClientCore extends Client {
}
async createLabel(name: string, color: number): Promise<number> {
await this.ensureWahaInjected();
const labelId: number = (await this.pupPage.evaluate(
async (name, color) => {
// @ts-ignore
@@ -166,6 +172,7 @@ export class WebjsClientCore extends Client {
}
async deleteLabel(label: Label) {
await this.ensureWahaInjected();
return await this.pupPage.evaluate(async (label) => {
// @ts-ignore
return await window.WAHA.WAWebBizLabelEditingAction.labelDeleteAction(
@@ -177,6 +184,7 @@ export class WebjsClientCore extends Client {
}
async updateLabel(label: Label) {
await this.ensureWahaInjected();
return await this.pupPage.evaluate(async (label) => {
// @ts-ignore
return await window.WAHA.WAWebBizLabelEditingAction.labelEditAction(
@@ -193,6 +201,8 @@ export class WebjsClientCore extends Client {
return await super.getChats();
}
await this.ensureWahaInjected();
// Get paginated chats
pagination.limit ||= Infinity;
pagination.offset ||= 0;
@@ -209,6 +219,16 @@ export class WebjsClientCore extends Client {
return chats.map((chat) => ChatFactory.create(this, chat));
}
protected async ensureWahaInjected() {
const hasWaha = await this.pupPage.evaluate(() => {
// @ts-ignore
return Boolean(window.WAHA && window.WAHA.getChats);
});
if (!hasWaha) {
await this.injectWaha();
}
}
async sendTextStatus(status: TextStatus) {
// Convert from hex to number
const waColor = 'FF' + status.backgroundColor.replace('#', '');
@@ -44,8 +44,10 @@ export class WebhookConductor {
for (const event of events) {
const obs$ = session.getEventObservable(event);
obs$.subscribe((payload) => {
const data = populateSessionInfo(event, session)(payload);
sender.send(data);
setImmediate(() => {
const data = populateSessionInfo(event, session)(payload);
sender.send(data);
});
});
this.logger.debug(`Event '${event}' is enabled for url: ${url}`);
}
+2
View File
@@ -48,6 +48,7 @@ import { getProxyConfig } from './helpers.proxy';
import { MediaManager } from './media/MediaManager';
import { LocalSessionAuthRepository } from './storage/LocalSessionAuthRepository';
import { LocalStoreCore } from './storage/LocalStoreCore';
import { CoreApiKeyRepository } from './storage/CoreApiKeyRepository';
export class OnlyDefaultSessionIsAllowed extends UnprocessableEntityException {
constructor(name: string) {
@@ -137,6 +138,7 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
}
async onApplicationBootstrap() {
this.apiKeyRepository = new CoreApiKeyRepository();
await this.engineBootstrap.bootstrap();
this.startPredefinedSessions();
}
+52
View File
@@ -0,0 +1,52 @@
import { UnprocessableEntityException } from '@nestjs/common';
import { DOCS_URL } from '@waha/core/exceptions';
import {
ApiKey,
IApiKeyRepository,
} from '@waha/core/storage/IApiKeyRepository';
export class CoreApiKeyRepository implements IApiKeyRepository {
async init() {
return;
}
list(): Promise<ApiKey[]> {
return Promise.resolve([]);
}
getActiveByKey(key: string): Promise<ApiKey | null> {
void key;
return Promise.resolve(null);
}
getById(id: string): Promise<ApiKey | null> {
void id;
return Promise.resolve(null);
}
getByKey(key: string): Promise<ApiKey | null> {
void key;
return Promise.resolve(null);
}
async upsert(key: ApiKey): Promise<ApiKey> {
void key;
throw new UnprocessableEntityException(
`API key management is not available in this edition. See ${DOCS_URL}`,
);
}
async deleteById(id: string): Promise<void> {
void id;
throw new UnprocessableEntityException(
`API key management is not available in this edition. See ${DOCS_URL}`,
);
}
async deleteBySession(session: string): Promise<void> {
void session;
throw new UnprocessableEntityException(
`API key management is not available in this edition. See ${DOCS_URL}`,
);
}
}
+45
View File
@@ -0,0 +1,45 @@
import { RawRuleOf } from '@casl/ability';
import { UnprocessableEntityException } from '@nestjs/common';
import { AppAbility } from '../auth/casl.types';
export interface ApiKey {
id: string;
key: string;
isActive: boolean;
isAdmin: boolean;
session: string | null;
rules: RawRuleOf<AppAbility>[] | null;
}
export interface IApiKeyRepository {
init(): Promise<void>;
list(): Promise<ApiKey[]>;
upsert(key: ApiKey): Promise<ApiKey>;
getActiveByKey(key: string): Promise<ApiKey | null>;
getById(id: string): Promise<ApiKey | null>;
getByKey(key: string): Promise<ApiKey | null>;
deleteById(id: string): Promise<void>;
deleteBySession(session: string): Promise<void>;
}
export function CheckInvariant(
apiKey: Pick<ApiKey, 'isAdmin' | 'session'>,
): void {
if (apiKey.isAdmin && apiKey.session) {
throw new UnprocessableEntityException(
'Session is not allowed for admin keys',
);
}
if (!apiKey.isAdmin && !apiKey.session) {
throw new UnprocessableEntityException(
'Either isAdmin must be true or session must be provided',
);
}
}
@@ -8,6 +8,10 @@ export abstract class ISessionConfigRepository {
abstract getConfig(sessionName: string): Promise<SessionConfig | null>;
abstract getConfigBySessions(
sessionNames: string[],
): Promise<Map<string, SessionConfig | null>>;
abstract exists(sessionName: string): Promise<boolean>;
abstract deleteConfig(sessionName: string): Promise<void>;
+4
View File
@@ -5,6 +5,10 @@ export abstract class ISessionMeRepository {
abstract getMe(sessionName: string): Promise<MeInfo | null>;
abstract getMeBySessions(
sessionNames: string[],
): Promise<Map<string, MeInfo | null>>;
abstract removeMe(sessionName: string): Promise<void>;
abstract init(): Promise<void>;
@@ -48,6 +48,26 @@ export class LocalSessionConfigRepository extends ISessionConfigRepository {
return JSON.parse(content);
}
async getConfigBySessions(
sessionNames: string[],
): Promise<Map<string, SessionConfig | null>> {
const result = new Map<string, SessionConfig | null>();
const uniqueNames = Array.from(new Set(sessionNames));
if (uniqueNames.length === 0) {
return result;
}
const items = await Promise.all(
uniqueNames.map(async (sessionName) => ({
sessionName,
config: await this.getConfig(sessionName),
})),
);
for (const item of items) {
result.set(item.sessionName, item.config ?? null);
}
return result;
}
async saveConfig(sessionName: string, config: SessionConfig) {
// Create a folder if not exist
const folder = this.store.getSessionDirectory(sessionName);
+26
View File
@@ -52,3 +52,29 @@ export const SQLSessionWorkerMigrations: Migration[] = [
// Worker can have multiple records
'CREATE INDEX IF NOT EXISTS session_worker_worker_idx ON session_worker (worker)',
];
/**
* Api Keys
*/
export const SQLApiKeySchema = new Schema(
'api_key',
[
new Field('id', 'TEXT'),
new Field('key', 'TEXT'),
new Field('isActive', 'INTEGER'),
new Field('session', 'TEXT'),
new Field('data', 'TEXT'),
],
[
new Index('api_key_id_index', ['id']),
new Index('api_key_key_idx', ['key']),
new Index('api_key_session_idx', ['session']),
],
);
export const SQLApiKeyMigrations: Migration[] = [
'CREATE TABLE IF NOT EXISTS api_key (id TEXT PRIMARY KEY, "key" TEXT, "isActive" INTEGER, session TEXT, data TEXT)',
'CREATE UNIQUE INDEX IF NOT EXISTS api_key_id_index ON api_key (id)',
'CREATE UNIQUE INDEX IF NOT EXISTS api_key_key_idx ON api_key ("key")',
'CREATE INDEX IF NOT EXISTS api_key_session_idx ON api_key (session)',
];
@@ -36,6 +36,22 @@ export class Sqlite3SessionMeRepository
return data?.me;
}
async getMeBySessions(
sessionNames: string[],
): Promise<Map<string, MeInfo | null>> {
const result = new Map<string, MeInfo | null>();
const uniqueNames = Array.from(new Set(sessionNames));
if (uniqueNames.length === 0) {
return result;
}
const entities = await this.getEntitiesByIds(uniqueNames);
for (const sessionName of uniqueNames) {
const entity = entities.get(sessionName);
result.set(sessionName, entity?.me ?? null);
}
return result;
}
removeMe(sessionName: string): Promise<void> {
return this.deleteById(sessionName);
}
+37
View File
@@ -0,0 +1,37 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsOptional, ValidateIf } from 'class-validator';
import { SessionName } from '@waha/structures/sessions.dto';
export class ApiKeyDTO {
@ApiProperty({ example: 'key_id_00000000000000000000000000' })
id: string;
@ApiProperty({ example: 'key_11111111111AAAAAAAAAAAAAAAAAAAAA' })
key: string;
@ApiProperty({ example: true })
isActive: boolean;
@ApiProperty({ example: false })
isAdmin: boolean;
@ApiProperty({ example: 'default', required: false, nullable: true })
session: string | null;
}
export class ApiKeyRequest {
@ApiProperty({ example: false })
@IsBoolean()
isAdmin: boolean = false;
@ApiProperty({ example: 'default', nullable: true })
@SessionName()
@IsOptional()
session: string | null = null;
@ApiProperty({ required: true, example: true })
@IsOptional()
@IsBoolean()
isActive: boolean = true;
}
+11
View File
@@ -25,4 +25,15 @@ export class WAHAEnvironment {
example: 'linux/x86',
})
platform: string;
@ApiProperty({
example: {
id: 'worker-1',
},
nullable: true,
description: 'Worker metadata for the running instance.',
})
worker: {
id: string | null;
};
}
+13 -6
View File
@@ -1,3 +1,4 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { BooleanString } from '@waha/nestjs/validation/BooleanString';
@@ -307,19 +308,25 @@ export class SessionDetailedInfo extends SessionInfo {
const DB_NAME_LIMIT = 64;
const DB_NAME_MAX_PREFIX_LEN = 'waha_noweb'.length;
export function SessionName() {
return applyDecorators(
IsString(),
MaxLength(DB_NAME_LIMIT - DB_NAME_MAX_PREFIX_LEN),
Matches(/^[a-zA-Z0-9_-]*$/, {
message:
'Session name can only contain alphanumeric characters, hyphens, and underscores (a-z, A-Z, 0-9, -, _) or be empty',
}),
);
}
export class SessionCreateRequest {
@ApiProperty({
example: 'default',
description: 'Session name (id)',
required: false,
})
@IsString()
@IsOptional()
@MaxLength(DB_NAME_LIMIT - DB_NAME_MAX_PREFIX_LEN)
@Matches(/^[a-zA-Z0-9_-]*$/, {
message:
'Session name can only contain alphanumeric characters, hyphens, and underscores (a-z, A-Z, 0-9, -, _) or be empty',
})
@SessionName()
name: string | undefined;
@ValidateNested()
+13
View File
@@ -1,3 +1,4 @@
import * as crypto from 'crypto';
import { ulid } from 'ulid';
/**
@@ -7,3 +8,15 @@ import { ulid } from 'ulid';
export function generatePrefixedId(prefix: string) {
return `${prefix}_${ulid().toLowerCase()}`;
}
const SECRET_CHARS =
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
export function generateSecret(length: number = 32) {
const bytes = crypto.randomBytes(length);
let secret = '';
for (let i = 0; i < length; i += 1) {
secret += SECRET_CHARS[bytes[i % bytes.length] % SECRET_CHARS.length];
}
return secret;
}
+6 -1
View File
@@ -29,6 +29,10 @@ export function getWAHAVersion(): WAHAVersion {
return WAHAVersion.CORE;
}
export function getWorker() {
return { id: process.env.WAHA_WORKER_ID || null };
}
function getBrowser() {
return getEngineName() === WAHAEngine.WEBJS
? getBrowserExecutablePath()
@@ -40,11 +44,12 @@ function getPlatform() {
}
export const VERSION: WAHAEnvironment = {
version: '2026.1.3',
version: '2026.1.4',
engine: getEngineName(),
tier: getWAHAVersion(),
browser: getBrowser(),
platform: getPlatform(),
worker: getWorker(),
};
export const IsChrome = VERSION.browser?.includes('chrome');
+2 -2
View File
@@ -2,11 +2,11 @@
"waha": {
"gows": {
"repo": "devlikeapro/gows",
"ref": "v1.0.30"
"ref": "v1.0.31"
},
"dashboard": {
"repo": "devlikeapro/dashboard",
"ref": "6030cf5e08f2a6e6d09a7a70a1c4194baf0beb55"
"ref": "6923320467747d7abc4e8e336852bc8ef3f992e9"
}
}
}
+52 -6
View File
@@ -5,9 +5,9 @@ __metadata:
version: 8
cacheKey: 10
"@adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-12-17":
"@adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2026-01-25":
version: 7.0.0-rc.9
resolution: "@adiwajshing/baileys@https://github.com/devlikeapro/Baileys.git#commit=21289d1c97dd4b6b0c2585c0f77cecc486fb9f2f"
resolution: "@adiwajshing/baileys@https://github.com/devlikeapro/Baileys.git#commit=7a9463729776e6fb19ccfb9ce8312515db92b94a"
dependencies:
"@cacheable/node-cache": "npm:^1.4.0"
"@hapi/boom": "npm:^9.1.3"
@@ -31,7 +31,7 @@ __metadata:
optional: true
link-preview-js:
optional: true
checksum: 10/6d3cbdbbb490356b89d65310244a8120f650641efbfd43f6fea4c5296c17d75feaeb7d870ee2ba77015b97c94c7ffd1950d0e6466b5c0dbc5eae020e68cf85d5
checksum: 10/69fce0f910203bc1bf4c1c677110e2d887fb5d00e5ea4759da8506afa36606baf70dc19e5396b24997cb204c86b48cda5d88a1e7d333282f7499b52b356c3b00
languageName: node
linkType: hard
@@ -1242,6 +1242,15 @@ __metadata:
languageName: node
linkType: hard
"@casl/ability@npm:^6.8.0":
version: 6.8.0
resolution: "@casl/ability@npm:6.8.0"
dependencies:
"@ucast/mongo2js": "npm:^1.3.0"
checksum: 10/e5a6b9c4ea480f734cfa1c88bbd18d65eb74ab2d66ef082e50b40064583ff70fa101e70e06faf0403a824b9b96879bcf57cf7abfecf7a5a26d6d99e6f16bd056
languageName: node
linkType: hard
"@colors/colors@npm:1.5.0":
version: 1.5.0
resolution: "@colors/colors@npm:1.5.0"
@@ -4018,6 +4027,42 @@ __metadata:
languageName: node
linkType: hard
"@ucast/core@npm:^1.0.0, @ucast/core@npm:^1.4.1, @ucast/core@npm:^1.6.1":
version: 1.10.2
resolution: "@ucast/core@npm:1.10.2"
checksum: 10/35c91961b534df03518ade09bf920856355bfadf0a59a8606d91ac4e22a24c2bd470964b2c54764a31e8eacecd4a2768ba8426b6a96c2474c6df305ee9b3f9a1
languageName: node
linkType: hard
"@ucast/js@npm:^3.0.0":
version: 3.0.4
resolution: "@ucast/js@npm:3.0.4"
dependencies:
"@ucast/core": "npm:^1.0.0"
checksum: 10/8a8a5ebe45b29e1885f00a05b293b47c54cbbe506080720c505425cd52bc3e311d16a49069ae8d0f797faa34d439ad066591a7abfebf56e89702c9ad7e57a96a
languageName: node
linkType: hard
"@ucast/mongo2js@npm:^1.3.0":
version: 1.4.0
resolution: "@ucast/mongo2js@npm:1.4.0"
dependencies:
"@ucast/core": "npm:^1.6.1"
"@ucast/js": "npm:^3.0.0"
"@ucast/mongo": "npm:^2.4.0"
checksum: 10/85def36f3da27469dea621dff616ce19bd15011fad56dba04ba2ece0f08b1943592e68f7e43e4a63612297cac8d4dd980937f0f16cbaffe39be7c5670f9a8943
languageName: node
linkType: hard
"@ucast/mongo@npm:^2.4.0":
version: 2.4.3
resolution: "@ucast/mongo@npm:2.4.3"
dependencies:
"@ucast/core": "npm:^1.4.1"
checksum: 10/5bcac2a06df97dda33deab3cfe1d57effd4ac49f27a846de29d6e583ca04f379f63d0def4737ec2c023dd9bbfded8ef2dfa285ed1575b8a320b1c2807c2d6200
languageName: node
linkType: hard
"@wasm-audio-decoders/common@npm:9.0.7":
version: 9.0.7
resolution: "@wasm-audio-decoders/common@npm:9.0.7"
@@ -12548,13 +12593,14 @@ __metadata:
version: 0.0.0-use.local
resolution: "waha@workspace:."
dependencies:
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2025-12-17"
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2026-01-25"
"@adiwajshing/keyed-db": "npm:^0.2.4"
"@aws-sdk/client-s3": "npm:^3.633.0"
"@aws-sdk/s3-request-presigner": "npm:^3.633.0"
"@bull-board/api": "npm:^6.9.1"
"@bull-board/express": "npm:^6.9.1"
"@bull-board/nestjs": "npm:^6.9.1"
"@casl/ability": "npm:^6.8.0"
"@figuro/chatwoot-sdk": "npm:^1.1.17"
"@grpc/grpc-js": "npm:^1.14.1"
"@grpc/proto-loader": "npm:^0.8.0"
@@ -12756,7 +12802,7 @@ __metadata:
"whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2025-12-17":
version: 1.34.3
resolution: "whatsapp-web.js@https://github.com/devlikeapro/whatsapp-web.js.git#commit=766bb3957a81338250ee830d34cfceec278e545e"
resolution: "whatsapp-web.js@https://github.com/devlikeapro/whatsapp-web.js.git#commit=47347ab0a148e01daf271a9e8c39b50dbc5c3cb2"
dependencies:
"@pedroslopez/moduleraid": "npm:^5.0.2"
archiver: "npm:^5.3.1"
@@ -12774,7 +12820,7 @@ __metadata:
optional: true
unzipper:
optional: true
checksum: 10/cb54d6192bd5d24c5521d1d5599fc7da65e87c3dfc595cab708b7335e95842d31fd7c0d84a7c479202de55bda67c4dcaec802150568e5f35815b6b07fd669d57
checksum: 10/fb3bf88719634f1061823d3b3d548007355886d2394f8dbe8bcf1570c70dc09849034cfbe488bdfc3f20807933373ed632e8b2c550198ec41ee1c03a5bdfbe29
languageName: node
linkType: hard