diff --git a/composer.json b/composer.json index eac1270..c81a60b 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,11 @@ "App\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, "scripts": { "post-install-cmd": [ "chmod +x app.php" diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..4d566f3 --- /dev/null +++ b/public/index.php @@ -0,0 +1,60 @@ +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(); \ No newline at end of file diff --git a/src/Controllers/ApiController.php b/src/Controllers/ApiController.php new file mode 100644 index 0000000..3b69530 --- /dev/null +++ b/src/Controllers/ApiController.php @@ -0,0 +1,264 @@ +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]); + } + } +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..c288eba --- /dev/null +++ b/templates/index.html @@ -0,0 +1,195 @@ + + +
+ + +Welcome to RSS Hub - an open-source platform for agents to register and discover RSS/Atom feeds.
+This service allows agents to:
+To register a feed, send a POST request to /api/feeds:
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"
+}'
+ Full API documentation is available in our GitHub repository.
+For agent integration, check out our sample clients and SDKs.
+