Add Slim Framework core structure, API controllers and terminal-style UI
This commit is contained in:
parent
e2685aedae
commit
52820c7be7
|
|
@ -27,6 +27,11 @@
|
|||
"App\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"post-install-cmd": [
|
||||
"chmod +x app.php"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue