Compare commits

...

3 Commits

Author SHA1 Message Date
glopes
44adffb713 refactor for the new tree and add todos 2026-02-08 17:41:26 -03:00
glopes
016b6516b2 refactor tree 2026-02-06 21:20:48 -03:00
glopes
b058ce933f new schema 2026-02-06 20:12:08 -03:00
20 changed files with 369 additions and 422 deletions

View File

@ -5,10 +5,10 @@ const Database = require("better-sqlite3");
const path = require("path");
const crypto = require("crypto");
const DB_PATH = path.resolve(__dirname, "../data/data.db");
function callDb(fn) {
const db = new Database(path.resolve(__dirname, "../data/auth.db"), {
fileMustExist: true,
});
const db = new Database(DB_PATH, { fileMustExist: true });
try {
fn(db);
} finally {
@ -20,118 +20,120 @@ function gen(bytes) {
return crypto.randomBytes(bytes).toString("hex");
}
/**
* adduser <user_name> <source>
*/
function addUser(args) {
const [username, email, status, displayName] = args;
const [userName, source] = args;
if (!userName || !source) {
console.error("usage: adduser <user_name> <source>");
process.exit(1);
}
callDb((db) => {
const res = db
.prepare(
`
INSERT INTO users (username, email, status, display_name)
VALUES (?, ?, ?, ?)
INSERT INTO users (
user_name,
user_source,
user_status,
user_created_at
)
VALUES (?, ?, 1, ?)
`,
)
.run(username, email, status, displayName);
.run(userName, source, Date.now());
console.log("user_id:", res.lastInsertRowid);
});
}
function disUser(args) {
const [username] = args;
callDb((db) => {
db.prepare(
`
UPDATE users SET status = 'disabled'
WHERE username = ?
`,
).run(username);
console.log("user disabled:", username);
});
}
function listUser(args) {
const [username] = args;
/**
* genkey <user_name>
*/
function genKey(args) {
const [userName] = args;
callDb((db) => {
const user = db
.prepare(
`
SELECT user_id, username, email, status, display_name, created_at
FROM users
WHERE username = ?
`,
)
.get(username);
.prepare(`SELECT user_id FROM users WHERE user_name = ?`)
.get(userName);
if (!user) {
console.log("user not found");
return;
console.error("user not found");
process.exit(1);
}
console.log("USER");
console.log(user);
const keys = db
.prepare(
`
SELECT api_key, status, created_at, last_used_at
FROM api_keys
WHERE user_id = ?
`,
)
.all(user.user_id);
if (keys.length) {
console.log("\nAPI_KEYS");
keys.forEach((k) => console.log(k));
}
});
}
function addKey(args) {
const [username] = args;
callDb((db) => {
const user = db
.prepare(
`
SELECT user_id FROM users WHERE username = ?
`,
)
.get(username);
const apiKey = gen(24);
const apiSecret = gen(48);
const apiKey = gen(24); // 48 hex chars
const apiSecret = gen(48); // 96 hex chars
db.prepare(
`
INSERT INTO api_keys (user_id, api_key, api_secret, status)
VALUES (?, ?, ?, 'active')
UPDATE users
SET user_api_key = ?, user_api_secret = ?
WHERE user_id = ?
`,
).run(user.user_id, apiKey, apiSecret);
).run(apiKey, apiSecret, user.user_id);
console.log("api_key:", apiKey);
console.log("api_secret:", apiSecret);
});
}
function disKey(args) {
const [apiKey] = args;
/**
* disable <user_name>
*/
function disableUser(args) {
const [userName] = args;
callDb((db) => {
db.prepare(
`
UPDATE api_keys SET status = 'revoked'
WHERE api_key = ?
UPDATE users SET user_status = 0
WHERE user_name = ?
`,
).run(apiKey);
).run(userName);
console.log("api_key revoked");
console.log("user disabled:", userName);
});
}
/**
* list <user_name>
*/
function listUser(args) {
const [userName] = args;
callDb((db) => {
const user = db
.prepare(
`
SELECT
user_id,
user_name,
user_source,
user_status,
user_api_key,
user_created_at
FROM users
WHERE user_name = ?
`,
)
.get(userName);
if (!user) {
console.log("user not found");
return;
}
console.log(user);
});
}
/* ======================== */
const argv = process.argv.slice(2);
const cmd = argv[0];
const args = argv.slice(1);
@ -141,23 +143,20 @@ switch (cmd) {
addUser(args);
break;
case "disuser":
disUser(args);
case "genkey":
genKey(args);
break;
case "listuser":
case "disable":
disableUser(args);
break;
case "list":
listUser(args);
break;
case "addkey":
addKey(args);
break;
case "diskey":
disKey(args);
break;
default:
console.error("Unknown command");
console.error("Commands: adduser | genkey | disable | list");
process.exit(1);
}

View File

@ -4,9 +4,9 @@ declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use FrameworkX\App;
use Bass\Webclient\Auth\Middleware\HmacAuthMiddleware;
use Bass\Webclient\Middleware\HmacAuthMiddleware;
use Bass\Webclient\Controllers\RequestController;
use Bass\Webclient\Http\ResponseLib;
use Bass\Webclient\Libs\ResponseLib;
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
@ -16,14 +16,12 @@ $app = new App();
$hmacAuth = new HmacAuthMiddleware();
$app->post(
'/v1/request',
$hmacAuth,
RequestController::class
);
$app->get(
'/health',
fn() => ResponseLib::sendOk(['status' => 'ok'])

31
readme.md Normal file
View File

@ -0,0 +1,31 @@
├── bin
│   ├── email-cli
│   └── support-cli
├── data
│   └── data.db
├── logs
├── public
│   └── index.php
├── readme.md
├── src
│   ├── Middleware
│   ├ |── HmacAuthMiddleware.php
│   │   └── RateLImitMiddleware.php
│   ├── Controllers
│   │   └── RequestController.php
│   ├── Model
│   │   └── ModelFactory.php
│   │   └── LeadModel.php
│   ├── Libs
│   │   └── ResponseLib.php
│   │   ├── ExecLib.php
│   │   ├── GuardLib.php
│   │   ├── RequestLib.php
│   │   └── SanitizationLib.php
│   ├── migration
│   │   └── schema.sql
│   └── Schema
│   └── LeadSchema.php
└── test
├── hmac_curl.sh
└── request.sh

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Bass\Webclient\Auth\Infra;
use PDO;
class AuthModelFactory
{
private static ?PDO $db = null;
public static function db(): PDO
{
if (self::$db === null) {
self::$db = new PDO('sqlite:' . __DIR__ . '/../../../data/auth.db');
self::$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
self::$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
self::$db->exec('PRAGMA foreign_keys = ON;');
}
return self::$db;
}
}

View File

@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
namespace Bass\Webclient\Auth\Middleware;
use Psr\Http\Message\ServerRequestInterface;
use React\Http\Message\Response;
use Bass\Webclient\Http\ResponseLib;
class RateLimitMiddleware
{
private int $maxRequests;
private int $windowSeconds;
private const API_KEY_REGEX = '/^[a-f0-9]{48}$/';
public function __construct(int $maxRequests = 30, int $windowSeconds = 60)
{
$this->maxRequests = $maxRequests;
$this->windowSeconds = $windowSeconds;
}
public function __invoke(ServerRequestInterface $request, callable $next): Response
{
$apiKey = $request->getHeaderLine('X-API-KEY');
if (!$apiKey) {
return ResponseLib::sendFail(
'Missing API key',
401,
['code' => 'MISSING_API_KEY']
);
}
// 🔒 FORMATO INVÁLIDO → DROP IMEDIATO
if (!preg_match(self::API_KEY_REGEX, $apiKey)) {
return ResponseLib::sendFail(
'Invalid API key format',
401,
['code' => 'INVALID_API_KEY_FORMAT']
);
}
// 🔒 rate-limit SOMENTE para chave válida
if (!$this->allowRequest($apiKey)) {
return ResponseLib::sendFail(
'Rate limit exceeded',
429,
[
'code' => 'RATE_LIMIT_EXCEEDED',
'retry_after' => $this->windowSeconds
]
);
}
return $next($request);
}
/**
* Controle de rate-limit por filesystem
*/
private function allowRequest(string $key): bool
{
$now = time();
$file = sys_get_temp_dir() . '/rl_' . sha1($key);
$data = [
'count' => 0,
'reset' => $now + $this->windowSeconds
];
if (file_exists($file)) {
$stored = json_decode(file_get_contents($file), true);
if (is_array($stored)) {
$data = $stored;
}
}
if ($now > $data['reset']) {
$data = [
'count' => 0,
'reset' => $now + $this->windowSeconds
];
}
$data['count']++;
file_put_contents($file, json_encode($data), LOCK_EX);
return $data['count'] <= $this->maxRequests;
}
}

View File

@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace Bass\Webclient\Auth\Models;
use Bass\Webclient\Auth\Infra\AuthModelFactory;
class ApiKeyModel
{
public function findActiveByKey(string $apiKey): array
{
$sql = "
SELECT
ak.api_key,
ak.api_secret,
ak.status,
u.user_id,
u.username
FROM api_keys ak
JOIN users u ON u.user_id = ak.user_id
WHERE ak.api_key = :api_key
AND ak.status = 'active'
AND u.status = 'active'
LIMIT 1
";
try {
$stmt = AuthModelFactory::db()->prepare($sql);
$stmt->execute(['api_key' => $apiKey]);
$row = $stmt->fetch();
if (!$row) {
return [
false,
[
'code' => 'API_KEY_NOT_FOUND',
'message' => 'Invalid API key'
]
];
}
return [true, $row];
} catch (\Throwable $e) {
return [
false,
[
'code' => 'AUTH_DB_ERROR',
'message' => 'Failed to query auth database'
]
];
}
}
public function touchLastUsed(string $apiKey): void
{
$sql = "UPDATE api_keys SET last_used_at = datetime('now') WHERE api_key = :api_key";
try {
AuthModelFactory::db()
->prepare($sql)
->execute(['api_key' => $apiKey]);
} catch (\Throwable $e) {
// falha aqui NÃO bloqueia request
}
}
}

View File

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Bass\Webclient\Controllers;
use Bass\Webclient\Libs\RequestLib;
use Bass\Webclient\Http\ResponseLib;
use Psr\Http\Message\ServerRequestInterface;
use React\Http\Message\Response;
class RequestController
{
public function __invoke(ServerRequestInterface $request): Response

View File

@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace Bass\Webclient\Domain\Client;
use Bass\Webclient\Infra\ModelFactory;
use PDO;
class ClientModel
{
private PDO $db;
public function __construct()
{
$this->db = ModelFactory::db();
}
public function insert(array $data): array
{
$sql = "
INSERT INTO client_request
(name, phone, email, company_name, sector, number_of_employees, revenue, description)
VALUES
(:name, :phone, :email, :company_name, :sector, :number_of_employees, :revenue, :description)
";
try {
$stmt = $this->db->prepare($sql);
$stmt->execute($data);
return [
true,
[
'client_id' => (int) $this->db->lastInsertId()
]
];
} catch (\Throwable $e) {
return [
false,
[
'code' => 'DB_ERROR',
'message' => 'Failed to insert client'
]
];
}
}
}

View File

@ -2,11 +2,9 @@
declare(strict_types=1);
namespace Bass\Webclient\Libs;
use Bass\Webclient\Domain\Client\ClientModel;
use Bass\Webclient\Model\LeadModel;
use Bass\Webclient\Schema\LeadSchema;
use React\Http\Message\Response;
use Bass\Webclient\Http\ResponseLib;
use Bass\Webclient\Schema\ClientCreateSchema;
class RequestLib
{
@ -36,74 +34,54 @@ class RequestLib
);
}
$schema = ClientCreateSchema::schema();
$schema = LeadSchema::schema();
[$ok, $err] = GuardLib::requireJsonObject($data);
if (!$ok) {
return ResponseLib::sendFail(
$err['message'],
400,
$err
);
return ResponseLib::sendFail($err['message'], 400, $err);
}
[$ok, $err] = GuardLib::maxPayloadFields($data);
if (!$ok) {
return ResponseLib::sendFail(
$err['message'],
400,
$err
);
return ResponseLib::sendFail($err['message'], 400, $err);
}
[$ok, $err] = GuardLib::allowOnlyFields($data, array_keys($schema));
if (!$ok) {
return ResponseLib::sendFail(
$err['message'],
400,
$err
);
return ResponseLib::sendFail($err['message'], 400, $err);
}
[$ok, $err] = GuardLib::blockDangerousPatterns($data);
if (!$ok) {
return ResponseLib::sendFail(
$err['message'],
400,
$err
);
return ResponseLib::sendFail($err['message'], 400, $err);
}
[$ok, $err] = GuardLib::requiredBySchema($data, $schema);
if (!$ok) {
return ResponseLib::sendFail(
$err['message'],
422,
$err
);
return ResponseLib::sendFail($err['message'], 422, $err);
}
[$ok, $err] = GuardLib::validateBySchema($data, $schema);
if (!$ok) {
return ResponseLib::sendFail(
$err['message'],
422,
$err
);
return ResponseLib::sendFail($err['message'], 422, $err);
}
[, $data] = SanitizationLib::cleanBySchema($data, $schema);
[$ok, $result] = (new ClientModel())->insert($data);
[$ok, $result] = (new LeadModel())->insert($data);
if (!$ok) {
return ResponseLib::sendFail(
'Internal server error',
500,
[
'code' => 'DATABASE_ERROR'
]
['code' => 'DATABASE_ERROR']
);
}
self::sendEmails($data);
return ResponseLib::sendOk(
[
'client_id' => $result['client_id'],
'lead_id' => $result['lead_id'],
'status' => 'received'
],
201

View File

@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace Bass\Webclient\Http;
namespace Bass\Webclient\libs;
use React\Http\Message\Response;

View File

@ -1,10 +1,10 @@
<?php
declare(strict_types=1);
namespace Bass\Webclient\Auth\Middleware;
namespace Bass\Webclient\Middleware;
use Bass\Webclient\Http\ResponseLib;
use Bass\Webclient\Auth\Models\ApiKeyModel;
use Bass\Webclient\Libs\ResponseLib;
use Bass\Webclient\Model\ApiKeyModel;
use Psr\Http\Message\ServerRequestInterface;
use React\Http\Message\Response;
@ -29,7 +29,7 @@ class HmacAuthMiddleware
);
}
// 🔒 FORMATO DA API KEY (fail fast)
// 🔒 API key format (fail fast)
if (!preg_match(self::API_KEY_REGEX, $apiKey)) {
return ResponseLib::sendFail(
'Invalid API key format',
@ -38,6 +38,7 @@ class HmacAuthMiddleware
);
}
// ⏱ replay protection (5 min window)
if (abs(time() - (int) $timestamp) > 300) {
return ResponseLib::sendFail(
'Expired request',
@ -66,8 +67,6 @@ class HmacAuthMiddleware
);
}
(new ApiKeyModel())->touchLastUsed($apiKey);
return $next(
$request->withAttribute('auth', [
'user_id' => $result['user_id'],

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Bass\Webclient\Middleware;
use Bass\Webclient\Libs\ResponseLib;
use Psr\Http\Message\ServerRequestInterface;
use React\Http\Message\Response;
class RateLimitMiddleware
{
private int $maxRequests;
private int $windowSeconds;
private array $limits = [];
public function __construct(int $maxRequests = 30, int $windowSeconds = 60)
{
$this->maxRequests = $maxRequests;
$this->windowSeconds = $windowSeconds;
}
public function __invoke(
ServerRequestInterface $request,
callable $next
): Response {
$apiKey = $request->getHeaderLine('X-API-KEY');
if (!$apiKey) {
return ResponseLib::sendFail(
'Missing API key',
401,
['code' => 'MISSING_API_KEY']
);
}
// 🔒 rate-limit por API key (em memória)
if (!$this->allowRequest($apiKey)) {
return ResponseLib::sendFail(
'Rate limit exceeded',
429,
[
'code' => 'RATE_LIMIT_EXCEEDED',
'retry_after' => $this->windowSeconds
]
);
}
return $next($request);
}
/**
* In-memory rate-limit control
* (per process, per API key)
*/
private function allowRequest(string $key): bool
{
$now = time();
if (!isset($this->limits[$key]) || $now > $this->limits[$key]['reset']) {
$this->limits[$key] = [
'count' => 0,
'reset' => $now + $this->windowSeconds
];
}
$this->limits[$key]['count']++;
return $this->limits[$key]['count'] <= $this->maxRequests;
}
}

51
src/Model/ApiKeyModel.php Normal file
View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Bass\Webclient\Model;
use Bass\Webclient\Model\ModelFactory;
class ApiKeyModel
{
public function findActiveByKey(string $apiKey): array
{
$sql = "
SELECT
user_api_key AS api_key,
user_api_secret AS api_secret,
user_status AS status,
user_id,
user_name AS username
FROM users
WHERE user_api_key = :api_key
AND user_status = 1
LIMIT 1
";
try {
$stmt = ModelFactory::db()->prepare($sql);
$stmt->execute(['api_key' => $apiKey]);
$row = $stmt->fetch();
if (!$row) {
return [
false,
[
'code' => 'API_KEY_NOT_FOUND',
'message' => 'Invalid API key'
]
];
}
return [true, $row];
} catch (\Throwable $e) {
return [
false,
[
'code' => 'AUTH_DB_ERROR',
'message' => 'Failed to query auth database'
]
];
}
}
}

66
src/Model/LeadModel.php Normal file
View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Bass\Webclient\Model;
use PDO;
use Bass\Webclient\Model\ModelFactory;
// TODO: ADD TAXID TO BE LIMIT ONE REQUEST PER VALID USER ID
class LeadModel
{
private PDO $db;
public function __construct()
{
$this->db = ModelFactory::db();
}
public function insert(array $data): array
{
$sql = "
INSERT INTO lead
(
lead_name,
lead_phone,
lead_email,
lead_company,
lead_sector,
lead_employees,
lead_revenue,
lead_description
)
VALUES
(
:name,
:phone,
:email,
:company_name,
:sector,
:number_of_employees,
:revenue,
:description
)
";
try {
$stmt = $this->db->prepare($sql);
$stmt->execute($data);
return [
true,
[
'lead_id' => (int) $this->db->lastInsertId()
]
];
} catch (\Throwable $e) {
return [
false,
[
'code' => 'DB_ERROR',
'message' => 'Failed to insert lead'
]
];
}
}
}

View File

@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace Bass\Webclient\Infra;
namespace Bass\Webclient\Model;
use PDO;
@ -9,7 +9,7 @@ class ModelFactory
{
public static function db(): PDO
{
$db = new PDO('sqlite:' . __DIR__ . '/../../data/app.db');
$db = new PDO('sqlite:' . __DIR__ . '/../../data/data.db');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$db->exec('PRAGMA foreign_keys = ON;');

View File

@ -3,7 +3,7 @@ declare(strict_types=1);
namespace Bass\Webclient\Schema;
class ClientCreateSchema
class LeadSchema
{
public static function schema(): array
{
@ -33,7 +33,7 @@ class ClientCreateSchema
'max' => 60,
],
'number_of_employees' => [
'required' => false,
'required' => true,
'type' => 'int',
'min' => 1,
],

View File

@ -0,0 +1 @@
<!-- TODO: add a validation for cnpj or cpf -->

View File

@ -1,39 +0,0 @@
PRAGMA foreign_keys=ON;
CREATE TABLE
users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
display_name TEXT,
email TEXT,
status TEXT NOT NULL DEFAULT 'active', -- active | disabled
created_at TEXT NOT NULL DEFAULT (datetime ('now'))
);
CREATE TABLE
api_keys (
api_key_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
api_key TEXT NOT NULL UNIQUE,
api_secret TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', -- active | revoked
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime ('now')),
FOREIGN KEY (user_id) REFERENCES users (user_id)
);
CREATE TABLE
access_channels (
channel_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
channel TEXT NOT NULL,
identifier TEXT, -- chat_id, phone, webhook_id
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime ('now')),
FOREIGN KEY (user_id) REFERENCES users (user_id)
);
INSERT INTO
users (username, display_name, status)
VALUES
('system', 'System User', 'active');

View File

@ -1,13 +1,30 @@
PRAGMA foreign_keys=ON;
-- TODO: ADD TAXID TO BE LIMIT ONE REQUEST PER VALID USER ID
CREATE TABLE
client_request (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT,
email TEXT NOT NULL,
company_name TEXT NOT NULL,
sector TEXT NOT NULL,
number_of_employees INTEGER NOT NULL,
revenue TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_name TEXT NOT NULL UNIQUE,
user_source TEXT NOT NULL, -- web | api | telegram | whatsapp | system
user_api_key TEXT UNIQUE,
user_api_secret TEXT,
user_status INTEGER NOT NULL DEFAULT 1, -- 1 = active | 0 = disabled
user_flag TEXT, -- free text: admin, system, bot, etc
user_created_at INTEGER NOT NULL -- timestamp ms
);
CREATE TABLE
lead (
lead_id INTEGER PRIMARY KEY AUTOINCREMENT,
lead_source INTEGER,
lead_name TEXT NOT NULL,
lead_phone TEXT,
lead_email TEXT NOT NULL,
lead_company TEXT NOT NULL,
lead_sector TEXT NOT NULL,
lead_employees INTEGER NOT NULL,
lead_revenue TEXT NOT NULL,
lead_description TEXT,
lead_created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (lead_source) REFERENCES users (user_id) ON DELETE SET NULL
);

View File

@ -4,9 +4,9 @@ set -euo pipefail
# ========= CONFIG =========
URL="http://127.0.0.1:8080/v1/request"
API_KEY="test_api_key"
API_SECRET="test_api_secret"
API_USER="test_user"
API_KEY="4677fb5d0258618550bdceacaf0acf39e5a38de18855bb65"
API_SECRET="5177347e4209991841d6721d733c831caf3752424941a5c9d3560dccb5e40711df34f9df7a9dda667a26be21a7e699ac"
API_USER="my-client"
# ========= PAYLOAD =========
BODY='{