Ready for release: Includes Auto-cron, Installer, and Schema

This commit is contained in:
Timothy Allen
2026-01-10 16:00:06 -06:00
commit 7fc2f2bc1f
9 changed files with 608 additions and 0 deletions

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
# .env
MYSQL_ROOT_PASSWORD=someweirdrootpassword
MYSQL_DATABASE=rss_db
MYSQL_USER=rss_user
MYSQL_PASSWORD=secretpassword123

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# --- Security ---
# Contains database passwords and secrets
.env
# Legacy config file (if you still use it locally)
src/config.php
# --- Dependencies ---
# Composer downloads these automatically during build
src/vendor/
# --- Application Data ---
# RSS cache folder (generated dynamically)
src/cache/
# Cron job logs
src/cron_output.log
# --- System Files ---
# Mac OS metadata
.DS_Store
# Windows thumbnail cache
Thumbs.db
ehthumbs.db
# --- IDE Settings (Optional but recommended) ---
.idea/
.vscode/
*.swp

28
Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM php:8.3-apache
# 1. Install system dependencies (Git, Unzip, Cron)
RUN apt-get update && apt-get install -y \
git \
unzip \
libzip-dev \
cron \
&& docker-php-ext-install zip pdo pdo_mysql
# 2. Install Composer globally
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# 3. Enable Apache mod_rewrite
RUN a2enmod rewrite
# 4. Copy the entrypoint script and make it executable
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# 5. Set working directory
WORKDIR /var/www/html
# 6. Set Entrypoint
ENTRYPOINT ["docker-entrypoint.sh"]
# 7. Default command
CMD ["apache2-foreground"]

24
config.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
session_start();
error_reporting(E_ALL & ~E_DEPRECATED);
ini_set('display_errors', 0);
// Read variables from Docker Environment
$host = getenv('DB_HOST') ?: 'db';
$db = getenv('DB_NAME') ?: 'rss_db';
$user = getenv('DB_USER') ?: 'root';
$pass = getenv('DB_PASS'); // This comes from docker-compose
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO("mysql:host=$host;dbname=$db;charset=utf8mb4", $user, $pass, $options);
} catch (\PDOException $e) {
// Show a cleaner error if DB connection fails
die("Database Connection Failed. Check your .env file and container status.");
}
?>

42
docker-compose.yml .yml Normal file
View File

@@ -0,0 +1,42 @@
version: '3.8'
services:
web:
build: .
ports:
- "8080:80"
volumes:
- ./src:/var/www/html
depends_on:
- db
networks:
- rss-network
# Pass the variables to PHP
environment:
- DB_HOST=db
- DB_NAME=${MYSQL_DATABASE}
- DB_USER=${MYSQL_USER}
- DB_PASS=${MYSQL_PASSWORD}
db:
image: mysql:8.0
container_name: rss_mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- db_data:/var/lib/mysql
# MAGIC LINE: This auto-runs your SQL on first launch!
- ./schema.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- rss-network
volumes:
db_data:
networks:
rss-network:
driver: bridge

24
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
set -e
# 1. Install SimplePie if vendor folder is missing
if [ ! -d "/var/www/html/vendor" ]; then
echo "Vendor folder not found. Installing SimplePie..."
composer require simplepie/simplepie
fi
# 2. Ensure Cache folder exists and is writable
if [ ! -d "/var/www/html/cache" ]; then
mkdir -p /var/www/html/cache
chmod -R 777 /var/www/html/cache
fi
# 3. Setup Cron Job (Runs every 15 minutes)
# We overwrite the file to ensure no duplicates
echo "*/15 * * * * /usr/local/bin/php /var/www/html/fetch.php >> /var/www/html/cron_output.log 2>&1" > /etc/cron.d/rss-cron
chmod 0644 /etc/cron.d/rss-cron
crontab /etc/cron.d/rss-cron
service cron start
# 4. Start Apache
exec "$@"

51
fetch.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
require 'config.php';
require 'vendor/autoload.php'; // Or manual include if not using composer
$feed = new SimplePie();
$feed->set_cache_location(__DIR__ . '/cache');
// 1. Get feeds
$stmt = $pdo->query("SELECT id, rss_url FROM feeds");
$feeds_list = $stmt->fetchAll();
foreach ($feeds_list as $row) {
$feed->set_feed_url($row['rss_url']);
$feed->init();
$feed->handle_content_type();
foreach ($feed->get_items() as $item) {
// FIX: Decode HTML entities so "AT&amp;T" becomes "AT&T"
$title = html_entity_decode($item->get_title(), ENT_QUOTES | ENT_HTML5);
$desc = html_entity_decode($item->get_description(), ENT_QUOTES | ENT_HTML5);
$link = $item->get_permalink();
$date = $item->get_date('Y-m-d H:i:s');
if (!$date) { $date = date('Y-m-d H:i:s'); }
$sql = "INSERT IGNORE INTO items (feed_id, title, link, description, pub_date)
VALUES (?, ?, ?, ?, ?)";
$stmtInsert = $pdo->prepare($sql);
$stmtInsert->execute([$row['id'], $title, $link, $desc, $date]);
}
}
// --- CLEANUP ROUTINE ---
// Delete articles older than 30 days, BUT KEEP items marked as 'is_saved'
$days_to_keep = 30;
$sql_cleanup = "DELETE FROM items
WHERE pub_date < DATE_SUB(NOW(), INTERVAL ? DAY)
AND is_saved = 0";
$stmtCleanup = $pdo->prepare($sql_cleanup);
$stmtCleanup->execute([$days_to_keep]);
// -----------------------
// 2. Redirect back to dashboard immediately
header("Location: index.php");
exit;
?>

377
index.php Normal file
View File

@@ -0,0 +1,377 @@
<?php
require 'config.php';
// --- ACTION HANDLERS ---
// 1. Add Feed
if (isset($_POST['add_feed'])) {
$stmt = $pdo->prepare("INSERT INTO feeds (site_name, rss_url, group_id) VALUES (?, ?, ?)");
$stmt->execute([$_POST['site_name'], $_POST['rss_url'], $_POST['group_id']]);
header("Location: index.php");
exit;
}
// 2. Add Group
if (isset($_POST['add_group'])) {
$stmt = $pdo->prepare("INSERT INTO `groups` (name) VALUES (?)");
$stmt->execute([$_POST['group_name']]);
header("Location: index.php");
exit;
}
// 3. Delete Feed
if (isset($_POST['delete_feed'])) {
$stmt = $pdo->prepare("DELETE FROM feeds WHERE id = ?");
$stmt->execute([$_POST['feed_id_to_delete']]);
header("Location: index.php");
exit;
}
// 4. Edit Feed
if (isset($_POST['edit_feed'])) {
$stmt = $pdo->prepare("UPDATE feeds SET site_name = ?, rss_url = ?, group_id = ? WHERE id = ?");
$stmt->execute([
$_POST['edit_site_name'],
$_POST['edit_rss_url'],
$_POST['edit_group_id'],
$_POST['edit_feed_id']
]);
header("Location: index.php");
exit;
}
// 5. Mark as Read (Updated with Undo Memory)
if (isset($_GET['mark_read'])) {
$id = $_GET['mark_read'];
// Update DB
$stmt = $pdo->prepare("UPDATE items SET is_read = 1, is_saved = 0 WHERE id = ?");
$stmt->execute([$id]);
// SAVE ID TO SESSION FOR UNDO
$_SESSION['undo_entry_id'] = $id;
// Redirect logic (Same as before)
$params = [];
if (isset($_GET['view'])) $params[] = "view=" . $_GET['view'];
if (isset($_GET['group_id'])) $params[] = "group_id=" . $_GET['group_id'];
$redirect = count($params) > 0 ? "?" . implode("&", $params) : "index.php";
header("Location: $redirect");
exit;
}
// 6. Save/Unsave Logic
if (isset($_GET['save_item'])) {
$stmt = $pdo->prepare("UPDATE items SET is_saved = 1 WHERE id = ?");
$stmt->execute([$_GET['save_item']]);
$params = [];
if (isset($_GET['view'])) $params[] = "view=" . $_GET['view'];
if (isset($_GET['group_id'])) $params[] = "group_id=" . $_GET['group_id'];
$redirect = count($params) > 0 ? "?" . implode("&", $params) : "index.php";
header("Location: $redirect");
exit;
}
if (isset($_GET['unsave_item'])) {
$stmt = $pdo->prepare("UPDATE items SET is_saved = 0 WHERE id = ?");
$stmt->execute([$_GET['unsave_item']]);
$params = [];
$params[] = "view=" . ($_GET['view'] ?? 'saved');
if (isset($_GET['group_id'])) $params[] = "group_id=" . $_GET['group_id'];
$redirect = "?" . implode("&", $params);
header("Location: $redirect");
exit;
}
// 7. NEW: Undo Handler
if (isset($_GET['undo_action'])) {
if (isset($_SESSION['undo_entry_id'])) {
// Revert the last item to Unread (is_read = 0)
$stmt = $pdo->prepare("UPDATE items SET is_read = 0 WHERE id = ?");
$stmt->execute([$_SESSION['undo_entry_id']]);
// Clear the session so the button disappears
unset($_SESSION['undo_entry_id']);
}
// Redirect logic to keep you on the same page
$params = [];
if (isset($_GET['view'])) $params[] = "view=" . $_GET['view'];
if (isset($_GET['group_id'])) $params[] = "group_id=" . $_GET['group_id'];
$redirect = count($params) > 0 ? "?" . implode("&", $params) : "index.php";
header("Location: $redirect");
exit;
}
// --- DATA FETCHING ---
$groups = $pdo->query("SELECT * FROM `groups`")->fetchAll();
// Fetch all feeds for the management modal
$all_feeds = $pdo->query("SELECT feeds.*, groups.name as group_name FROM feeds LEFT JOIN `groups` ON feeds.group_id = groups.id ORDER BY site_name ASC")->fetchAll();
$view_mode = $_GET['view'] ?? 'unread';
$filter_group_id = $_GET['group_id'] ?? null;
// Build SQL
$sql = "SELECT items.id AS item_id, items.title, items.link, items.description, items.pub_date, feeds.site_name
FROM items JOIN feeds ON items.feed_id = feeds.id ";
$params = [];
if ($view_mode === 'saved') {
$sql .= "WHERE is_saved = 1 ";
} else {
$sql .= "WHERE is_read = 0 AND is_saved = 0 ";
}
if ($filter_group_id) {
$sql .= "AND feeds.group_id = ? ";
$params[] = $filter_group_id;
}
$sql .= "ORDER BY pub_date DESC LIMIT 50";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$items = $stmt->fetchAll();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>RSS Catcher</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.sidebar { height: 100vh; overflow-y: auto; position: fixed; width: 25%; }
.main-content { margin-left: 25%; width: 75%; }
.nav-link.active { background-color: #0d6efd; color: white !important; }
</style>
</head>
<body class="bg-light">
<div class="container-fluid">
<div class="row">
<div class="col-md-3 bg-white p-4 border-end sidebar">
<h3 class="mb-4">RSS Catcher</h3>
<div class="list-group mb-4">
<a href="index.php" class="list-group-item list-group-item-action <?= ($view_mode == 'unread' && !$filter_group_id) ? 'active' : '' ?>">
📥 All Unread
</a>
<a href="?view=saved" class="list-group-item list-group-item-action <?= ($view_mode == 'saved') ? 'active' : '' ?>">
⭐ Saved for Later
</a>
</div>
<h6 class="text-muted text-uppercase small">Filter by Group</h6>
<div class="list-group mb-4">
<?php foreach($groups as $g): ?>
<a href="?group_id=<?= $g['id'] ?>"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center <?= ($filter_group_id == $g['id']) ? 'active' : '' ?>">
<?= htmlspecialchars($g['name']) ?>
</a>
<?php endforeach; ?>
</div>
<hr>
<div class="d-grid gap-2">
<button class="btn btn-outline-dark btn-sm" data-bs-toggle="modal" data-bs-target="#manageFeedsModal">
🛠 Manage Feeds (Edit/Delete)
</button>
<div class="accordion" id="adminControls">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#addControls">
⚙️ Add New
</button>
</h2>
<div id="addControls" class="accordion-collapse collapse" data-bs-parent="#adminControls">
<div class="accordion-body">
<form method="post" class="mb-3">
<label class="form-label small">New Group</label>
<div class="input-group input-group-sm">
<input type="text" name="group_name" class="form-control" required>
<button class="btn btn-secondary" name="add_group">+</button>
</div>
</form>
<form method="post">
<label class="form-label small">New Feed</label>
<input type="text" name="site_name" class="form-control form-control-sm mb-2" placeholder="Site Name" required>
<input type="url" name="rss_url" class="form-control form-control-sm mb-2" placeholder="RSS URL" required>
<select name="group_id" class="form-select form-select-sm mb-2">
<?php foreach($groups as $g): ?>
<option value="<?= $g['id'] ?>"><?= $g['name'] ?></option>
<?php endforeach; ?>
</select>
<button class="btn btn-primary btn-sm w-100" name="add_feed">Add Feed</button>
</form>
</div>
</div>
</div>
</div>
<a href="fetch.php" class="btn btn-success btn-sm">Force Refresh</a>
</div>
</div>
<div class="col-md-9 p-4 main-content">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>
<?php
$count_label = " <small class='text-muted'>(" . count($items) . ")</small>";
if ($view_mode === 'saved') echo "⭐ Saved Articles" . $count_label;
elseif ($filter_group_id) echo "📂 Group View" . $count_label;
else echo "📥 Unread Articles" . $count_label;
?>
</h2>
<?php if (isset($_SESSION['undo_entry_id'])): ?>
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3">
<span>✅ Article marked as read.</span>
<?php
$undoParams = "undo_action=true";
if (isset($_GET['view'])) $undoParams .= "&view=" . $_GET['view'];
if (isset($_GET['group_id'])) $undoParams .= "&group_id=" . $_GET['group_id'];
?>
<a href="?<?= $undoParams ?>" class="btn btn-sm btn-dark">↩ Undo</a>
</div>
<?php endif; ?>
</div>
<?php if (count($items) === 0): ?>
<div class="alert alert-info">No articles found in this view.</div>
<?php endif; ?>
<div class="row">
<?php foreach($items as $item): ?>
<div class="col-12 mb-3">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<a href="<?= $item['link'] ?>" target="_blank" class="text-decoration-none text-dark">
<?= htmlspecialchars($item['title']) ?>
</a>
</h5>
<h6 class="card-subtitle mb-2 text-muted small">
<?= htmlspecialchars($item['site_name']) ?> | <?= date('M j, g:i a', strtotime($item['pub_date'])) ?>
</h6>
<p class="card-text text-secondary">
<?= strip_tags(substr($item['description'], 0, 250)) ?>...
</p>
<div class="d-flex gap-2">
<?php
$contextParams = "&view=" . $view_mode;
if ($filter_group_id) $contextParams .= "&group_id=" . $filter_group_id;
?>
<a href="?mark_read=<?= $item['item_id'] ?><?= $contextParams ?>" class="btn btn-sm btn-outline-secondary">Mark as Read</a>
<?php if($view_mode === 'saved'): ?>
<a href="?unsave_item=<?= $item['item_id'] ?><?= $contextParams ?>" class="btn btn-sm btn-warning">Remove from Saved</a>
<?php else: ?>
<a href="?save_item=<?= $item['item_id'] ?><?= $contextParams ?>" class="btn btn-sm btn-outline-primary">⭐ Save for Later</a>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<div class="modal fade" id="manageFeedsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Manage Feeds</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Name</th>
<th>Group</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach($all_feeds as $f): ?>
<tr>
<td><?= htmlspecialchars($f['site_name']) ?></td>
<td><?= htmlspecialchars($f['group_name']) ?></td>
<td>
<button class="btn btn-sm btn-primary"
onclick="openEditModal(<?= $f['id'] ?>, '<?= addslashes($f['site_name']) ?>', '<?= addslashes($f['rss_url']) ?>', <?= $f['group_id'] ?>)">
Edit
</button>
<form method="post" style="display:inline;" onsubmit="return confirm('Delete this feed and ALL its articles?');">
<input type="hidden" name="feed_id_to_delete" value="<?= $f['id'] ?>">
<button class="btn btn-sm btn-danger" name="delete_feed">Delete</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="modal fade" id="editFeedModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="post">
<div class="modal-header">
<h5 class="modal-title">Edit Feed</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" name="edit_feed_id" id="edit_feed_id">
<div class="mb-3">
<label>Site Name</label>
<input type="text" name="edit_site_name" id="edit_site_name" class="form-control" required>
</div>
<div class="mb-3">
<label>RSS URL</label>
<input type="url" name="edit_rss_url" id="edit_rss_url" class="form-control" required>
</div>
<div class="mb-3">
<label>Group</label>
<select name="edit_group_id" id="edit_group_id" class="form-select">
<?php foreach($groups as $g): ?>
<option value="<?= $g['id'] ?>"><?= $g['name'] ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" name="edit_feed">Save Changes</button>
</div>
</form>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
function openEditModal(id, name, url, groupId) {
// Populate fields
document.getElementById('edit_feed_id').value = id;
document.getElementById('edit_site_name').value = name;
document.getElementById('edit_rss_url').value = url;
document.getElementById('edit_group_id').value = groupId;
// Open Modal
var myModal = new bootstrap.Modal(document.getElementById('editFeedModal'));
myModal.show();
}
</script>
</body>
</html>

30
schema.sql Normal file
View File

@@ -0,0 +1,30 @@
CREATE TABLE `groups` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE `feeds` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`group_id` int(11) NOT NULL,
`site_name` varchar(255) NOT NULL,
`rss_url` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
FOREIGN KEY (`group_id`) REFERENCES `groups`(`id`) ON DELETE CASCADE
);
CREATE TABLE `items` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`feed_id` int(11) NOT NULL,
`title` varchar(255) NOT NULL,
`link` varchar(500) NOT NULL,
`description` TEXT,
`pub_date` datetime DEFAULT NULL,
`is_read` tinyint(1) DEFAULT 0,
`is_saved` tinyint(1) DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_link` (`link`),
FOREIGN KEY (`feed_id`) REFERENCES `feeds`(`id`) ON DELETE CASCADE
);
INSERT INTO `groups` (`name`) VALUES ('Tech News');