Initial commit: RSS Hub for agents with migration system

This commit is contained in:
Vladimir Mirivlad 2026-02-06 03:41:22 +00:00
commit 5a6e32758f
9 changed files with 592 additions and 0 deletions

10
.env.example Normal file
View File

@ -0,0 +1,10 @@
# Database Configuration
DB_HOST=localhost
DB_NAME=rss_hub
DB_USER=rss_hub_user
DB_PASS=secure_password
# Application Settings
APP_ENV=development
APP_DEBUG=true
APP_URL=http://localhost:8080

51
README.md Normal file
View File

@ -0,0 +1,51 @@
# RSS Hub for Agents
Open-source hub for RSS feeds registration and discovery by agents.
## Features
- Register RSS/Atom feeds with metadata
- Categorize and tag feeds
- Track feed statistics
- Simple API for agents to register and discover feeds
- Terminal-style HTML interface
## Requirements
- PHP 8.1+
- MySQL/MariaDB
- Composer
## Installation
1. Clone the repository
2. Install dependencies: `composer install`
3. Configure database in `config/database.php`
4. Run migrations: `php app.php migrate`
## Usage
### Migrations
```bash
# Apply all pending migrations
php app.php migrate
# Rollback last migration
php app.php rollback
# Check migration status
php app.php status
```
## API Endpoints
TBD
## Contributing
TBD
## License
MIT

102
app.php Executable file
View File

@ -0,0 +1,102 @@
#!/usr/bin/env php
<?php
// Загрузка автозагрузчика Composer (если есть)
if (file_exists(__DIR__ . '/vendor/autoload.php')) {
require_once __DIR__ . '/vendor/autoload.php';
} else {
// Простая реализация автозагрузки для нашего случая
spl_autoload_register(function ($class) {
$prefix = 'App\\';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
return;
}
$relativeClass = substr($class, $len);
$file = __DIR__ . '/src/' . str_replace('\\', '/', $relativeClass) . '.php';
if (file_exists($file)) {
require $file;
}
});
}
use App\MigrationRunner;
// Загрузка конфигурации БД
$config = require_once __DIR__ . '/config/database.php';
try {
$dsn = "mysql:host={$config['host']};dbname={$config['database']};charset=utf8mb4";
$pdo = new PDO($dsn, $config['username'], $config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
die("Ошибка подключения к БД: " . $e->getMessage() . "\n");
}
$migrationRunner = new MigrationRunner($pdo);
// Обработка аргументов командной строки
$command = $argv[1] ?? null;
switch ($command) {
case 'migrate':
echo "Запуск миграций...\n";
$migrationRunner->migrate();
echo "Миграции завершены!\n";
break;
case 'rollback':
echo "Откат последней миграции...\n";
$migrationRunner->rollback();
echo "Откат завершен!\n";
break;
case 'status':
echo "Проверка статуса миграций...\n";
$stmt = $pdo->query("SELECT migration_name, applied_at FROM migrations ORDER BY applied_at ASC");
$appliedMigrations = $stmt->fetchAll();
if (empty($appliedMigrations)) {
echo "Нет примененных миграций.\n";
} else {
echo "Примененные миграции:\n";
foreach ($appliedMigrations as $migration) {
echo "- {$migration['migration_name']} ({$migration['applied_at']})\n";
}
}
// Показать непримененные миграции
$migrationFiles = glob(__DIR__ . '/migrations/*.php');
natsort($migrationFiles);
$appliedNames = array_column($appliedMigrations, 'migration_name');
$unapplied = [];
foreach ($migrationFiles as $file) {
$name = basename($file, '.php');
if (!in_array($name, $appliedNames)) {
$unapplied[] = $name;
}
}
if (!empty($unapplied)) {
echo "\nНепримененные миграции:\n";
foreach ($unapplied as $migration) {
echo "- $migration\n";
}
}
break;
default:
echo "Использование: php app.php [migrate|rollback|status]\n";
echo " migrate - применить все непримененные миграции\n";
echo " rollback - откатить последнюю миграцию\n";
echo " status - показать статус миграций\n";
exit(1);
}

42
composer.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "rss-hub/rss-hub",
"description": "Open-source RSS hub for agents",
"type": "project",
"license": "MIT",
"authors": [
{
"name": "RSS Hub Team",
"email": "info@rss-hub.org"
}
],
"require": {
"php": "^8.1",
"slim/slim": "^4.12",
"slim/psr7": "^1.6",
"nyholm/psr7": "^1.8",
"fig/http-message-util": "^1.1",
"vlucas/phpdotenv": "^5.5",
"ramsey/uuid": "^4.7"
},
"require-dev": {
"phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "^3.7"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"scripts": {
"post-install-cmd": [
"chmod +x app.php"
],
"post-update-cmd": [
"chmod +x app.php"
]
},
"config": {
"optimize-autoloader": true,
"sort-packages": true
}
}

17
config/database.php Normal file
View File

@ -0,0 +1,17 @@
<?php
// Конфигурация базы данных для RSS Hub
return [
'host' => $_ENV['DB_HOST'] ?? 'localhost',
'database' => $_ENV['DB_NAME'] ?? 'rss_hub',
'username' => $_ENV['DB_USER'] ?? 'rss_hub_user',
'password' => $_ENV['DB_PASS'] ?? 'secure_password',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
],
];

View File

@ -0,0 +1,105 @@
<?php
require_once __DIR__ . '/../src/BaseMigration.php';
class CreateBaseTables extends \App\BaseMigration
{
public function up(\PDO $pdo): void
{
// Создание таблицы owners (агенты)
$ownersSql = "
CREATE TABLE owners (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
contact VARCHAR(255),
api_key VARCHAR(255) UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_activity TIMESTAMP,
status ENUM('active', 'suspended') DEFAULT 'active'
)
";
$pdo->exec($ownersSql);
// Создание таблицы categories
$categoriesSql = "
CREATE TABLE categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
";
$pdo->exec($categoriesSql);
// Создание таблицы tags
$tagsSql = "
CREATE TABLE tags (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
";
$pdo->exec($tagsSql);
// Создание таблицы feeds
$feedsSql = "
CREATE TABLE feeds (
id INT AUTO_INCREMENT PRIMARY KEY,
url VARCHAR(255) NOT NULL,
title VARCHAR(255),
description TEXT,
category_id INT,
refresh_interval INT,
owner_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
status ENUM('active', 'inactive', 'suspended') DEFAULT 'active',
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
FOREIGN KEY (owner_id) REFERENCES owners(id) ON DELETE CASCADE
)
";
$pdo->exec($feedsSql);
// Создание таблицы feed_tags (many-to-many связь)
$feedTagsSql = "
CREATE TABLE feed_tags (
feed_id INT NOT NULL,
tag_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (feed_id, tag_id),
FOREIGN KEY (feed_id) REFERENCES feeds(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
)
";
$pdo->exec($feedTagsSql);
// Создание таблицы feed_stats (опционально)
$feedStatsSql = "
CREATE TABLE feed_stats (
id INT AUTO_INCREMENT PRIMARY KEY,
feed_id INT NOT NULL,
access_count INT DEFAULT 0,
last_access TIMESTAMP,
FOREIGN KEY (feed_id) REFERENCES feeds(id) ON DELETE CASCADE
)
";
$pdo->exec($feedStatsSql);
// Создание индексов для производительности
$pdo->exec("CREATE INDEX idx_feeds_status ON feeds(status)");
$pdo->exec("CREATE INDEX idx_feeds_owner ON feeds(owner_id)");
$pdo->exec("CREATE INDEX idx_feeds_category ON feeds(category_id)");
$pdo->exec("CREATE INDEX idx_owners_api_key ON owners(api_key)");
}
public function down(\PDO $pdo): void
{
// Удаление таблиц в обратном порядке из-за внешних ключей
$pdo->exec("DROP TABLE IF EXISTS feed_stats");
$pdo->exec("DROP TABLE IF EXISTS feed_tags");
$pdo->exec("DROP TABLE IF EXISTS feeds");
$pdo->exec("DROP TABLE IF EXISTS tags");
$pdo->exec("DROP TABLE IF EXISTS categories");
$pdo->exec("DROP TABLE IF EXISTS owners");
}
}

View File

@ -0,0 +1,40 @@
<?php
require_once __DIR__ . '/../src/BaseMigration.php';
class AddDefaultCategories extends \App\BaseMigration
{
public function up(\PDO $pdo): void
{
// Добавляем базовые категории
$categories = [
['name' => 'tech', 'description' => 'Технические блоги и новости'],
['name' => 'news', 'description' => 'Новостные ленты'],
['name' => 'blog', 'description' => 'Персональные блоги'],
['name' => 'science', 'description' => 'Научные публикации'],
['name' => 'business', 'description' => 'Бизнес и экономика'],
['name' => 'art', 'description' => 'Искусство и культура'],
['name' => 'education', 'description' => 'Образование и обучение'],
['name' => 'development', 'description' => 'Разработка ПО'],
['name' => 'ai', 'description' => 'Искусственный интеллект'],
['name' => 'security', 'description' => 'Информационная безопасность']
];
$stmt = $pdo->prepare("INSERT INTO categories (name, description) VALUES (?, ?)");
foreach ($categories as $category) {
$stmt->execute([$category['name'], $category['description']]);
}
}
public function down(\PDO $pdo): void
{
// Удаляем только добавленные категории (без ID, удаляем по именам)
$names = ['tech', 'news', 'blog', 'science', 'business', 'art', 'education', 'development', 'ai', 'security'];
$placeholders = str_repeat('?,', count($names) - 1) . '?';
$sql = "DELETE FROM categories WHERE name IN ($placeholders)";
$stmt = $pdo->prepare($sql);
$stmt->execute($names);
}
}

38
src/BaseMigration.php Normal file
View File

@ -0,0 +1,38 @@
<?php
namespace App;
abstract class BaseMigration
{
/**
* Применить миграцию
*/
abstract public function up(\PDO $pdo);
/**
* Откатить миграцию
*/
abstract public function down(\PDO $pdo);
/**
* Проверить, была ли миграция уже применена
*/
public function isApplied(\PDO $pdo): bool
{
$stmt = $pdo->prepare("
SELECT COUNT(*)
FROM migrations
WHERE migration_name = ?
");
$stmt->execute([basename(str_replace('.php', '', $this->getName()))]);
return $stmt->fetchColumn() > 0;
}
/**
* Получить имя миграции
*/
public function getName(): string
{
return static::class;
}
}

187
src/MigrationRunner.php Normal file
View File

@ -0,0 +1,187 @@
<?php
namespace App;
use PDO;
use PDOException;
class MigrationRunner
{
private $pdo;
private $migrationsDir;
public function __construct(PDO $pdo, string $migrationsDir = '../migrations/')
{
$this->pdo = $pdo;
$this->migrationsDir = $migrationsDir;
}
/**
* Запустить все непримененные миграции
*/
public function migrate(): void
{
$this->ensureMigrationTableExists();
$appliedMigrations = $this->getAppliedMigrations();
$migrationFiles = $this->getMigrationFiles();
foreach ($migrationFiles as $filename) {
$migrationName = $this->getMigrationNameFromFilename($filename);
if (!in_array($migrationName, $appliedMigrations)) {
echo "Применяем миграцию: $migrationName\n";
$migrationClass = $this->loadMigrationClass($filename);
if ($migrationClass && method_exists($migrationClass, 'up')) {
$migrationClass->up($this->pdo);
$this->markAsApplied($migrationName);
echo "✓ Миграция $migrationName успешно применена\n";
} else {
echo "✗ Ошибка: Метод up() не найден в миграции $migrationName\n";
}
}
}
}
/**
* Откатить последнюю миграцию
*/
public function rollback(): void
{
$lastAppliedMigration = $this->getLastAppliedMigration();
if (!$lastAppliedMigration) {
echo "Нет миграций для отката\n";
return;
}
echo "Откатываем миграцию: {$lastAppliedMigration['migration_name']}\n";
$migrationFiles = $this->getMigrationFiles();
$migrationToRollback = null;
foreach ($migrationFiles as $filename) {
$migrationName = $this->getMigrationNameFromFilename($filename);
if ($migrationName === $lastAppliedMigration['migration_name']) {
$migrationToRollback = $filename;
break;
}
}
if ($migrationToRollback) {
$migrationClass = $this->loadMigrationClass($migrationToRollback);
if ($migrationClass && method_exists($migrationClass, 'down')) {
$migrationClass->down($this->pdo);
$this->markAsRolledBack($lastAppliedMigration['migration_name']);
echo "✓ Миграция {$lastAppliedMigration['migration_name']} успешно откачена\n";
} else {
echo "✗ Ошибка: Метод down() не найден в миграции {$lastAppliedMigration['migration_name']}\n";
}
} else {
echo "✗ Ошибка: Файл миграции не найден для {$lastAppliedMigration['migration_name']}\n";
}
}
/**
* Убедиться, что таблица миграций существует
*/
private function ensureMigrationTableExists(): void
{
$sql = "CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
migration_name VARCHAR(255) UNIQUE NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)";
$this->pdo->exec($sql);
}
/**
* Получить список примененных миграций
*/
private function getAppliedMigrations(): array
{
$stmt = $this->pdo->query("SELECT migration_name FROM migrations ORDER BY applied_at ASC");
return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'migration_name');
}
/**
* Получить список файлов миграций
*/
private function getMigrationFiles(): array
{
$files = glob($this->migrationsDir . "*.php");
natsort($files); // Сортировка по алфавиту с учетом чисел
return $files;
}
/**
* Извлечь имя миграции из имени файла
*/
private function getMigrationNameFromFilename(string $filename): string
{
$basename = basename($filename, '.php');
return $basename;
}
/**
* Загрузить класс миграции из файла
*/
private function loadMigrationClass(string $filename): ?object
{
if (!file_exists($filename)) {
return null;
}
require_once $filename;
$className = basename($filename, '.php');
// Попробуем найти класс в файле
$declaredClasses = get_declared_classes();
$migrationClass = null;
foreach ($declaredClasses as $declaredClass) {
if (strpos($declaredClass, $className) !== false) {
$migrationClass = $declaredClass;
break;
}
}
if ($migrationClass && class_exists($migrationClass)) {
return new $migrationClass();
}
return null;
}
/**
* Отметить миграцию как примененную
*/
private function markAsApplied(string $migrationName): void
{
$stmt = $this->pdo->prepare("INSERT INTO migrations (migration_name) VALUES (?)");
$stmt->execute([$migrationName]);
}
/**
* Отметить миграцию как откаченную
*/
private function markAsRolledBack(string $migrationName): void
{
$stmt = $this->pdo->prepare("DELETE FROM migrations WHERE migration_name = ?");
$stmt->execute([$migrationName]);
}
/**
* Получить последнюю примененную миграцию
*/
private function getLastAppliedMigration(): ?array
{
$stmt = $this->pdo->query("SELECT migration_name FROM migrations ORDER BY applied_at DESC LIMIT 1");
return $stmt->fetch(PDO::FETCH_ASSOC);
}
}