From 0164a0a03568ec8dbcc86de15e71c4b05564a5e5 Mon Sep 17 00:00:00 2001 From: glopes Date: Thu, 29 Jan 2026 18:28:39 -0300 Subject: [PATCH] first commit --- .gitignore | 6 + bin/email-cli | 18 ++ bin/support-cli | 163 ++++++++++++++ composer.json | 14 ++ package.json | 19 ++ public/index.php | 32 +++ src/Auth/Infra/AuthModelFactory.php | 22 ++ src/Auth/Middleware/HmacAuthMiddleware.php | 80 +++++++ src/Auth/Middleware/RateLImitMiddleware.php | 92 ++++++++ src/Auth/Models/ApiKeyModel.php | 65 ++++++ src/Controllers/RequestController.php | 18 ++ src/Domain/Client/ClientModel.php | 45 ++++ src/Http/ResponseLib.php | 59 +++++ src/Infra/ModelFactory.php | 21 ++ src/Libs/ExecLib.php | 40 ++++ src/Libs/GuardLib.php | 237 ++++++++++++++++++++ src/Libs/RequestLib.php | 132 +++++++++++ src/Libs/SanitizationLib.php | 66 ++++++ src/Schema/ClientCreateSchema.php | 51 +++++ src/migration/auth_schema.sql | 39 ++++ src/migration/schema.sql | 13 ++ test/hmac_curl.sh | 29 +++ test/request.sh | 39 ++++ 23 files changed, 1300 insertions(+) create mode 100644 .gitignore create mode 100644 bin/email-cli create mode 100755 bin/support-cli create mode 100644 composer.json create mode 100644 package.json create mode 100644 public/index.php create mode 100644 src/Auth/Infra/AuthModelFactory.php create mode 100644 src/Auth/Middleware/HmacAuthMiddleware.php create mode 100644 src/Auth/Middleware/RateLImitMiddleware.php create mode 100644 src/Auth/Models/ApiKeyModel.php create mode 100644 src/Controllers/RequestController.php create mode 100644 src/Domain/Client/ClientModel.php create mode 100644 src/Http/ResponseLib.php create mode 100644 src/Infra/ModelFactory.php create mode 100644 src/Libs/ExecLib.php create mode 100644 src/Libs/GuardLib.php create mode 100644 src/Libs/RequestLib.php create mode 100644 src/Libs/SanitizationLib.php create mode 100644 src/Schema/ClientCreateSchema.php create mode 100644 src/migration/auth_schema.sql create mode 100644 src/migration/schema.sql create mode 100755 test/hmac_curl.sh create mode 100755 test/request.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df7f741 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +vendor/ +data/ +node_modules/ +package-lock.json +composer.lock +logs/ diff --git a/bin/email-cli b/bin/email-cli new file mode 100644 index 0000000..5294a20 --- /dev/null +++ b/bin/email-cli @@ -0,0 +1,18 @@ +#!/usr/bin/env php + { + 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); +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..130c488 --- /dev/null +++ b/composer.json @@ -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" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..62edaa9 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..24543f7 --- /dev/null +++ b/public/index.php @@ -0,0 +1,32 @@ +post( + '/v1/request', + $hmacAuth, + RequestController::class +); + + +$app->get( + '/health', + fn() => ResponseLib::sendOk(['status' => 'ok']) +); + +$app->run(); diff --git a/src/Auth/Infra/AuthModelFactory.php b/src/Auth/Infra/AuthModelFactory.php new file mode 100644 index 0000000..741f4a7 --- /dev/null +++ b/src/Auth/Infra/AuthModelFactory.php @@ -0,0 +1,22 @@ +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; + } +} diff --git a/src/Auth/Middleware/HmacAuthMiddleware.php b/src/Auth/Middleware/HmacAuthMiddleware.php new file mode 100644 index 0000000..fe19308 --- /dev/null +++ b/src/Auth/Middleware/HmacAuthMiddleware.php @@ -0,0 +1,80 @@ +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' + ]) + ); + } +} diff --git a/src/Auth/Middleware/RateLImitMiddleware.php b/src/Auth/Middleware/RateLImitMiddleware.php new file mode 100644 index 0000000..eae2f81 --- /dev/null +++ b/src/Auth/Middleware/RateLImitMiddleware.php @@ -0,0 +1,92 @@ +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; + } +} diff --git a/src/Auth/Models/ApiKeyModel.php b/src/Auth/Models/ApiKeyModel.php new file mode 100644 index 0000000..992cca6 --- /dev/null +++ b/src/Auth/Models/ApiKeyModel.php @@ -0,0 +1,65 @@ +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 + } + } +} diff --git a/src/Controllers/RequestController.php b/src/Controllers/RequestController.php new file mode 100644 index 0000000..565d1bb --- /dev/null +++ b/src/Controllers/RequestController.php @@ -0,0 +1,18 @@ +getBody() + ); + } +} diff --git a/src/Domain/Client/ClientModel.php b/src/Domain/Client/ClientModel.php new file mode 100644 index 0000000..53d0b4e --- /dev/null +++ b/src/Domain/Client/ClientModel.php @@ -0,0 +1,45 @@ +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' + ] + ]; + } + } +} diff --git a/src/Http/ResponseLib.php b/src/Http/ResponseLib.php new file mode 100644 index 0000000..5470bd7 --- /dev/null +++ b/src/Http/ResponseLib.php @@ -0,0 +1,59 @@ + 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 + ); + } +} diff --git a/src/Infra/ModelFactory.php b/src/Infra/ModelFactory.php new file mode 100644 index 0000000..cbe06b7 --- /dev/null +++ b/src/Infra/ModelFactory.php @@ -0,0 +1,21 @@ +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; + } +} diff --git a/src/Libs/ExecLib.php b/src/Libs/ExecLib.php new file mode 100644 index 0000000..9739a79 --- /dev/null +++ b/src/Libs/ExecLib.php @@ -0,0 +1,40 @@ + 'EXEC_FAILED', + 'message' => 'Command execution failed', + 'bin' => $bin, + 'status' => $exitCode + ] + ]; + } + return [true, []]; + } +} diff --git a/src/Libs/GuardLib.php b/src/Libs/GuardLib.php new file mode 100644 index 0000000..46ecfa1 --- /dev/null +++ b/src/Libs/GuardLib.php @@ -0,0 +1,237 @@ + '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}" + ] + ]; + } + +} diff --git a/src/Libs/RequestLib.php b/src/Libs/RequestLib.php new file mode 100644 index 0000000..e9a0e83 --- /dev/null +++ b/src/Libs/RequestLib.php @@ -0,0 +1,132 @@ + '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'] + ]) + ); + } +} diff --git a/src/Libs/SanitizationLib.php b/src/Libs/SanitizationLib.php new file mode 100644 index 0000000..75ceaa3 --- /dev/null +++ b/src/Libs/SanitizationLib.php @@ -0,0 +1,66 @@ + $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]; + } + +} diff --git a/src/Schema/ClientCreateSchema.php b/src/Schema/ClientCreateSchema.php new file mode 100644 index 0000000..1c8dd3c --- /dev/null +++ b/src/Schema/ClientCreateSchema.php @@ -0,0 +1,51 @@ + [ + '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, + ], + ]; + } +} diff --git a/src/migration/auth_schema.sql b/src/migration/auth_schema.sql new file mode 100644 index 0000000..74f6b46 --- /dev/null +++ b/src/migration/auth_schema.sql @@ -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'); \ No newline at end of file diff --git a/src/migration/schema.sql b/src/migration/schema.sql new file mode 100644 index 0000000..0bd5cb5 --- /dev/null +++ b/src/migration/schema.sql @@ -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 + ); \ No newline at end of file diff --git a/test/hmac_curl.sh b/test/hmac_curl.sh new file mode 100755 index 0000000..1a3b6c8 --- /dev/null +++ b/test/hmac_curl.sh @@ -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" diff --git a/test/request.sh b/test/request.sh new file mode 100755 index 0000000..f320503 --- /dev/null +++ b/test/request.sh @@ -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"