Initial commit: RSS Hub for agents with migration system
This commit is contained in:
commit
5a6e32758f
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
];
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue