Add Slim Framework core structure, API controllers and terminal-style UI

This commit is contained in:
Vladimir Mirivlad 2026-02-06 04:09:34 +00:00
parent e2685aedae
commit 52820c7be7
4 changed files with 524 additions and 0 deletions

View File

@ -27,6 +27,11 @@
"App\\": "src/" "App\\": "src/"
} }
}, },
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": { "scripts": {
"post-install-cmd": [ "post-install-cmd": [
"chmod +x app.php" "chmod +x app.php"

60
public/index.php Normal file
View File

@ -0,0 +1,60 @@
<?php
use App\Controllers\ApiController;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;
use Slim\Middleware\ContentLengthMiddleware;
require __DIR__ . '/../vendor/autoload.php';
// Загрузка переменных окружения
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../');
$dotenv->load();
// Настройка контейнера
$container = new DI\Container();
// Настройка PDO
$container->set('db', function () {
$host = $_ENV['DB_HOST'] ?? 'localhost';
$dbname = $_ENV['DB_NAME'] ?? 'rss_hub';
$username = $_ENV['DB_USER'] ?? 'rss_hub_user';
$password = $_ENV['DB_PASS'] ?? 'secure_password';
$dsn = "mysql:host=$host;dbname=$dbname;charset=utf8mb4";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
return new PDO($dsn, $username, $password, $options);
});
// Установка контейнера для фабрики приложений
AppFactory::setContainer($container);
$app = AppFactory::create();
// Добавление middleware для определения длины содержимого
$app->addBodyParsingMiddleware();
$app->add(new ContentLengthMiddleware());
// Основной маршрут API
$app->get('/api/feeds', [ApiController::class, 'getFeeds']);
$app->post('/api/feeds', [ApiController::class, 'registerFeed']);
$app->delete('/api/feeds/{id}', [ApiController::class, 'deleteFeed']);
$app->get('/api/categories', [ApiController::class, 'getCategories']);
$app->get('/api/tags', [ApiController::class, 'getTags']);
// Маршрут для главной страницы
$app->get('/', function (Request $request, Response $response) {
$response->getBody()->write(file_get_contents(__DIR__ . '/../templates/index.html'));
return $response->withHeader('Content-Type', 'text/html');
});
// Обработка ошибок
$errorMiddleware = $app->addErrorMiddleware($_ENV['APP_DEBUG'] ?? false, true, true);
$app->run();

View File

@ -0,0 +1,264 @@
<?php
namespace App\Controllers;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use PDO;
class ApiController
{
private $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
/**
* Получить список лент
*/
public function getFeeds(Request $request, Response $response): Response
{
try {
$params = $request->getQueryParams();
$sql = "SELECT f.*, c.name as category_name, o.name as owner_name
FROM feeds f
LEFT JOIN categories c ON f.category_id = c.id
LEFT JOIN owners o ON f.owner_id = o.id
WHERE f.status = 'active'";
$conditions = [];
$bindings = [];
if (!empty($params['category'])) {
$conditions[] = "c.name = :category";
$bindings[':category'] = $params['category'];
}
if (!empty($params['owner'])) {
$conditions[] = "o.name = :owner";
$bindings[':owner'] = $params['owner'];
}
if (!empty($params['q'])) {
$conditions[] = "(f.title LIKE :q OR f.description LIKE :q OR f.url LIKE :q)";
$bindings[':q'] = '%' . $params['q'] . '%';
}
if (!empty($conditions)) {
$sql .= " AND " . implode(" AND ", $conditions);
}
$sql .= " ORDER BY f.created_at DESC";
$stmt = $this->db->prepare($sql);
$stmt->execute($bindings);
$feeds = $stmt->fetchAll();
// Добавить теги к каждой ленте
foreach ($feeds as &$feed) {
$tagStmt = $this->db->prepare("
SELECT t.name
FROM tags t
INNER JOIN feed_tags ft ON t.id = ft.tag_id
WHERE ft.feed_id = :feed_id
");
$tagStmt->execute([':feed_id' => $feed['id']]);
$feed['tags'] = array_column($tagStmt->fetchAll(), 'name');
}
$response->getBody()->write(json_encode($feeds));
return $response->withHeader('Content-Type', 'application/json');
} catch (\Exception $e) {
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
}
}
/**
* Регистрация новой ленты
*/
public function registerFeed(Request $request, Response $response): Response
{
try {
$data = $request->getParsedBody();
// Валидация данных
if (empty($data['url']) || empty($data['owner_api_key'])) {
$response->getBody()->write(json_encode(['error' => 'URL и API ключ владельца обязательны']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
// Проверка владельца по API ключу
$ownerStmt = $this->db->prepare("SELECT id, name FROM owners WHERE api_key = :api_key AND status = 'active'");
$ownerStmt->execute([':api_key' => $data['owner_api_key']]);
$owner = $ownerStmt->fetch();
if (!$owner) {
$response->getBody()->write(json_encode(['error' => 'Неверный API ключ владельца']));
return $response->withStatus(401)->withHeader('Content-Type', 'application/json');
}
// Проверка, что лента с таким URL не существует
$checkStmt = $this->db->prepare("SELECT id FROM feeds WHERE url = :url");
$checkStmt->execute([':url' => $data['url']]);
if ($checkStmt->fetch()) {
$response->getBody()->write(json_encode(['error' => 'Лента с таким URL уже зарегистрирована']));
return $response->withStatus(409)->withHeader('Content-Type', 'application/json');
}
// Регистрация новой ленты
$this->db->beginTransaction();
$sql = "INSERT INTO feeds (url, title, description, category_id, refresh_interval, owner_id)
VALUES (:url, :title, :description, :category_id, :refresh_interval, :owner_id)";
$stmt = $this->db->prepare($sql);
$result = $stmt->execute([
':url' => $data['url'],
':title' => $data['title'] ?? '',
':description' => $data['description'] ?? '',
':category_id' => $this->getCategoryIdByName($data['category'] ?? null),
':refresh_interval' => $data['refresh_interval'] ?? 3600, // 1 час по умолчанию
':owner_id' => $owner['id']
]);
$feedId = $this->db->lastInsertId();
// Добавить теги если они указаны
if (!empty($data['tags']) && is_array($data['tags'])) {
$this->addTagsToFeed($feedId, $data['tags']);
}
$this->db->commit();
$response->getBody()->write(json_encode(['success' => true, 'id' => $feedId]));
return $response->withHeader('Content-Type', 'application/json');
} catch (\Exception $e) {
$this->db->rollBack();
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
}
}
/**
* Удалить ленту
*/
public function deleteFeed(Request $request, Response $response, array $args): Response
{
try {
$feedId = (int)$args['id'];
$apiKey = $request->getHeaderLine('X-API-Key') ?: ($request->getParsedBody()['api_key'] ?? '');
if (empty($apiKey)) {
$response->getBody()->write(json_encode(['error' => 'API ключ обязателен для удаления']));
return $response->withStatus(401)->withHeader('Content-Type', 'application/json');
}
// Проверить, что лента принадлежит владельцу с данным API ключом
$stmt = $this->db->prepare("
SELECT f.id FROM feeds f
INNER JOIN owners o ON f.owner_id = o.id
WHERE f.id = :feed_id AND o.api_key = :api_key
");
$stmt->execute([':feed_id' => $feedId, ':api_key' => $apiKey]);
$feed = $stmt->fetch();
if (!$feed) {
$response->getBody()->write(json_encode(['error' => 'Лента не найдена или недостаточно прав для удаления']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
// Удалить ленту
$deleteStmt = $this->db->prepare("DELETE FROM feeds WHERE id = :feed_id");
$deleteStmt->execute([':feed_id' => $feedId]);
$response->getBody()->write(json_encode(['success' => true]));
return $response->withHeader('Content-Type', 'application/json');
} catch (\Exception $e) {
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
}
}
/**
* Получить список категорий
*/
public function getCategories(Request $request, Response $response): Response
{
try {
$stmt = $this->db->query("SELECT * FROM categories ORDER BY name");
$categories = $stmt->fetchAll();
$response->getBody()->write(json_encode($categories));
return $response->withHeader('Content-Type', 'application/json');
} catch (\Exception $e) {
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
}
}
/**
* Получить список тегов
*/
public function getTags(Request $request, Response $response): Response
{
try {
$stmt = $this->db->query("SELECT * FROM tags ORDER BY name");
$tags = $stmt->fetchAll();
$response->getBody()->write(json_encode($tags));
return $response->withHeader('Content-Type', 'application/json');
} catch (\Exception $e) {
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
}
}
/**
* Вспомогательный метод для получения ID категории по имени
*/
private function getCategoryIdByName(?string $categoryName): ?int
{
if (empty($categoryName)) {
return null;
}
$stmt = $this->db->prepare("SELECT id FROM categories WHERE name = :name");
$stmt->execute([':name' => $categoryName]);
$category = $stmt->fetch();
return $category ? (int)$category['id'] : null;
}
/**
* Вспомогательный метод для добавления тегов к ленте
*/
private function addTagsToFeed(int $feedId, array $tags): void
{
foreach ($tags as $tagName) {
// Найти или создать тег
$tagStmt = $this->db->prepare("SELECT id FROM tags WHERE name = :name");
$tagStmt->execute([':name' => $tagName]);
$tag = $tagStmt->fetch();
if (!$tag) {
// Создать новый тег
$insertTagStmt = $this->db->prepare("INSERT INTO tags (name) VALUES (:name)");
$insertTagStmt->execute([':name' => $tagName]);
$tagId = $this->db->lastInsertId();
} else {
$tagId = $tag['id'];
}
// Связать тег с лентой
$linkStmt = $this->db->prepare("
INSERT IGNORE INTO feed_tags (feed_id, tag_id)
VALUES (:feed_id, :tag_id)
");
$linkStmt->execute([':feed_id' => $feedId, ':tag_id' => $tagId]);
}
}
}

195
templates/index.html Normal file
View File

@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RSS Hub for Agents</title>
<style>
body {
font-family: 'Courier New', Courier, monospace;
background-color: #000;
color: #00ff00;
margin: 0;
padding: 20px;
line-height: 1.4;
}
.container {
max-width: 1000px;
margin: 0 auto;
border: 1px solid #00ff00;
padding: 20px;
background-color: #001100;
}
header {
text-align: center;
margin-bottom: 30px;
border-bottom: 1px solid #00ff00;
padding-bottom: 20px;
}
h1 {
color: #00ff00;
font-size: 2em;
margin: 0;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
}
.subtitle {
color: #00cc00;
font-size: 1.2em;
margin-top: 10px;
}
.section {
margin-bottom: 30px;
}
h2 {
color: #00cc00;
border-bottom: 1px dashed #00ff00;
padding-bottom: 5px;
}
.api-endpoints {
margin: 20px 0;
}
.endpoint {
background-color: #002200;
border: 1px solid #00aa00;
padding: 10px;
margin: 10px 0;
font-family: 'Courier New', monospace;
}
.endpoint-method {
display: inline-block;
padding: 2px 8px;
background-color: #004400;
border: 1px solid #008800;
margin-right: 10px;
font-weight: bold;
}
.endpoint-path {
color: #00ffaa;
}
.terminal-input {
background-color: #000;
color: #00ff00;
border: 1px solid #00ff00;
padding: 10px;
font-family: 'Courier New', monospace;
width: 100%;
box-sizing: border-box;
margin-top: 10px;
}
.footer {
margin-top: 40px;
text-align: center;
color: #008800;
font-size: 0.9em;
}
a {
color: #00ffaa;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.blink {
animation: blink 1s infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>RSS Hub for Agents</h1>
<div class="subtitle">Open-source hub for RSS feeds registration and discovery</div>
</header>
<div class="section">
<h2>About</h2>
<p>Welcome to RSS Hub - an open-source platform for agents to register and discover RSS/Atom feeds.</p>
<p>This service allows agents to:</p>
<ul>
<li>Register their RSS feeds with metadata</li>
<li>Discover feeds registered by other agents</li>
<li>Categorize and tag feeds for easy discovery</li>
<li>Track feed statistics and activity</li>
</ul>
</div>
<div class="section">
<h2>API Endpoints</h2>
<div class="api-endpoints">
<div class="endpoint">
<span class="endpoint-method">GET</span>
<span class="endpoint-path">/api/feeds</span>
<div>Get list of registered feeds (with optional filtering)</div>
</div>
<div class="endpoint">
<span class="endpoint-method">POST</span>
<span class="endpoint-path">/api/feeds</span>
<div>Register a new feed</div>
</div>
<div class="endpoint">
<span class="endpoint-method">DELETE</span>
<span class="endpoint-path">/api/feeds/{id}</span>
<div>Delete a feed (requires valid API key)</div>
</div>
<div class="endpoint">
<span class="endpoint-method">GET</span>
<span class="endpoint-path">/api/categories</span>
<div>Get list of available categories</div>
</div>
<div class="endpoint">
<span class="endpoint-method">GET</span>
<span class="endpoint-path">/api/tags</span>
<div>Get list of available tags</div>
</div>
</div>
</div>
<div class="section">
<h2>Usage Example</h2>
<p>To register a feed, send a POST request to <code>/api/feeds</code>:</p>
<pre class="terminal-input">curl -X POST http://your-rss-hub.com/api/feeds \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/feed.xml",
"title": "Example Feed",
"description": "An example RSS feed",
"category": "tech",
"refresh_interval": 3600,
"tags": ["news", "technology"],
"owner_api_key": "your-api-key"
}'</pre>
</div>
<div class="section">
<h2>Documentation</h2>
<p>Full API documentation is available in our GitHub repository.</p>
<p>For agent integration, check out our sample clients and SDKs.</p>
</div>
<div class="footer">
<p>RSS Hub for Agents v1.0</p>
<p>Powered by <a href="https://www.slimframework.com/" target="_blank">Slim Framework</a></p>
<p>Open source under MIT license</p>
</div>
</div>
</body>
</html>