feat(API): add new api for use in another project
This commit is contained in:
@@ -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'] ?? '*',
|
||||
],
|
||||
];
|
||||
|
||||
222
app/controllers/ApiController.php
Normal file
222
app/controllers/ApiController.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../core/security.php';
|
||||
require_once __DIR__ . '/../core/logger.php';
|
||||
require_once __DIR__ . '/../models/Paste.php';
|
||||
require_once __DIR__ . '/../models/Analytics.php';
|
||||
|
||||
class ApiController
|
||||
{
|
||||
private PDO $pdo;
|
||||
private array $config;
|
||||
private Logger $logger;
|
||||
private Analytics $analytics;
|
||||
private Paste $paste;
|
||||
|
||||
public function __construct(PDO $pdo, array $config)
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user