mirror of
https://github.com/hyzendust/freedoms4-backend-public.git
synced 2026-06-30 23:12:18 +02:00
379 lines
14 KiB
PHP
379 lines
14 KiB
PHP
<?php
|
|
/**
|
|
* comments.php — Blog comment backend for freedoms4.org
|
|
*
|
|
* Actions:
|
|
* GET ?action=get&post_id=... — fetch comments for a post
|
|
* POST { action: "post", post_id, body } — add a top-level comment
|
|
* POST { action: "reply", post_id, parent_id, body } — reply to a comment
|
|
* POST { action: "delete", comment_id } — delete own comment
|
|
*/
|
|
|
|
// ── Credentials from env file ──
|
|
$env_file = '/etc/freedoms4/auth.env';
|
|
if (!is_readable($env_file)) {
|
|
http_response_code(503);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo json_encode(['success' => false, 'message' => 'Server configuration error.']);
|
|
exit;
|
|
}
|
|
$env = [];
|
|
foreach (file($env_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
|
if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) continue;
|
|
[$k, $v] = explode('=', $line, 2);
|
|
$env[trim($k)] = trim($v);
|
|
}
|
|
|
|
define('DB_HOST', $env['DB_HOST'] ?? '127.0.0.1');
|
|
define('DB_PORT', $env['DB_PORT'] ?? '5432');
|
|
define('DB_NAME', $env['DB_NAME'] ?? 'freedoms4');
|
|
define('DB_USER', $env['DB_USER'] ?? 'freedoms4_user');
|
|
define('DB_PASS', $env['DB_PASS'] ?? '');
|
|
|
|
define('SESSION_NAME', 'f4_session');
|
|
define('SESSION_SECURE', true);
|
|
define('SESSION_SAMESITE', 'None');
|
|
define('MAX_BODY_BYTES', 8192);
|
|
define('MAX_COMMENT_LEN', 2000);
|
|
|
|
// ── CORS ──
|
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
|
$allowed_origins = ['https://freedoms4.org', 'https://www.freedoms4.org'];
|
|
|
|
if (!$origin || !in_array($origin, $allowed_origins, true)) {
|
|
http_response_code(403);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo json_encode(['success' => false, 'message' => 'Forbidden.']);
|
|
exit;
|
|
}
|
|
if ($origin) {
|
|
header('Access-Control-Allow-Origin: ' . $origin);
|
|
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
|
header('Access-Control-Allow-Headers: Content-Type');
|
|
header('Access-Control-Allow-Credentials: true');
|
|
}
|
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|
http_response_code(204);
|
|
exit;
|
|
}
|
|
|
|
// ── Helpers ──
|
|
function json_out(array $data, int $status = 200): never {
|
|
http_response_code($status);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo json_encode($data);
|
|
exit;
|
|
}
|
|
|
|
function db_connect(): PDO {
|
|
static $pdo = null;
|
|
if ($pdo !== null) return $pdo;
|
|
$dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', DB_HOST, DB_PORT, DB_NAME);
|
|
try {
|
|
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
PDO::ATTR_EMULATE_PREPARES => false,
|
|
]);
|
|
} catch (PDOException $e) {
|
|
error_log('comments.php DB error: ' . $e->getMessage());
|
|
json_out(['success' => false, 'message' => 'Database unavailable.'], 503);
|
|
}
|
|
return $pdo;
|
|
}
|
|
|
|
function start_session(): void {
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
session_name(SESSION_NAME);
|
|
session_set_cookie_params([
|
|
'lifetime' => 0,
|
|
'path' => '/',
|
|
'secure' => SESSION_SECURE,
|
|
'httponly' => true,
|
|
'samesite' => SESSION_SAMESITE,
|
|
]);
|
|
session_start();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send comment notification emails.
|
|
*
|
|
* $type 'new_comment' | 'new_reply'
|
|
* $actor username of the person who wrote the comment/reply
|
|
* $body the comment/reply text
|
|
* $post_id the post slug/path (used as human-readable context)
|
|
* $notify_user ['username' => ..., 'email' => ...] | null — commenter being replied to
|
|
*/
|
|
function send_notification(string $type, string $actor, string $body, string $post_id, ?array $notify_user): void {
|
|
$from = 'no-reply@freedoms4.org';
|
|
$headers = implode("\r\n", [
|
|
'From: freedoms4.org <' . $from . '>',
|
|
'Reply-To: ' . $from,
|
|
'X-Mailer: PHP/' . PHP_VERSION,
|
|
'MIME-Version: 1.0',
|
|
'Content-Type: text/plain; charset=UTF-8',
|
|
]);
|
|
|
|
$post_url = 'https://freedoms4.org' . trim($post_id, '"\'');
|
|
|
|
if ($type === 'new_reply') {
|
|
// ── Reply notification ──
|
|
// Always notify hyzen (unless hyzen is the one replying)
|
|
$reply_subject = "You have a new reply from {$actor}";
|
|
$reply_body =
|
|
"You have a new reply from {$actor}:\n\n" .
|
|
"{$body}\n\n" .
|
|
"Post: {$post_url}\n\n" .
|
|
"freedoms4.org";
|
|
|
|
// Notify the commenter being replied to (their registered email + @freedoms4.org),
|
|
// unless they are hyzen (handled separately below)
|
|
if ($notify_user && $notify_user['username'] !== 'hyzen') {
|
|
// Send to registered email
|
|
if (!empty($notify_user['email'])) {
|
|
@mail($notify_user['email'], $reply_subject, $reply_body, $headers);
|
|
}
|
|
// Send to their @freedoms4.org address
|
|
$site_email = $notify_user['username'] . '@freedoms4.org';
|
|
@mail($site_email, $reply_subject, $reply_body, $headers);
|
|
}
|
|
|
|
// Notify hyzen for all replies (unless hyzen is the replier)
|
|
if ($actor !== 'hyzen') {
|
|
// If hyzen is being replied to, use the reply subject; otherwise use new-comment subject
|
|
if ($notify_user && $notify_user['username'] === 'hyzen') {
|
|
@mail('hyzen@freedoms4.org', $reply_subject, $reply_body, $headers);
|
|
} else {
|
|
// hyzen gets a "new reply" notice even when it's not on their own comment
|
|
$hyzen_subject = "A new comment from {$actor}";
|
|
$hyzen_body =
|
|
"A new comment from {$actor}:\n\n" .
|
|
"{$body}\n\n" .
|
|
"Post: {$post_url}\n\n" .
|
|
"freedoms4.org";
|
|
@mail('hyzen@freedoms4.org', $hyzen_subject, $hyzen_body, $headers);
|
|
}
|
|
}
|
|
|
|
} else {
|
|
// ── New top-level comment notification (hyzen only) ──
|
|
if ($actor === 'hyzen') return; // hyzen commenting on their own site — skip
|
|
$subject = "A new comment from {$actor}";
|
|
$msg =
|
|
"A new comment from {$actor}:\n\n" .
|
|
"{$body}\n\n" .
|
|
"Post: {$post_url}\n\n" .
|
|
"freedoms4.org";
|
|
@mail('hyzen@freedoms4.org', $subject, $msg, $headers);
|
|
}
|
|
}
|
|
|
|
function logged_in_user(): ?array {
|
|
if (empty($_SESSION['user_id']) || empty($_SESSION['username'])) return null;
|
|
// Verify the user still exists in the DB (handles deleted accounts / wiped DB)
|
|
try {
|
|
$pdo = db_connect();
|
|
$stmt = $pdo->prepare('SELECT blocked FROM users WHERE id = :id LIMIT 1');
|
|
$stmt->execute([':id' => (int)$_SESSION['user_id']]);
|
|
$row = $stmt->fetch();
|
|
if (!$row) {
|
|
$_SESSION = [];
|
|
session_destroy();
|
|
return null;
|
|
}
|
|
if ($row['blocked'] === true || $row['blocked'] === 't') {
|
|
return null;
|
|
}
|
|
} catch (Exception $e) {
|
|
// DB unavailable — treat as logged-out to be safe
|
|
return null;
|
|
}
|
|
return ['id' => (int)$_SESSION['user_id'], 'username' => $_SESSION['username']];
|
|
}
|
|
|
|
// ── GET: fetch comments ──
|
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|
$post_id = trim($_GET['post_id'] ?? '');
|
|
if ($post_id === '') {
|
|
json_out(['success' => false, 'message' => 'post_id is required.'], 400);
|
|
}
|
|
|
|
start_session();
|
|
$viewer = logged_in_user();
|
|
$is_admin = $viewer && $viewer['username'] === 'hyzen';
|
|
|
|
$pdo = db_connect();
|
|
$stmt = $pdo->prepare(
|
|
"SELECT c.id, c.post_id, c.parent_id, c.user_id, COALESCE(u.username, c.username, '[deleted user]') AS username,
|
|
c.body, c.created_at, c.deleted, c.deleted_by
|
|
FROM comments c
|
|
LEFT JOIN users u ON u.id = c.user_id
|
|
WHERE c.post_id = :pid
|
|
ORDER BY c.created_at ASC"
|
|
);
|
|
$stmt->execute([':pid' => $post_id]);
|
|
$rows = $stmt->fetchAll();
|
|
|
|
// Build tree: top-level comments with nested replies
|
|
$top = [];
|
|
$index = [];
|
|
foreach ($rows as $row) {
|
|
if ($row['deleted']) {
|
|
$row['body'] = null;
|
|
$row['deleted_label'] = $row['deleted_by'] === 'admin' ? 'deleted by admin' : 'deleted by user';
|
|
} else {
|
|
$row['deleted_label'] = null;
|
|
}
|
|
$row['replies'] = [];
|
|
$row['is_own'] = $viewer && (int)$row['user_id'] === $viewer['id'];
|
|
$index[$row['id']] = $row;
|
|
}
|
|
foreach ($index as $id => &$node) {
|
|
if ($node['parent_id'] === null) {
|
|
$top[$id] = &$node;
|
|
} else {
|
|
$index[$node['parent_id']]['replies'][$id] = &$node;
|
|
}
|
|
}
|
|
unset($node);
|
|
|
|
json_out(['success' => true, 'comments' => array_values($top), 'logged_in' => $viewer !== null, 'username' => $viewer['username'] ?? null, 'is_admin' => $is_admin]);
|
|
}
|
|
|
|
// ── POST actions ──
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
json_out(['success' => false, 'message' => 'Method not allowed.'], 405);
|
|
}
|
|
|
|
$content_length = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
|
|
if ($content_length > MAX_BODY_BYTES) {
|
|
json_out(['success' => false, 'message' => 'Request too large.'], 413);
|
|
}
|
|
$raw = fread(fopen('php://input', 'r'), MAX_BODY_BYTES + 1);
|
|
$body = json_decode($raw, true);
|
|
if (!is_array($body)) {
|
|
json_out(['success' => false, 'message' => 'Invalid request body.'], 400);
|
|
}
|
|
|
|
start_session();
|
|
$user = logged_in_user();
|
|
if (!$user) {
|
|
json_out(['success' => false, 'message' => 'You must be logged in to comment.'], 401);
|
|
}
|
|
|
|
$action = $body['action'] ?? '';
|
|
|
|
// ── POST: add comment or reply ──
|
|
if ($action === 'post' || $action === 'reply') {
|
|
$post_id = trim($body['post_id'] ?? '');
|
|
$text = trim($body['body'] ?? '');
|
|
$parent_id = isset($body['parent_id']) ? (int)$body['parent_id'] : null;
|
|
|
|
if ($post_id === '') {
|
|
json_out(['success' => false, 'message' => 'post_id is required.']);
|
|
}
|
|
if ($text === '') {
|
|
json_out(['success' => false, 'message' => 'Comment cannot be empty.']);
|
|
}
|
|
if (strlen($text) > MAX_COMMENT_LEN) {
|
|
json_out(['success' => false, 'message' => 'Comment is too long (max 2000 characters).']);
|
|
}
|
|
|
|
$pdo = db_connect();
|
|
|
|
// Rate limit: max 1 comment per user per minute (hyzen is exempt)
|
|
if ($user['username'] !== 'hyzen') {
|
|
$rate_stmt = $pdo->prepare(
|
|
"SELECT COUNT(*) FROM comments
|
|
WHERE user_id = :uid AND created_at > NOW() - INTERVAL '1 minute'"
|
|
);
|
|
$rate_stmt->execute([':uid' => $user['id']]);
|
|
if ((int)$rate_stmt->fetchColumn() >= 1) {
|
|
json_out(['success' => false, 'message' => 'You are posting too fast. Please wait a moment.'], 429);
|
|
}
|
|
}
|
|
|
|
// Validate parent exists and belongs to same post
|
|
if ($parent_id !== null) {
|
|
$stmt = $pdo->prepare("SELECT id FROM comments WHERE id = :pid AND post_id = :post AND deleted = FALSE LIMIT 1");
|
|
$stmt->execute([':pid' => $parent_id, ':post' => $post_id]);
|
|
if (!$stmt->fetch()) {
|
|
json_out(['success' => false, 'message' => 'Parent comment not found.'], 404);
|
|
}
|
|
}
|
|
|
|
$stmt = $pdo->prepare(
|
|
"INSERT INTO comments (post_id, parent_id, user_id, username, body, created_at, deleted)
|
|
VALUES (:post, :parent, :uid, :username, :body, NOW(), FALSE)
|
|
RETURNING id, created_at"
|
|
);
|
|
$stmt->execute([
|
|
':post' => $post_id,
|
|
':parent' => $parent_id,
|
|
':uid' => $user['id'],
|
|
':username' => $user['username'],
|
|
':body' => $text,
|
|
]);
|
|
$row = $stmt->fetch();
|
|
|
|
// ── Email notifications ──
|
|
if ($parent_id !== null) {
|
|
// It's a reply — find the parent comment's author for notification
|
|
$parent_stmt = $pdo->prepare(
|
|
"SELECT c.user_id, u.username, u.email
|
|
FROM comments c JOIN users u ON u.id = c.user_id
|
|
WHERE c.id = :pid LIMIT 1"
|
|
);
|
|
$parent_stmt->execute([':pid' => $parent_id]);
|
|
$parent_author = $parent_stmt->fetch() ?: null;
|
|
send_notification('new_reply', $user['username'], $text, $post_id, $parent_author);
|
|
} else {
|
|
// Top-level comment
|
|
send_notification('new_comment', $user['username'], $text, $post_id, null);
|
|
}
|
|
|
|
json_out(['success' => true, 'id' => $row['id'], 'created_at' => $row['created_at']]);
|
|
}
|
|
|
|
// ── POST: delete comment (own) or any comment (admin) ──
|
|
if ($action === 'delete') {
|
|
$comment_id = (int)($body['comment_id'] ?? 0);
|
|
if ($comment_id === 0) {
|
|
json_out(['success' => false, 'message' => 'comment_id is required.']);
|
|
}
|
|
|
|
$is_admin = $user['username'] === 'hyzen';
|
|
|
|
$pdo = db_connect();
|
|
|
|
// Check if the comment belongs to the deleter
|
|
$owner_stmt = $pdo->prepare("SELECT user_id FROM comments WHERE id = :id LIMIT 1");
|
|
$owner_stmt->execute([':id' => $comment_id]);
|
|
$comment_row = $owner_stmt->fetch(PDO::FETCH_ASSOC);
|
|
$is_own = $comment_row && (int)$comment_row['user_id'] === (int)$user['id'];
|
|
|
|
$deleted_by = $is_own ? 'user' : 'admin';
|
|
|
|
if ($is_admin) {
|
|
$stmt = $pdo->prepare(
|
|
"UPDATE comments SET deleted = TRUE, body = NULL, deleted_by = :by
|
|
WHERE id = :id AND deleted = FALSE"
|
|
);
|
|
$stmt->execute([':id' => $comment_id, ':by' => $deleted_by]);
|
|
} else {
|
|
$stmt = $pdo->prepare(
|
|
"UPDATE comments SET deleted = TRUE, body = NULL, deleted_by = :by
|
|
WHERE id = :id AND user_id = :uid AND deleted = FALSE"
|
|
);
|
|
$stmt->execute([':id' => $comment_id, ':uid' => $user['id'], ':by' => $deleted_by]);
|
|
}
|
|
|
|
if ($stmt->rowCount() === 0) {
|
|
json_out(['success' => false, 'message' => 'Comment not found or not yours.'], 403);
|
|
}
|
|
json_out(['success' => true]);
|
|
}
|
|
|
|
json_out(['success' => false, 'message' => 'Unknown action.'], 400);
|