Compare commits
No commits in common. "44adffb713babfca59d9d15b61b4bc8705b16c8e" and "0164a0a03568ec8dbcc86de15e71c4b05564a5e5" have entirely different histories.
44adffb713
...
0164a0a035
173
bin/support-cli
173
bin/support-cli
|
|
@ -5,10 +5,10 @@ const Database = require("better-sqlite3");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
|
|
||||||
const DB_PATH = path.resolve(__dirname, "../data/data.db");
|
|
||||||
|
|
||||||
function callDb(fn) {
|
function callDb(fn) {
|
||||||
const db = new Database(DB_PATH, { fileMustExist: true });
|
const db = new Database(path.resolve(__dirname, "../data/auth.db"), {
|
||||||
|
fileMustExist: true,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
fn(db);
|
fn(db);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -20,119 +20,117 @@ function gen(bytes) {
|
||||||
return crypto.randomBytes(bytes).toString("hex");
|
return crypto.randomBytes(bytes).toString("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* adduser <user_name> <source>
|
|
||||||
*/
|
|
||||||
function addUser(args) {
|
function addUser(args) {
|
||||||
const [userName, source] = args;
|
const [username, email, status, displayName] = args;
|
||||||
|
|
||||||
if (!userName || !source) {
|
|
||||||
console.error("usage: adduser <user_name> <source>");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
callDb((db) => {
|
callDb((db) => {
|
||||||
const res = db
|
const res = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO users (
|
INSERT INTO users (username, email, status, display_name)
|
||||||
user_name,
|
VALUES (?, ?, ?, ?)
|
||||||
user_source,
|
`,
|
||||||
user_status,
|
|
||||||
user_created_at
|
|
||||||
)
|
|
||||||
VALUES (?, ?, 1, ?)
|
|
||||||
`,
|
|
||||||
)
|
)
|
||||||
.run(userName, source, Date.now());
|
.run(username, email, status, displayName);
|
||||||
|
|
||||||
console.log("user_id:", res.lastInsertRowid);
|
console.log("user_id:", res.lastInsertRowid);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function disUser(args) {
|
||||||
* genkey <user_name>
|
const [username] = args;
|
||||||
*/
|
|
||||||
function genKey(args) {
|
|
||||||
const [userName] = args;
|
|
||||||
|
|
||||||
callDb((db) => {
|
|
||||||
const user = db
|
|
||||||
.prepare(`SELECT user_id FROM users WHERE user_name = ?`)
|
|
||||||
.get(userName);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
console.error("user not found");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = gen(24); // 48 hex chars
|
|
||||||
const apiSecret = gen(48); // 96 hex chars
|
|
||||||
|
|
||||||
db.prepare(
|
|
||||||
`
|
|
||||||
UPDATE users
|
|
||||||
SET user_api_key = ?, user_api_secret = ?
|
|
||||||
WHERE user_id = ?
|
|
||||||
`,
|
|
||||||
).run(apiKey, apiSecret, user.user_id);
|
|
||||||
|
|
||||||
console.log("api_key:", apiKey);
|
|
||||||
console.log("api_secret:", apiSecret);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* disable <user_name>
|
|
||||||
*/
|
|
||||||
function disableUser(args) {
|
|
||||||
const [userName] = args;
|
|
||||||
|
|
||||||
callDb((db) => {
|
callDb((db) => {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
UPDATE users SET user_status = 0
|
UPDATE users SET status = 'disabled'
|
||||||
WHERE user_name = ?
|
WHERE username = ?
|
||||||
`,
|
`,
|
||||||
).run(userName);
|
).run(username);
|
||||||
|
|
||||||
console.log("user disabled:", userName);
|
console.log("user disabled:", username);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* list <user_name>
|
|
||||||
*/
|
|
||||||
function listUser(args) {
|
function listUser(args) {
|
||||||
const [userName] = args;
|
const [username] = args;
|
||||||
|
|
||||||
callDb((db) => {
|
callDb((db) => {
|
||||||
const user = db
|
const user = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT user_id, username, email, status, display_name, created_at
|
||||||
user_id,
|
FROM users
|
||||||
user_name,
|
WHERE username = ?
|
||||||
user_source,
|
`,
|
||||||
user_status,
|
|
||||||
user_api_key,
|
|
||||||
user_created_at
|
|
||||||
FROM users
|
|
||||||
WHERE user_name = ?
|
|
||||||
`,
|
|
||||||
)
|
)
|
||||||
.get(userName);
|
.get(username);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.log("user not found");
|
console.log("user not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("USER");
|
||||||
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);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO api_keys (user_id, api_key, api_secret, status)
|
||||||
|
VALUES (?, ?, ?, 'active')
|
||||||
|
`,
|
||||||
|
).run(user.user_id, apiKey, apiSecret);
|
||||||
|
|
||||||
|
console.log("api_key:", apiKey);
|
||||||
|
console.log("api_secret:", apiSecret);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function disKey(args) {
|
||||||
|
const [apiKey] = args;
|
||||||
|
callDb((db) => {
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
UPDATE api_keys SET status = 'revoked'
|
||||||
|
WHERE api_key = ?
|
||||||
|
`,
|
||||||
|
).run(apiKey);
|
||||||
|
|
||||||
|
console.log("api_key revoked");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const argv = process.argv.slice(2);
|
const argv = process.argv.slice(2);
|
||||||
const cmd = argv[0];
|
const cmd = argv[0];
|
||||||
|
|
@ -143,20 +141,23 @@ switch (cmd) {
|
||||||
addUser(args);
|
addUser(args);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "genkey":
|
case "disuser":
|
||||||
genKey(args);
|
disUser(args);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "disable":
|
case "listuser":
|
||||||
disableUser(args);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "list":
|
|
||||||
listUser(args);
|
listUser(args);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "addkey":
|
||||||
|
addKey(args);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "diskey":
|
||||||
|
disKey(args);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.error("Unknown command");
|
console.error("Unknown command");
|
||||||
console.error("Commands: adduser | genkey | disable | list");
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||||
require __DIR__ . '/../vendor/autoload.php';
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
use FrameworkX\App;
|
use FrameworkX\App;
|
||||||
use Bass\Webclient\Middleware\HmacAuthMiddleware;
|
use Bass\Webclient\Auth\Middleware\HmacAuthMiddleware;
|
||||||
use Bass\Webclient\Controllers\RequestController;
|
use Bass\Webclient\Controllers\RequestController;
|
||||||
use Bass\Webclient\Libs\ResponseLib;
|
use Bass\Webclient\Http\ResponseLib;
|
||||||
|
|
||||||
ini_set('display_errors', '1');
|
ini_set('display_errors', '1');
|
||||||
ini_set('display_startup_errors', '1');
|
ini_set('display_startup_errors', '1');
|
||||||
|
|
@ -16,12 +16,14 @@ $app = new App();
|
||||||
|
|
||||||
$hmacAuth = new HmacAuthMiddleware();
|
$hmacAuth = new HmacAuthMiddleware();
|
||||||
|
|
||||||
|
|
||||||
$app->post(
|
$app->post(
|
||||||
'/v1/request',
|
'/v1/request',
|
||||||
$hmacAuth,
|
$hmacAuth,
|
||||||
RequestController::class
|
RequestController::class
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
$app->get(
|
$app->get(
|
||||||
'/health',
|
'/health',
|
||||||
fn() => ResponseLib::sendOk(['status' => 'ok'])
|
fn() => ResponseLib::sendOk(['status' => 'ok'])
|
||||||
|
|
|
||||||
31
readme.md
31
readme.md
|
|
@ -1,31 +0,0 @@
|
||||||
├── 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
|
|
||||||
22
src/Auth/Infra/AuthModelFactory.php
Normal file
22
src/Auth/Infra/AuthModelFactory.php
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Bass\Webclient\Middleware;
|
namespace Bass\Webclient\Auth\Middleware;
|
||||||
|
|
||||||
use Bass\Webclient\Libs\ResponseLib;
|
use Bass\Webclient\Http\ResponseLib;
|
||||||
use Bass\Webclient\Model\ApiKeyModel;
|
use Bass\Webclient\Auth\Models\ApiKeyModel;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use React\Http\Message\Response;
|
use React\Http\Message\Response;
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ class HmacAuthMiddleware
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔒 API key format (fail fast)
|
// 🔒 FORMATO DA API KEY (fail fast)
|
||||||
if (!preg_match(self::API_KEY_REGEX, $apiKey)) {
|
if (!preg_match(self::API_KEY_REGEX, $apiKey)) {
|
||||||
return ResponseLib::sendFail(
|
return ResponseLib::sendFail(
|
||||||
'Invalid API key format',
|
'Invalid API key format',
|
||||||
|
|
@ -38,7 +38,6 @@ class HmacAuthMiddleware
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⏱ replay protection (5 min window)
|
|
||||||
if (abs(time() - (int) $timestamp) > 300) {
|
if (abs(time() - (int) $timestamp) > 300) {
|
||||||
return ResponseLib::sendFail(
|
return ResponseLib::sendFail(
|
||||||
'Expired request',
|
'Expired request',
|
||||||
|
|
@ -67,6 +66,8 @@ class HmacAuthMiddleware
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(new ApiKeyModel())->touchLastUsed($apiKey);
|
||||||
|
|
||||||
return $next(
|
return $next(
|
||||||
$request->withAttribute('auth', [
|
$request->withAttribute('auth', [
|
||||||
'user_id' => $result['user_id'],
|
'user_id' => $result['user_id'],
|
||||||
92
src/Auth/Middleware/RateLImitMiddleware.php
Normal file
92
src/Auth/Middleware/RateLImitMiddleware.php
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/Auth/Models/ApiKeyModel.php
Normal file
65
src/Auth/Models/ApiKeyModel.php
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<?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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||||
namespace Bass\Webclient\Controllers;
|
namespace Bass\Webclient\Controllers;
|
||||||
|
|
||||||
use Bass\Webclient\Libs\RequestLib;
|
use Bass\Webclient\Libs\RequestLib;
|
||||||
|
use Bass\Webclient\Http\ResponseLib;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use React\Http\Message\Response;
|
use React\Http\Message\Response;
|
||||||
|
|
||||||
class RequestController
|
class RequestController
|
||||||
{
|
{
|
||||||
public function __invoke(ServerRequestInterface $request): Response
|
public function __invoke(ServerRequestInterface $request): Response
|
||||||
|
|
|
||||||
45
src/Domain/Client/ClientModel.php
Normal file
45
src/Domain/Client/ClientModel.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?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'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Bass\Webclient\libs;
|
namespace Bass\Webclient\Http;
|
||||||
|
|
||||||
use React\Http\Message\Response;
|
use React\Http\Message\Response;
|
||||||
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Bass\Webclient\Model;
|
namespace Bass\Webclient\Infra;
|
||||||
|
|
||||||
use PDO;
|
use PDO;
|
||||||
|
|
||||||
|
|
@ -9,7 +9,7 @@ class ModelFactory
|
||||||
{
|
{
|
||||||
public static function db(): PDO
|
public static function db(): PDO
|
||||||
{
|
{
|
||||||
$db = new PDO('sqlite:' . __DIR__ . '/../../data/data.db');
|
$db = new PDO('sqlite:' . __DIR__ . '/../../data/app.db');
|
||||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
$db->exec('PRAGMA foreign_keys = ON;');
|
$db->exec('PRAGMA foreign_keys = ON;');
|
||||||
|
|
@ -2,9 +2,11 @@
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Bass\Webclient\Libs;
|
namespace Bass\Webclient\Libs;
|
||||||
use Bass\Webclient\Model\LeadModel;
|
|
||||||
use Bass\Webclient\Schema\LeadSchema;
|
use Bass\Webclient\Domain\Client\ClientModel;
|
||||||
use React\Http\Message\Response;
|
use React\Http\Message\Response;
|
||||||
|
use Bass\Webclient\Http\ResponseLib;
|
||||||
|
use Bass\Webclient\Schema\ClientCreateSchema;
|
||||||
|
|
||||||
class RequestLib
|
class RequestLib
|
||||||
{
|
{
|
||||||
|
|
@ -34,54 +36,74 @@ class RequestLib
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$schema = LeadSchema::schema();
|
$schema = ClientCreateSchema::schema();
|
||||||
|
|
||||||
[$ok, $err] = GuardLib::requireJsonObject($data);
|
[$ok, $err] = GuardLib::requireJsonObject($data);
|
||||||
if (!$ok) {
|
if (!$ok) {
|
||||||
return ResponseLib::sendFail($err['message'], 400, $err);
|
return ResponseLib::sendFail(
|
||||||
|
$err['message'],
|
||||||
|
400,
|
||||||
|
$err
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[$ok, $err] = GuardLib::maxPayloadFields($data);
|
[$ok, $err] = GuardLib::maxPayloadFields($data);
|
||||||
if (!$ok) {
|
if (!$ok) {
|
||||||
return ResponseLib::sendFail($err['message'], 400, $err);
|
return ResponseLib::sendFail(
|
||||||
|
$err['message'],
|
||||||
|
400,
|
||||||
|
$err
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[$ok, $err] = GuardLib::allowOnlyFields($data, array_keys($schema));
|
[$ok, $err] = GuardLib::allowOnlyFields($data, array_keys($schema));
|
||||||
if (!$ok) {
|
if (!$ok) {
|
||||||
return ResponseLib::sendFail($err['message'], 400, $err);
|
return ResponseLib::sendFail(
|
||||||
|
$err['message'],
|
||||||
|
400,
|
||||||
|
$err
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[$ok, $err] = GuardLib::blockDangerousPatterns($data);
|
[$ok, $err] = GuardLib::blockDangerousPatterns($data);
|
||||||
if (!$ok) {
|
if (!$ok) {
|
||||||
return ResponseLib::sendFail($err['message'], 400, $err);
|
return ResponseLib::sendFail(
|
||||||
|
$err['message'],
|
||||||
|
400,
|
||||||
|
$err
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[$ok, $err] = GuardLib::requiredBySchema($data, $schema);
|
[$ok, $err] = GuardLib::requiredBySchema($data, $schema);
|
||||||
if (!$ok) {
|
if (!$ok) {
|
||||||
return ResponseLib::sendFail($err['message'], 422, $err);
|
return ResponseLib::sendFail(
|
||||||
|
$err['message'],
|
||||||
|
422,
|
||||||
|
$err
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[$ok, $err] = GuardLib::validateBySchema($data, $schema);
|
[$ok, $err] = GuardLib::validateBySchema($data, $schema);
|
||||||
if (!$ok) {
|
if (!$ok) {
|
||||||
return ResponseLib::sendFail($err['message'], 422, $err);
|
return ResponseLib::sendFail(
|
||||||
|
$err['message'],
|
||||||
|
422,
|
||||||
|
$err
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[, $data] = SanitizationLib::cleanBySchema($data, $schema);
|
[, $data] = SanitizationLib::cleanBySchema($data, $schema);
|
||||||
|
[$ok, $result] = (new ClientModel())->insert($data);
|
||||||
[$ok, $result] = (new LeadModel())->insert($data);
|
|
||||||
if (!$ok) {
|
if (!$ok) {
|
||||||
return ResponseLib::sendFail(
|
return ResponseLib::sendFail(
|
||||||
'Internal server error',
|
'Internal server error',
|
||||||
500,
|
500,
|
||||||
['code' => 'DATABASE_ERROR']
|
[
|
||||||
|
'code' => 'DATABASE_ERROR'
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
self::sendEmails($data);
|
self::sendEmails($data);
|
||||||
|
|
||||||
return ResponseLib::sendOk(
|
return ResponseLib::sendOk(
|
||||||
[
|
[
|
||||||
'lead_id' => $result['lead_id'],
|
'client_id' => $result['client_id'],
|
||||||
'status' => 'received'
|
'status' => 'received'
|
||||||
],
|
],
|
||||||
201
|
201
|
||||||
|
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
<?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'
|
|
||||||
]
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
<?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'
|
|
||||||
]
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Bass\Webclient\Schema;
|
namespace Bass\Webclient\Schema;
|
||||||
|
|
||||||
class LeadSchema
|
class ClientCreateSchema
|
||||||
{
|
{
|
||||||
public static function schema(): array
|
public static function schema(): array
|
||||||
{
|
{
|
||||||
|
|
@ -33,7 +33,7 @@ class LeadSchema
|
||||||
'max' => 60,
|
'max' => 60,
|
||||||
],
|
],
|
||||||
'number_of_employees' => [
|
'number_of_employees' => [
|
||||||
'required' => true,
|
'required' => false,
|
||||||
'type' => 'int',
|
'type' => 'int',
|
||||||
'min' => 1,
|
'min' => 1,
|
||||||
],
|
],
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
<!-- TODO: add a validation for cnpj or cpf -->
|
|
||||||
39
src/migration/auth_schema.sql
Normal file
39
src/migration/auth_schema.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
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');
|
||||||
|
|
@ -1,30 +1,13 @@
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
|
|
||||||
-- TODO: ADD TAXID TO BE LIMIT ONE REQUEST PER VALID USER ID
|
|
||||||
CREATE TABLE
|
CREATE TABLE
|
||||||
users (
|
client_request (
|
||||||
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL,
|
||||||
user_source TEXT NOT NULL, -- web | api | telegram | whatsapp | system
|
phone TEXT,
|
||||||
user_api_key TEXT UNIQUE,
|
email TEXT NOT NULL,
|
||||||
user_api_secret TEXT,
|
company_name TEXT NOT NULL,
|
||||||
user_status INTEGER NOT NULL DEFAULT 1, -- 1 = active | 0 = disabled
|
sector TEXT NOT NULL,
|
||||||
user_flag TEXT, -- free text: admin, system, bot, etc
|
number_of_employees INTEGER NOT NULL,
|
||||||
user_created_at INTEGER NOT NULL -- timestamp ms
|
revenue TEXT NOT NULL,
|
||||||
);
|
description TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
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
|
|
||||||
);
|
);
|
||||||
|
|
@ -4,9 +4,9 @@ set -euo pipefail
|
||||||
# ========= CONFIG =========
|
# ========= CONFIG =========
|
||||||
URL="http://127.0.0.1:8080/v1/request"
|
URL="http://127.0.0.1:8080/v1/request"
|
||||||
|
|
||||||
API_KEY="4677fb5d0258618550bdceacaf0acf39e5a38de18855bb65"
|
API_KEY="test_api_key"
|
||||||
API_SECRET="5177347e4209991841d6721d733c831caf3752424941a5c9d3560dccb5e40711df34f9df7a9dda667a26be21a7e699ac"
|
API_SECRET="test_api_secret"
|
||||||
API_USER="my-client"
|
API_USER="test_user"
|
||||||
|
|
||||||
# ========= PAYLOAD =========
|
# ========= PAYLOAD =========
|
||||||
BODY='{
|
BODY='{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user