feat(API): add new api for use in another project

This commit is contained in:
2026-07-10 01:47:01 +03:30
parent a6a414c2f5
commit d5639f05fc
6 changed files with 262 additions and 0 deletions

View File

@@ -6,3 +6,4 @@ DB_PASS=
REDIS_HOST=127.0.0.1 REDIS_HOST=127.0.0.1
REDIS_PORT=6379 REDIS_PORT=6379
APP_URL= APP_URL=
API_CORS_ORIGIN=*

View File

@@ -18,5 +18,6 @@ return [
'master_key' => $env['MASTER_KEY'] ?? 'change_me_master_key', 'master_key' => $env['MASTER_KEY'] ?? 'change_me_master_key',
'base_url' => $env['APP_URL'] ?? '', 'base_url' => $env['APP_URL'] ?? '',
'cipher' => 'AES-256-CBC', 'cipher' => 'AES-256-CBC',
'cors_origin' => $env['API_CORS_ORIGIN'] ?? '*',
], ],
]; ];

View 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;
}
}

View File

@@ -8,5 +8,10 @@ RewriteCond %{REQUEST_METHOD} GET [OR]
RewriteCond %{REQUEST_METHOD} POST RewriteCond %{REQUEST_METHOD} POST
RewriteRule ^view/([a-f0-9]+)/?$ view.php?id=$1 [L,QSA] 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 # Block direct access to .env and sensitive files
RewriteRule ^(\.env|\.git) - [F,L] RewriteRule ^(\.env|\.git) - [F,L]

10
public/api.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
require_once __DIR__ . '/../app/controllers/ApiController.php';
$pdo = require __DIR__ . '/../app/core/db.php';
$config = require __DIR__ . '/../app/config/config.php';
$action = $_GET['action'] ?? '';
$id = $_GET['id'] ?? null;
$api = new ApiController($pdo, $config);
$api->handle($_SERVER['REQUEST_METHOD'], $action, $id);

View File

@@ -12,6 +12,29 @@ if (preg_match('#^/view/([a-f0-9]+)$#i', $path, $m)) {
exit; 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 // Route: static assets
if (preg_match('#\.(css|js|png|jpg|ico|svg|woff2?)$#', $path)) { if (preg_match('#\.(css|js|png|jpg|ico|svg|woff2?)$#', $path)) {
return false; // let PHP built-in server serve the file return false; // let PHP built-in server serve the file