From d5639f05fcd31e49e20cbd1ed3fbe5e0afdcb589 Mon Sep 17 00:00:00 2001 From: soheil khaledabadi Date: Fri, 10 Jul 2026 01:47:01 +0330 Subject: [PATCH] feat(API): add new api for use in another project --- .env.example | 1 + app/config/config.php | 1 + app/controllers/ApiController.php | 222 ++++++++++++++++++++++++++++++ public/.htaccess | 5 + public/api.php | 10 ++ public/router.php | 23 ++++ 6 files changed, 262 insertions(+) create mode 100644 app/controllers/ApiController.php create mode 100644 public/api.php diff --git a/.env.example b/.env.example index 0700f5e..35b2a9a 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,4 @@ DB_PASS= REDIS_HOST=127.0.0.1 REDIS_PORT=6379 APP_URL= +API_CORS_ORIGIN=* diff --git a/app/config/config.php b/app/config/config.php index 20f0d84..2b8042d 100644 --- a/app/config/config.php +++ b/app/config/config.php @@ -18,5 +18,6 @@ return [ 'master_key' => $env['MASTER_KEY'] ?? 'change_me_master_key', 'base_url' => $env['APP_URL'] ?? '', 'cipher' => 'AES-256-CBC', + 'cors_origin' => $env['API_CORS_ORIGIN'] ?? '*', ], ]; diff --git a/app/controllers/ApiController.php b/app/controllers/ApiController.php new file mode 100644 index 0000000..985de9c --- /dev/null +++ b/app/controllers/ApiController.php @@ -0,0 +1,222 @@ +pdo = $pdo; + $this->config = $config; + $this->logger = new Logger('api'); + $this->analytics = new Analytics($pdo); + $this->paste = new Paste($pdo); + } + + public function handle(string $method, string $action, ?string $id): void + { + $this->sendCorsHeaders(); + + if ($method === 'OPTIONS') { + http_response_code(204); + exit; + } + + if ($action === 'create' && $method === 'POST') { + $this->create(); + return; + } + + if ($action === 'get' && $method === 'GET' && $id !== null) { + $this->get($id); + return; + } + + if ($action === 'unlock' && $method === 'POST' && $id !== null) { + $this->unlock($id); + return; + } + + $this->jsonResponse(['success' => false, 'message' => 'Not found.'], 404); + } + + private function create(): void + { + $body = $this->readBody(); + + $text = trim((string)($body['text'] ?? '')); + $password = (string)($body['password'] ?? ''); + $expire = isset($body['expire']) ? (int)$body['expire'] : 0; + + if ($text === '') { + $this->analytics->record('paste_validation_failed', null, ['reason' => 'empty_text', 'source' => 'api']); + $this->jsonResponse(['success' => false, 'message' => 'Text cannot be empty.'], 422); + } + + $id = generateId(); + $enc = encryptText($text, $this->config['app']['master_key']); + $password_hash = $password !== '' ? password_hash($password, PASSWORD_DEFAULT) : null; + $expire_time = $expire > 0 ? time() + $expire : null; + + $saved = $this->paste->save($id, $enc['cipher'], $enc['iv'], $expire_time, $password_hash); + + if (!$saved) { + $this->logger->error('API failed to save paste', ['id' => $id]); + $this->analytics->record('paste_save_failed', $id, ['source' => 'api']); + $this->jsonResponse(['success' => false, 'message' => 'Failed to save paste. Please try again.'], 500); + } + + $charCount = mb_strlen($text, 'UTF-8'); + $this->logger->info('Paste created via API', [ + 'id' => $id, + 'char_count' => $charCount, + 'has_password' => $password_hash !== null, + 'expires_in' => $expire > 0 ? "{$expire}s" : 'never', + ]); + + $this->analytics->record('paste_created', $id, [ + 'char_count' => $charCount, + 'has_password' => $password_hash !== null, + 'expire_secs' => $expire > 0 ? $expire : null, + 'storage' => $expire_time !== null ? 'redis' : 'mysql', + 'source' => 'api', + ]); + + $base = rtrim( + $this->config['app']['base_url'] ?: ('http' . (isset($_SERVER['HTTPS']) ? 's' : '') . '://' . $_SERVER['HTTP_HOST']), + '/' + ); + + $this->jsonResponse([ + 'success' => true, + 'id' => $id, + 'url' => $base . '/view/' . $id, + 'has_password' => $password_hash !== null, + 'expires_at' => $expire_time, + ], 201); + } + + private function get(string $id): void + { + $id = preg_replace('/[^a-f0-9]/i', '', $id); + + $data = $id !== '' ? $this->paste->get($id) : false; + + if (!$data) { + $this->analytics->record('paste_not_found', $id ?: null, ['source' => 'api']); + $this->jsonResponse(['success' => false, 'message' => 'Paste not found.'], 404); + } + + if ($data['expire_time'] !== null && time() > (int)$data['expire_time']) { + $this->analytics->record('paste_expired', $id, ['expired_at' => $data['expire_time'], 'source' => 'api']); + $this->jsonResponse(['success' => false, 'message' => 'This paste has expired.'], 410); + } + + if ($data['password_hash']) { + $this->analytics->record('paste_password_prompt', $id, ['source' => 'api']); + $this->jsonResponse([ + 'success' => true, + 'needs_password' => true, + 'expires_at' => $data['expire_time'] !== null ? (int)$data['expire_time'] : null, + ]); + } + + $decrypted = decryptText($data['encrypted_text'], $data['iv'], $this->config['app']['master_key']); + if ($decrypted === false) { + $this->logger->error('API decryption failed for paste', ['id' => $id]); + $this->analytics->record('paste_decrypt_error', $id, ['source' => 'api']); + $this->jsonResponse(['success' => false, 'message' => 'Decryption failed. The paste may be corrupted.'], 500); + } + + $this->analytics->record('paste_viewed', $id, [ + 'char_count' => mb_strlen($decrypted, 'UTF-8'), + 'has_expiry' => $data['expire_time'] !== null, + 'source' => 'api', + ]); + + $this->jsonResponse([ + 'success' => true, + 'needs_password' => false, + 'text' => $decrypted, + 'expires_at' => $data['expire_time'] !== null ? (int)$data['expire_time'] : null, + ]); + } + + private function unlock(string $id): void + { + $id = preg_replace('/[^a-f0-9]/i', '', $id); + $body = $this->readBody(); + + $data = $id !== '' ? $this->paste->get($id) : false; + + if (!$data) { + $this->jsonResponse(['success' => false, 'message' => 'Paste not found.'], 404); + } + + if ($data['expire_time'] !== null && time() > (int)$data['expire_time']) { + $this->jsonResponse(['success' => false, 'message' => 'This paste has expired.'], 410); + } + + if (!$data['password_hash']) { + $this->jsonResponse(['success' => false, 'message' => 'This paste is not password protected.'], 400); + } + + $submitted = (string)($body['password'] ?? ''); + if (!password_verify($submitted, $data['password_hash'])) { + $this->analytics->record('paste_failed_password', $id, ['source' => 'api']); + $this->jsonResponse(['success' => false, 'message' => 'Incorrect password.'], 401); + } + + $decrypted = decryptText($data['encrypted_text'], $data['iv'], $this->config['app']['master_key']); + if ($decrypted === false) { + $this->logger->error('API decryption failed for paste', ['id' => $id]); + $this->analytics->record('paste_decrypt_error', $id, ['source' => 'api']); + $this->jsonResponse(['success' => false, 'message' => 'Decryption failed. The paste may be corrupted.'], 500); + } + + $this->analytics->record('paste_unlocked', $id, ['source' => 'api']); + + $this->jsonResponse([ + 'success' => true, + 'text' => $decrypted, + 'expires_at' => $data['expire_time'] !== null ? (int)$data['expire_time'] : null, + ]); + } + + private function readBody(): array + { + $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; + if (stripos($contentType, 'application/json') !== false) { + $raw = file_get_contents('php://input'); + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : []; + } + return $_POST; + } + + private function sendCorsHeaders(): void + { + $allowedOrigin = $this->config['app']['cors_origin'] ?? '*'; + header("Access-Control-Allow-Origin: {$allowedOrigin}"); + header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type'); + header('Access-Control-Max-Age: 86400'); + } + + private function jsonResponse(array $data, int $status = 200): void + { + http_response_code($status); + header('Content-Type: application/json'); + echo json_encode($data); + exit; + } +} diff --git a/public/.htaccess b/public/.htaccess index f678b92..e5678de 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -8,5 +8,10 @@ RewriteCond %{REQUEST_METHOD} GET [OR] RewriteCond %{REQUEST_METHOD} POST RewriteRule ^view/([a-f0-9]+)/?$ view.php?id=$1 [L,QSA] +# REST API routes +RewriteRule ^api/paste/([a-f0-9]+)/unlock/?$ api.php?id=$1&action=unlock [L,QSA] +RewriteRule ^api/paste/([a-f0-9]+)/?$ api.php?id=$1&action=get [L,QSA] +RewriteRule ^api/paste/?$ api.php?action=create [L,QSA] + # Block direct access to .env and sensitive files RewriteRule ^(\.env|\.git) - [F,L] diff --git a/public/api.php b/public/api.php new file mode 100644 index 0000000..94dccb6 --- /dev/null +++ b/public/api.php @@ -0,0 +1,10 @@ +handle($_SERVER['REQUEST_METHOD'], $action, $id); diff --git a/public/router.php b/public/router.php index 7fc9ee5..0977bca 100644 --- a/public/router.php +++ b/public/router.php @@ -12,6 +12,29 @@ if (preg_match('#^/view/([a-f0-9]+)$#i', $path, $m)) { exit; } +// Route: /api/paste/{id}/unlock +if (preg_match('#^/api/paste/([a-f0-9]+)/unlock$#i', $path, $m) && $_SERVER['REQUEST_METHOD'] === 'POST') { + $_GET['id'] = $m[1]; + $_GET['action'] = 'unlock'; + require __DIR__ . '/api.php'; + exit; +} + +// Route: /api/paste/{id} +if (preg_match('#^/api/paste/([a-f0-9]+)$#i', $path, $m)) { + $_GET['id'] = $m[1]; + $_GET['action'] = 'get'; + require __DIR__ . '/api.php'; + exit; +} + +// Route: /api/paste +if ($path === '/api/paste') { + $_GET['action'] = 'create'; + require __DIR__ . '/api.php'; + exit; +} + // Route: static assets if (preg_match('#\.(css|js|png|jpg|ico|svg|woff2?)$#', $path)) { return false; // let PHP built-in server serve the file