first commit
This commit is contained in:
commit
0164a0a035
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
vendor/
|
||||||
|
data/
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
composer.lock
|
||||||
|
logs/
|
||||||
18
bin/email-cli
Normal file
18
bin/email-cli
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
[$script, $to, $subject, $data] = $argv;
|
||||||
|
|
||||||
|
$data = json_decode($data, true);
|
||||||
|
|
||||||
|
echo "Sending email to {$to}\n";
|
||||||
|
echo "Subject: {$subject}\n";
|
||||||
|
echo "Body:\n";
|
||||||
|
print_r($data);
|
||||||
|
|
||||||
|
/*
|
||||||
|
Aqui você pode:
|
||||||
|
- Usar sendmail
|
||||||
|
- SMTP
|
||||||
|
- API (SES, Sendgrid, Mailgun)
|
||||||
|
*/
|
||||||
163
bin/support-cli
Executable file
163
bin/support-cli
Executable file
|
|
@ -0,0 +1,163 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const Database = require("better-sqlite3");
|
||||||
|
const path = require("path");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
|
function callDb(fn) {
|
||||||
|
const db = new Database(path.resolve(__dirname, "../data/auth.db"), {
|
||||||
|
fileMustExist: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
fn(db);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gen(bytes) {
|
||||||
|
return crypto.randomBytes(bytes).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUser(args) {
|
||||||
|
const [username, email, status, displayName] = args;
|
||||||
|
|
||||||
|
callDb((db) => {
|
||||||
|
const res = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO users (username, email, status, display_name)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.run(username, email, status, displayName);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
callDb((db) => {
|
||||||
|
const user = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT user_id, username, email, status, display_name, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE username = ?
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.get(username);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log("user not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 cmd = argv[0];
|
||||||
|
const args = argv.slice(1);
|
||||||
|
|
||||||
|
switch (cmd) {
|
||||||
|
case "adduser":
|
||||||
|
addUser(args);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "disuser":
|
||||||
|
disUser(args);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "listuser":
|
||||||
|
listUser(args);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "addkey":
|
||||||
|
addKey(args);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "diskey":
|
||||||
|
disKey(args);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.error("Unknown command");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
14
composer.json
Normal file
14
composer.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Bass\\Webclient\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Bass Webclient backend",
|
||||||
|
"name": "bass/webclient",
|
||||||
|
"require": {
|
||||||
|
"clue/framework-x": "^4.0",
|
||||||
|
"php": "^8.1"
|
||||||
|
},
|
||||||
|
"type": "project"
|
||||||
|
}
|
||||||
19
package.json
Normal file
19
package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "basspago_backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"directories": {
|
||||||
|
"test": "test"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
|
"commander": "^14.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
public/index.php
Normal file
32
public/index.php
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use FrameworkX\App;
|
||||||
|
use Bass\Webclient\Auth\Middleware\HmacAuthMiddleware;
|
||||||
|
use Bass\Webclient\Controllers\RequestController;
|
||||||
|
use Bass\Webclient\Http\ResponseLib;
|
||||||
|
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
ini_set('display_startup_errors', '1');
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
$app = new App();
|
||||||
|
|
||||||
|
$hmacAuth = new HmacAuthMiddleware();
|
||||||
|
|
||||||
|
|
||||||
|
$app->post(
|
||||||
|
'/v1/request',
|
||||||
|
$hmacAuth,
|
||||||
|
RequestController::class
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
$app->get(
|
||||||
|
'/health',
|
||||||
|
fn() => ResponseLib::sendOk(['status' => 'ok'])
|
||||||
|
);
|
||||||
|
|
||||||
|
$app->run();
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/Auth/Middleware/HmacAuthMiddleware.php
Normal file
80
src/Auth/Middleware/HmacAuthMiddleware.php
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Bass\Webclient\Auth\Middleware;
|
||||||
|
|
||||||
|
use Bass\Webclient\Http\ResponseLib;
|
||||||
|
use Bass\Webclient\Auth\Models\ApiKeyModel;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use React\Http\Message\Response;
|
||||||
|
|
||||||
|
class HmacAuthMiddleware
|
||||||
|
{
|
||||||
|
private const API_KEY_REGEX = '/^[a-f0-9]{48}$/';
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
callable $next
|
||||||
|
): Response {
|
||||||
|
$apiKey = $request->getHeaderLine('X-API-KEY');
|
||||||
|
$user = $request->getHeaderLine('X-API-USER');
|
||||||
|
$timestamp = $request->getHeaderLine('X-API-TIMESTAMP');
|
||||||
|
$signature = $request->getHeaderLine('X-API-SIGNATURE');
|
||||||
|
|
||||||
|
if (!$apiKey || !$user || !$timestamp || !$signature) {
|
||||||
|
return ResponseLib::sendFail(
|
||||||
|
'Missing auth headers',
|
||||||
|
401,
|
||||||
|
['code' => 'MISSING_AUTH_HEADERS']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔒 FORMATO DA API KEY (fail fast)
|
||||||
|
if (!preg_match(self::API_KEY_REGEX, $apiKey)) {
|
||||||
|
return ResponseLib::sendFail(
|
||||||
|
'Invalid API key format',
|
||||||
|
401,
|
||||||
|
['code' => 'INVALID_API_KEY_FORMAT']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abs(time() - (int) $timestamp) > 300) {
|
||||||
|
return ResponseLib::sendFail(
|
||||||
|
'Expired request',
|
||||||
|
401,
|
||||||
|
['code' => 'EXPIRED_REQUEST']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$ok, $result] = (new ApiKeyModel())->findActiveByKey($apiKey);
|
||||||
|
if (!$ok) {
|
||||||
|
return ResponseLib::sendFail(
|
||||||
|
$result['message'] ?? 'Invalid API key',
|
||||||
|
401,
|
||||||
|
['code' => 'INVALID_API_KEY']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = "{$apiKey}:{$timestamp}:{$user}";
|
||||||
|
$expected = hash_hmac('sha256', $payload, $result['api_secret']);
|
||||||
|
|
||||||
|
if (!hash_equals($expected, $signature)) {
|
||||||
|
return ResponseLib::sendFail(
|
||||||
|
'Invalid signature',
|
||||||
|
401,
|
||||||
|
['code' => 'INVALID_SIGNATURE']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
(new ApiKeyModel())->touchLastUsed($apiKey);
|
||||||
|
|
||||||
|
return $next(
|
||||||
|
$request->withAttribute('auth', [
|
||||||
|
'user_id' => $result['user_id'],
|
||||||
|
'username' => $result['username'],
|
||||||
|
'api_key' => $apiKey,
|
||||||
|
'channel' => 'api'
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/Controllers/RequestController.php
Normal file
18
src/Controllers/RequestController.php
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
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
|
||||||
|
{
|
||||||
|
return RequestLib::handleClientRequest(
|
||||||
|
(string) $request->getBody()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/Http/ResponseLib.php
Normal file
59
src/Http/ResponseLib.php
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Bass\Webclient\Http;
|
||||||
|
|
||||||
|
use React\Http\Message\Response;
|
||||||
|
|
||||||
|
class ResponseLib
|
||||||
|
{
|
||||||
|
public static function sendOk(array $data = [], int $status = 200): Response
|
||||||
|
{
|
||||||
|
return self::json(
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data
|
||||||
|
],
|
||||||
|
$status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function sendFail(
|
||||||
|
string $message,
|
||||||
|
int $status = 400,
|
||||||
|
array $details = []
|
||||||
|
): Response {
|
||||||
|
$data = ['message' => $message];
|
||||||
|
if (!empty($details)) {
|
||||||
|
$data['details'] = $details;
|
||||||
|
}
|
||||||
|
return self::json(
|
||||||
|
[
|
||||||
|
'success' => false,
|
||||||
|
'data' => $data
|
||||||
|
],
|
||||||
|
$status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static function json(array $payload, int $status): Response
|
||||||
|
{
|
||||||
|
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
if ($json === false) {
|
||||||
|
// fallback extremo: nunca quebrar a API
|
||||||
|
$json = '{"success":false,"error":"Response encoding error"}';
|
||||||
|
$status = 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
$status,
|
||||||
|
[
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'Cache-Control' => 'no-store'
|
||||||
|
],
|
||||||
|
$json
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Infra/ModelFactory.php
Normal file
21
src/Infra/ModelFactory.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Bass\Webclient\Infra;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
class ModelFactory
|
||||||
|
{
|
||||||
|
public static function db(): PDO
|
||||||
|
{
|
||||||
|
$db = new PDO('sqlite:' . __DIR__ . '/../../data/app.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;');
|
||||||
|
$db->exec('PRAGMA journal_mode = WAL;');
|
||||||
|
$db->exec('PRAGMA synchronous = NORMAL;');
|
||||||
|
|
||||||
|
return $db;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/Libs/ExecLib.php
Normal file
40
src/Libs/ExecLib.php
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Bass\Webclient\Libs;
|
||||||
|
|
||||||
|
class ExecLib
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{0:bool,1:array}
|
||||||
|
*/
|
||||||
|
public static function run(
|
||||||
|
string $bin,
|
||||||
|
string $to,
|
||||||
|
string $subject,
|
||||||
|
string $data
|
||||||
|
): array {
|
||||||
|
$cmd = sprintf(
|
||||||
|
"%s %s %s %s",
|
||||||
|
escapeshellcmd($bin),
|
||||||
|
escapeshellarg($to),
|
||||||
|
escapeshellarg($subject),
|
||||||
|
escapeshellarg($data)
|
||||||
|
);
|
||||||
|
|
||||||
|
exec($cmd, $output, $exitCode);
|
||||||
|
|
||||||
|
if ($exitCode !== 0) {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'code' => 'EXEC_FAILED',
|
||||||
|
'message' => 'Command execution failed',
|
||||||
|
'bin' => $bin,
|
||||||
|
'status' => $exitCode
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [true, []];
|
||||||
|
}
|
||||||
|
}
|
||||||
237
src/Libs/GuardLib.php
Normal file
237
src/Libs/GuardLib.php
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Bass\Webclient\Libs;
|
||||||
|
|
||||||
|
class GuardLib
|
||||||
|
{
|
||||||
|
public static function requireJsonObject(mixed $data): array
|
||||||
|
{
|
||||||
|
// JSON precisa ser um objeto (array associativo)
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'code' => 'INVALID_JSON',
|
||||||
|
'message' => 'JSON payload must be an object'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bloqueia array indexado []
|
||||||
|
if (array_is_list($data)) {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'code' => 'INVALID_JSON_STRUCTURE',
|
||||||
|
'message' => 'JSON payload must be an object, not a list'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payload vazio {}
|
||||||
|
if ($data === []) {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'code' => 'EMPTY_PAYLOAD',
|
||||||
|
'message' => 'JSON payload cannot be empty'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [true, []];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function validateClientRequest(array $data): array
|
||||||
|
{
|
||||||
|
foreach (['name', 'email', 'company_name', 'sector', 'revenue'] as $field) {
|
||||||
|
if (!isset($data[$field]) || trim((string) $data[$field]) === '') {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'code' => 'MISSING_FIELD',
|
||||||
|
'field' => $field,
|
||||||
|
'message' => "Field {$field} is required"
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filter_var((string) $data['email'], FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'code' => 'INVALID_EMAIL',
|
||||||
|
'message' => 'Invalid email'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($data['number_of_employees'] ?? 0) < 1) {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'code' => 'INVALID_EMPLOYEES',
|
||||||
|
'message' => 'Invalid number_of_employees'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [true, []];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function allowOnlyFields(array $data, array $allowed): array
|
||||||
|
{
|
||||||
|
$extra = array_diff(array_keys($data), $allowed);
|
||||||
|
|
||||||
|
if (!empty($extra)) {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'code' => 'UNEXPECTED_FIELDS',
|
||||||
|
'fields' => array_values($extra),
|
||||||
|
'message' => 'Payload contains unexpected fields'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [true, []];
|
||||||
|
}
|
||||||
|
public static function maxPayloadFields(array $data, int $limit = 15): array
|
||||||
|
{
|
||||||
|
if (count($data) > $limit) {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'code' => 'PAYLOAD_TOO_LARGE',
|
||||||
|
'message' => 'Too many fields in payload'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [true, []];
|
||||||
|
}
|
||||||
|
public static function blockDangerousPatterns(array $data): array
|
||||||
|
{
|
||||||
|
$patterns = [
|
||||||
|
'/\$\(/', // command substitution
|
||||||
|
'/`/', // backticks
|
||||||
|
'/\|/', // pipe
|
||||||
|
'/&&|\|\|/', // logical chaining
|
||||||
|
'/--/', // SQL comment
|
||||||
|
'/;/' // statement break
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if (!is_scalar($value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($patterns as $pattern) {
|
||||||
|
if (preg_match($pattern, (string) $value)) {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'code' => 'DANGEROUS_INPUT',
|
||||||
|
'field' => $key,
|
||||||
|
'message' => 'Suspicious input detected'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [true, []];
|
||||||
|
}
|
||||||
|
public static function requiredBySchema(array $data, array $schema): array
|
||||||
|
{
|
||||||
|
foreach ($schema as $field => $rules) {
|
||||||
|
if (($rules['required'] ?? false) === true) {
|
||||||
|
if (!isset($data[$field]) || trim((string) $data[$field]) === '') {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'code' => 'MISSING_FIELD',
|
||||||
|
'field' => $field,
|
||||||
|
'message' => "Field {$field} is required"
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [true, []];
|
||||||
|
}
|
||||||
|
public static function validateBySchema(array $data, array $schema): array
|
||||||
|
{
|
||||||
|
foreach ($schema as $field => $rules) {
|
||||||
|
if (!isset($data[$field])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $data[$field];
|
||||||
|
|
||||||
|
switch ($rules['type']) {
|
||||||
|
case 'string':
|
||||||
|
if (!is_scalar($value)) {
|
||||||
|
return self::typeError($field);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($rules['max']) && mb_strlen((string) $value) > $rules['max']) {
|
||||||
|
return self::limitError($field);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'int':
|
||||||
|
if (!is_numeric($value)) {
|
||||||
|
return self::typeError($field);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($rules['min']) && (int) $value < $rules['min']) {
|
||||||
|
return self::limitError($field);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'email':
|
||||||
|
if (!filter_var((string) $value, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'code' => 'INVALID_EMAIL',
|
||||||
|
'field' => $field,
|
||||||
|
'message' => 'Invalid email'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [true, []];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function typeError(string $field): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'code' => 'INVALID_TYPE',
|
||||||
|
'field' => $field,
|
||||||
|
'message' => "Invalid type for {$field}"
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function limitError(string $field): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'code' => 'INVALID_VALUE',
|
||||||
|
'field' => $field,
|
||||||
|
'message' => "Invalid value for {$field}"
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
132
src/Libs/RequestLib.php
Normal file
132
src/Libs/RequestLib.php
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Bass\Webclient\Libs;
|
||||||
|
|
||||||
|
use Bass\Webclient\Domain\Client\ClientModel;
|
||||||
|
use React\Http\Message\Response;
|
||||||
|
use Bass\Webclient\Http\ResponseLib;
|
||||||
|
use Bass\Webclient\Schema\ClientCreateSchema;
|
||||||
|
|
||||||
|
class RequestLib
|
||||||
|
{
|
||||||
|
public static function handleClientRequest(string $raw): Response
|
||||||
|
{
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
|
||||||
|
if ($data === null && json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
return ResponseLib::sendFail(
|
||||||
|
'Invalid JSON payload',
|
||||||
|
400,
|
||||||
|
[
|
||||||
|
'code' => 'INVALID_JSON',
|
||||||
|
'message' => json_last_error_msg()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return ResponseLib::sendFail(
|
||||||
|
'JSON body must be an object',
|
||||||
|
400,
|
||||||
|
[
|
||||||
|
'code' => 'INVALID_JSON_TYPE',
|
||||||
|
'expected' => 'object'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$schema = ClientCreateSchema::schema();
|
||||||
|
|
||||||
|
[$ok, $err] = GuardLib::requireJsonObject($data);
|
||||||
|
if (!$ok) {
|
||||||
|
return ResponseLib::sendFail(
|
||||||
|
$err['message'],
|
||||||
|
400,
|
||||||
|
$err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$ok, $err] = GuardLib::maxPayloadFields($data);
|
||||||
|
if (!$ok) {
|
||||||
|
return ResponseLib::sendFail(
|
||||||
|
$err['message'],
|
||||||
|
400,
|
||||||
|
$err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$ok, $err] = GuardLib::allowOnlyFields($data, array_keys($schema));
|
||||||
|
if (!$ok) {
|
||||||
|
return ResponseLib::sendFail(
|
||||||
|
$err['message'],
|
||||||
|
400,
|
||||||
|
$err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$ok, $err] = GuardLib::blockDangerousPatterns($data);
|
||||||
|
if (!$ok) {
|
||||||
|
return ResponseLib::sendFail(
|
||||||
|
$err['message'],
|
||||||
|
400,
|
||||||
|
$err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[$ok, $err] = GuardLib::requiredBySchema($data, $schema);
|
||||||
|
if (!$ok) {
|
||||||
|
return ResponseLib::sendFail(
|
||||||
|
$err['message'],
|
||||||
|
422,
|
||||||
|
$err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[$ok, $err] = GuardLib::validateBySchema($data, $schema);
|
||||||
|
if (!$ok) {
|
||||||
|
return ResponseLib::sendFail(
|
||||||
|
$err['message'],
|
||||||
|
422,
|
||||||
|
$err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[, $data] = SanitizationLib::cleanBySchema($data, $schema);
|
||||||
|
[$ok, $result] = (new ClientModel())->insert($data);
|
||||||
|
if (!$ok) {
|
||||||
|
return ResponseLib::sendFail(
|
||||||
|
'Internal server error',
|
||||||
|
500,
|
||||||
|
[
|
||||||
|
'code' => 'DATABASE_ERROR'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self::sendEmails($data);
|
||||||
|
return ResponseLib::sendOk(
|
||||||
|
[
|
||||||
|
'client_id' => $result['client_id'],
|
||||||
|
'status' => 'received'
|
||||||
|
],
|
||||||
|
201
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sendEmails(array $data): void
|
||||||
|
{
|
||||||
|
ExecLib::run(
|
||||||
|
'bin/email-cli',
|
||||||
|
'sales@empresa.com',
|
||||||
|
'Novo lead recebido',
|
||||||
|
json_encode($data)
|
||||||
|
);
|
||||||
|
|
||||||
|
ExecLib::run(
|
||||||
|
'bin/email-cli',
|
||||||
|
$data['email'],
|
||||||
|
'Recebemos sua solicitação',
|
||||||
|
json_encode([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'company' => $data['company_name']
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/Libs/SanitizationLib.php
Normal file
66
src/Libs/SanitizationLib.php
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Bass\Webclient\Libs;
|
||||||
|
|
||||||
|
class SanitizationLib
|
||||||
|
{
|
||||||
|
public static function clean(array $data): array
|
||||||
|
{
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if (is_object($value)) {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'code' => 'INVALID_FIELD_TYPE',
|
||||||
|
'field' => $key,
|
||||||
|
'message' => 'Object values are not allowed'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
true,
|
||||||
|
[
|
||||||
|
'name' => trim((string) ($data['name'] ?? '')),
|
||||||
|
'phone' => trim((string) ($data['phone'] ?? '')),
|
||||||
|
'email' => strtolower(trim((string) ($data['email'] ?? ''))),
|
||||||
|
'company_name' => trim((string) ($data['company_name'] ?? '')),
|
||||||
|
'sector' => trim((string) ($data['sector'] ?? '')),
|
||||||
|
'number_of_employees' => (int) ($data['number_of_employees'] ?? 0),
|
||||||
|
'revenue' => trim((string) ($data['revenue'] ?? '')),
|
||||||
|
'description' => trim((string) ($data['description'] ?? '')),
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
public static function cleanBySchema(array $data, array $schema): array
|
||||||
|
{
|
||||||
|
$clean = [];
|
||||||
|
|
||||||
|
foreach ($schema as $field => $rules) {
|
||||||
|
if (!isset($data[$field])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $data[$field];
|
||||||
|
|
||||||
|
switch ($rules['type']) {
|
||||||
|
case 'string':
|
||||||
|
case 'email':
|
||||||
|
$clean[$field] = trim((string) $value);
|
||||||
|
if ($rules['type'] === 'email') {
|
||||||
|
$clean[$field] = strtolower($clean[$field]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'int':
|
||||||
|
$clean[$field] = (int) $value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [true, $clean];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
51
src/Schema/ClientCreateSchema.php
Normal file
51
src/Schema/ClientCreateSchema.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Bass\Webclient\Schema;
|
||||||
|
|
||||||
|
class ClientCreateSchema
|
||||||
|
{
|
||||||
|
public static function schema(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => [
|
||||||
|
'required' => true,
|
||||||
|
'type' => 'string',
|
||||||
|
'max' => 100,
|
||||||
|
],
|
||||||
|
'phone' => [
|
||||||
|
'required' => false,
|
||||||
|
'type' => 'string',
|
||||||
|
'max' => 20,
|
||||||
|
],
|
||||||
|
'email' => [
|
||||||
|
'required' => true,
|
||||||
|
'type' => 'email',
|
||||||
|
],
|
||||||
|
'company_name' => [
|
||||||
|
'required' => true,
|
||||||
|
'type' => 'string',
|
||||||
|
'max' => 120,
|
||||||
|
],
|
||||||
|
'sector' => [
|
||||||
|
'required' => true,
|
||||||
|
'type' => 'string',
|
||||||
|
'max' => 60,
|
||||||
|
],
|
||||||
|
'number_of_employees' => [
|
||||||
|
'required' => false,
|
||||||
|
'type' => 'int',
|
||||||
|
'min' => 1,
|
||||||
|
],
|
||||||
|
'revenue' => [
|
||||||
|
'required' => true,
|
||||||
|
'type' => 'string',
|
||||||
|
],
|
||||||
|
'description' => [
|
||||||
|
'required' => false,
|
||||||
|
'type' => 'string',
|
||||||
|
'max' => 500,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
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');
|
||||||
13
src/migration/schema.sql
Normal file
13
src/migration/schema.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
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
|
||||||
|
);
|
||||||
29
test/hmac_curl.sh
Executable file
29
test/hmac_curl.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ========= CONFIG PADRÃO =========
|
||||||
|
URL="${URL:-http://127.0.0.1:8080/v1/request}"
|
||||||
|
API_KEY="${API_KEY:-test_api_key}"
|
||||||
|
API_SECRET="${API_SECRET:-test_api_secret}"
|
||||||
|
API_USER="${API_USER:-test_user}"
|
||||||
|
|
||||||
|
# ========= ARGUMENTOS =========
|
||||||
|
METHOD="${1:-POST}"
|
||||||
|
BODY=$2
|
||||||
|
|
||||||
|
# ========= HMAC =========
|
||||||
|
TIMESTAMP=$(date +%s)
|
||||||
|
PAYLOAD="${API_KEY}:${TIMESTAMP}:${API_USER}"
|
||||||
|
|
||||||
|
SIGNATURE=$(printf "%s" "$PAYLOAD" \
|
||||||
|
| openssl dgst -sha256 -hmac "$API_SECRET" \
|
||||||
|
| sed 's/^.* //')
|
||||||
|
|
||||||
|
# ========= CURL =========
|
||||||
|
curl -i -X "$METHOD" "$URL" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-API-KEY: $API_KEY" \
|
||||||
|
-H "X-API-USER: $API_USER" \
|
||||||
|
-H "X-API-TIMESTAMP: $TIMESTAMP" \
|
||||||
|
-H "X-API-SIGNATURE: $SIGNATURE" \
|
||||||
|
-d "$BODY"
|
||||||
39
test/request.sh
Executable file
39
test/request.sh
Executable file
|
|
@ -0,0 +1,39 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
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"
|
||||||
|
|
||||||
|
# ========= PAYLOAD =========
|
||||||
|
BODY='{
|
||||||
|
"name": "João Silva",
|
||||||
|
"phone": "+55 11 99999-9999",
|
||||||
|
"email": "joao@empresa.com",
|
||||||
|
"company_name": "Empresa LTDA",
|
||||||
|
"sector": "Tecnologia",
|
||||||
|
"number_of_employees": 25,
|
||||||
|
"revenue": "1000000",
|
||||||
|
"description": "Quero integrar a API"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# ========= HMAC =========
|
||||||
|
TIMESTAMP=$(date +%s)
|
||||||
|
PAYLOAD="${API_KEY}:${TIMESTAMP}:${API_USER}"
|
||||||
|
|
||||||
|
SIGNATURE=$(printf "%s" "$PAYLOAD" | \
|
||||||
|
openssl dgst -sha256 -hmac "$API_SECRET" | \
|
||||||
|
sed 's/^.* //')
|
||||||
|
|
||||||
|
|
||||||
|
# ========= REQUEST =========
|
||||||
|
curl -X POST "$URL" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-API-KEY: $API_KEY" \
|
||||||
|
-H "X-API-USER: $API_USER" \
|
||||||
|
-H "X-API-TIMESTAMP: $TIMESTAMP" \
|
||||||
|
-H "X-API-SIGNATURE: $SIGNATURE" \
|
||||||
|
-d "$BODY"
|
||||||
Loading…
Reference in New Issue
Block a user