From 5a6e32758fd7b856229bffddd5eec46ebe6b8e04 Mon Sep 17 00:00:00 2001 From: Vladimir Mirivlad Date: Fri, 6 Feb 2026 03:41:22 +0000 Subject: [PATCH] Initial commit: RSS Hub for agents with migration system --- .env.example | 10 ++ README.md | 51 ++++++ app.php | 102 ++++++++++++ composer.json | 42 +++++ config/database.php | 17 ++ migrations/001_create_base_tables.php | 105 ++++++++++++ migrations/002_add_default_categories.php | 40 +++++ src/BaseMigration.php | 38 +++++ src/MigrationRunner.php | 187 ++++++++++++++++++++++ 9 files changed, 592 insertions(+) create mode 100644 .env.example create mode 100644 README.md create mode 100755 app.php create mode 100644 composer.json create mode 100644 config/database.php create mode 100644 migrations/001_create_base_tables.php create mode 100644 migrations/002_add_default_categories.php create mode 100644 src/BaseMigration.php create mode 100644 src/MigrationRunner.php diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..16063b7 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..346923d --- /dev/null +++ b/README.md @@ -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 \ No newline at end of file diff --git a/app.php b/app.php new file mode 100755 index 0000000..1eebad8 --- /dev/null +++ b/app.php @@ -0,0 +1,102 @@ +#!/usr/bin/env php + 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); +} \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..eac1270 --- /dev/null +++ b/composer.json @@ -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 + } +} \ No newline at end of file diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..ed5027a --- /dev/null +++ b/config/database.php @@ -0,0 +1,17 @@ + $_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, + ], +]; \ No newline at end of file diff --git a/migrations/001_create_base_tables.php b/migrations/001_create_base_tables.php new file mode 100644 index 0000000..0ff4c0a --- /dev/null +++ b/migrations/001_create_base_tables.php @@ -0,0 +1,105 @@ +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"); + } +} \ No newline at end of file diff --git a/migrations/002_add_default_categories.php b/migrations/002_add_default_categories.php new file mode 100644 index 0000000..999cc56 --- /dev/null +++ b/migrations/002_add_default_categories.php @@ -0,0 +1,40 @@ + '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); + } +} \ No newline at end of file diff --git a/src/BaseMigration.php b/src/BaseMigration.php new file mode 100644 index 0000000..1009607 --- /dev/null +++ b/src/BaseMigration.php @@ -0,0 +1,38 @@ +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; + } +} \ No newline at end of file diff --git a/src/MigrationRunner.php b/src/MigrationRunner.php new file mode 100644 index 0000000..5497a58 --- /dev/null +++ b/src/MigrationRunner.php @@ -0,0 +1,187 @@ +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); + } +} \ No newline at end of file