Guest Post Inbox (Approve + Publish, No DB)

February 3, 2026 NEW

Guest Post Inbox is a drop-in PHP script that lets visitors submit guest post drafts, puts them into a moderation queue, and lets you approve/publish them from a simple admin view. It’s built to avoid spam (honeypot + rate limit) and it stores everything as JSON files (no database).

Install:

  1. Create: /tools/guestpost/
  2. Save the script below as: /tools/guestpost/index.php
  3. Edit the CONFIG at the top (set your site name + admin token)
  4. Make sure the folder is writable (it will create guestpost.json)
  5. Visit:
    • Public: /tools/guestpost/
    • Admin: /tools/guestpost/?admin=YOUR_TOKEN
<?php
declare(strict_types=1);

/**
 * Tiny Guest Post Inbox (No DB) — One File
 * - Public: guest submission form + approved posts list
 * - Admin: approve/deny/edit queued drafts
 * - Storage: JSON file in this directory
 *
 * URLs:
 *   Public: /tools/guestpost/
 *   View post: /tools/guestpost/?post=slug
 *   Admin: /tools/guestpost/?admin=YOUR_TOKEN
 */

// ---------------- CONFIG ----------------
$CFG = [
  'site_name'   => 'Your Site',
  'tool_name'   => 'Guest Posts',
  'data_file'   => __DIR__ . '/guestpost.json',
  'admin_token' => 'CHANGE_ME_LONG_RANDOM',

  // Limits
  'max_title'   => 110,
  'max_name'    => 60,
  'max_email'   => 120,
  'max_body'    => 12000,

  // Anti-spam
  'honeypot'    => 'companyfax',
  'rate_hits'   => 3,      // submits
  'rate_window' => 900,    // per 15 min per IP
];
// --------------------------------------

// ---------- helpers ----------
session_start();

function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }

function csrf_token(): string {
  if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(16));
  return (string)$_SESSION['csrf'];
}
function csrf_ok(string $t): bool {
  return isset($_SESSION['csrf']) && hash_equals((string)$_SESSION['csrf'], $t);
}

function safe_json_decode(string $raw): array {
  $j = json_decode($raw, true);
  return is_array($j) ? $j : [];
}
function load_data(string $file): array {
  if (!is_file($file)) return ['queue'=>[], 'posts'=>[]];
  $raw = @file_get_contents($file);
  if ($raw === false) return ['queue'=>[], 'posts'=>[]];
  $j = safe_json_decode($raw);
  $j['queue'] = (isset($j['queue']) && is_array($j['queue'])) ? $j['queue'] : [];
  $j['posts'] = (isset($j['posts']) && is_array($j['posts'])) ? $j['posts'] : [];
  return $j;
}
function save_data(string $file, array $data): bool {
  $out = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
  if ($out === false) return false;
  return @file_put_contents($file, $out, LOCK_EX) !== false;
}

function ip(): string { return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; }

function rl_path(string $key): string {
  return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'gp_rl_' . sha1($key) . '.json';
}
function rate_limit(string $scope, int $maxHits, int $window): array {
  $bucket = (int)floor(time() / max(1, $window));
  $key = $scope . '|' . ip() . '|' . $bucket;
  $path = rl_path($key);

  $fh = @fopen($path, 'c+');
  if (!$fh) return [true, 0];

  if (!flock($fh, LOCK_EX)) { fclose($fh); return [true, 0]; }

  $raw = '';
  $sz = @filesize($path);
  if ($sz && $sz < 4096) { rewind($fh); $raw = (string)fread($fh, $sz); }

  $st = ['reset' => time() + $window, 'count' => 0];
  if ($raw !== '') {
    $j = json_decode($raw, true);
    if (is_array($j) && isset($j['reset'],$j['count'])) {
      $st['reset'] = (int)$j['reset'];
      $st['count'] = (int)$j['count'];
    }
  }
  if (time() >= $st['reset']) { $st['reset'] = time() + $window; $st['count'] = 0; }

  $st['count']++;
  $ok = ($st['count'] <= $maxHits);
  $retry = max(1, $st['reset'] - time());

  rewind($fh); ftruncate($fh, 0);
  fwrite($fh, json_encode($st, JSON_UNESCAPED_SLASHES));
  fflush($fh);

  flock($fh, LOCK_UN);
  fclose($fh);

  return [$ok, $retry];
}

function clean_one_line(string $s, int $max): string {
  $s = trim($s);
  $s = preg_replace('/\s+/', ' ', $s) ?? $s;
  if (mb_strlen($s) > $max) $s = mb_substr($s, 0, $max);
  return $s;
}
function clean_body(string $s, int $max): string {
  $s = str_replace(["\r\n","\r"], "\n", $s);
  $s = trim($s);
  if (mb_strlen($s) > $max) $s = mb_substr($s, 0, $max);
  return $s;
}
function valid_email(string $s): bool {
  if ($s === '') return true;
  return filter_var($s, FILTER_VALIDATE_EMAIL) !== false;
}
function slugify(string $s): string {
  $s = strtolower(trim($s));
  $s = preg_replace('/[^a-z0-9\s-]/', '', $s) ?? $s;
  $s = preg_replace('/\s+/', '-', $s) ?? $s;
  $s = preg_replace('/-+/', '-', $s) ?? $s;
  $s = trim($s, '-');
  return $s !== '' ? $s : 'post';
}
function excerpt(string $body, int $n = 180): string {
  $t = trim(preg_replace('/\s+/', ' ', $body) ?? $body);
  if (mb_strlen($t) <= $n) return $t;
  return mb_substr($t, 0, $n - 1) . '…';
}
function find_by_id(array $arr, string $id): array {
  foreach ($arr as $i => $row) {
    if (is_array($row) && (string)($row['id'] ?? '') === $id) return [$i, $row];
  }
  return [-1, []];
}
function find_post_by_slug(array $posts, string $slug): array {
  foreach ($posts as $p) {
    if (is_array($p) && (string)($p['slug'] ?? '') === $slug) return $p;
  }
  return [];
}

$data = load_data((string)$CFG['data_file']);
$isAdmin = isset($_GET['admin']) && hash_equals((string)$CFG['admin_token'], (string)$_GET['admin']);

$notice = '';
$error  = '';

// ---------- actions ----------
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  $action = (string)($_POST['action'] ?? '');

  if (!csrf_ok((string)($_POST['csrf'] ?? ''))) {
    $error = 'Security check failed. Refresh and try again.';
  } else {
    if ($action === 'submit_guest') {
      [$ok, $retry] = rate_limit('guest_submit', (int)$CFG['rate_hits'], (int)$CFG['rate_window']);
      if (!$ok) {
        $error = 'Rate limited. Try again in ' . $retry . 's.';
      } else {
        // honeypot
        $hp = (string)($_POST[(string)$CFG['honeypot']] ?? '');
        if ($hp !== '') $error = 'Spam blocked.';

        $title  = clean_one_line((string)($_POST['title'] ?? ''), (int)$CFG['max_title']);
        $name   = clean_one_line((string)($_POST['name'] ?? ''), (int)$CFG['max_name']);
        $email  = clean_one_line((string)($_POST['email'] ?? ''), (int)$CFG['max_email']);
        $body   = clean_body((string)($_POST['body'] ?? ''), (int)$CFG['max_body']);

        if ($error === '') {
          if ($title === '' || mb_strlen($title) < 6) $error = 'Title is too short.';
          elseif ($body === '' || mb_strlen($body) < 40) $error = 'Draft is too short.';
          elseif ($email !== '' && !valid_email($email)) $error = 'Email looks invalid.';
        }

        if ($error === '') {
          // ensure unique slug among published posts
          $base = slugify($title);
          $slug = $base;
          $n = 2;
          while (find_post_by_slug($data['posts'], $slug)) {
            $slug = $base . '-' . $n;
            $n++;
          }

          $data['queue'][] = [
            'id'        => bin2hex(random_bytes(10)),
            'title'     => $title,
            'slug'      => $slug,
            'author'    => $name,
            'email'     => $email,
            'body'      => $body, // plain text only (safe)
            'createdAt' => gmdate('c'),
            'ip'        => ip(),
            'ua'        => $_SERVER['HTTP_USER_AGENT'] ?? '',
          ];

          if (!save_data((string)$CFG['data_file'], $data)) {
            $error = 'Could not save (check folder permissions).';
          } else {
            $notice = 'Thanks! Your draft is in the review queue.';
          }
        }
      }
    }

    if ($isAdmin && $action === 'admin_save') {
      $id = (string)($_POST['id'] ?? '');
      [$idx, $row] = find_by_id($data['queue'], $id);
      if ($idx < 0) $error = 'Queue item not found.';
      else {
        $row['title']  = clean_one_line((string)($_POST['title'] ?? ''), (int)$CFG['max_title']);
        $row['author'] = clean_one_line((string)($_POST['author'] ?? ''), (int)$CFG['max_name']);
        $row['email']  = clean_one_line((string)($_POST['email'] ?? ''), (int)$CFG['max_email']);
        $row['body']   = clean_body((string)($_POST['body'] ?? ''), (int)$CFG['max_body']);

        // allow admin to change slug, keep it safe + unique among posts
        $newSlug = slugify((string)($_POST['slug'] ?? $row['slug']));
        if ($newSlug === '') $newSlug = $row['slug'];
        if ($newSlug !== (string)$row['slug'] && find_post_by_slug($data['posts'], $newSlug)) {
          $error = 'Slug already exists in published posts.';
        } else {
          $row['slug'] = $newSlug;
          $data['queue'][$idx] = $row;
          save_data((string)$CFG['data_file'], $data);
          $notice = 'Saved.';
        }
      }
    }

    if ($isAdmin && $action === 'admin_approve') {
      $id = (string)($_POST['id'] ?? '');
      [$idx, $row] = find_by_id($data['queue'], $id);
      if ($idx < 0) $error = 'Queue item not found.';
      else {
        $data['posts'][] = [
          'id'          => $row['id'],
          'title'       => $row['title'],
          'slug'        => $row['slug'],
          'author'      => $row['author'],
          'body'        => $row['body'],
          'createdAt'   => $row['createdAt'],
          'publishedAt' => gmdate('c'),
        ];
        unset($data['queue'][$idx]);
        $data['queue'] = array_values($data['queue']);
        save_data((string)$CFG['data_file'], $data);
        $notice = 'Published.';
      }
    }

    if ($isAdmin && $action === 'admin_deny') {
      $id = (string)($_POST['id'] ?? '');
      [$idx, $row] = find_by_id($data['queue'], $id);
      if ($idx < 0) $error = 'Queue item not found.';
      else {
        unset($data['queue'][$idx]);
        $data['queue'] = array_values($data['queue']);
        save_data((string)$CFG['data_file'], $data);
        $notice = 'Removed.';
      }
    }
  }
}

// ---------- view routing ----------
$postSlug = clean_one_line((string)($_GET['post'] ?? ''), 200);
$viewPost = $postSlug !== '' ? find_post_by_slug($data['posts'], $postSlug) : [];

$showSubmit = isset($_GET['submit']) && !$isAdmin;

usort($data['posts'], function($a,$b){
  return strcmp((string)($b['publishedAt'] ?? ''), (string)($a['publishedAt'] ?? ''));
});
usort($data['queue'], function($a,$b){
  return strcmp((string)($b['createdAt'] ?? ''), (string)($a['createdAt'] ?? ''));
});

// ---------- HTML ----------
$title = $CFG['site_name'] . ' — ' . $CFG['tool_name'];
?><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><?php echo h($title); ?></title>
<meta name="description" content="Submit guest post drafts for review. Admin can approve/publish. No database." />
<meta name="robots" content="index,follow" />
<style>
  :root{
    --bg:#0b0f16; --panel:#111a26; --panel2:#0c1420;
    --text:#e8eef8; --muted:rgba(232,238,248,.72);
    --line:rgba(255,255,255,.10); --accent:#2a8cff;
    --shadow:0 18px 55px rgba(0,0,0,.45); --r:16px;
  }
  *{box-sizing:border-box}
  body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif}
  a{color:#8ad1ff;text-decoration:none}
  a:hover{text-decoration:underline}
  .wrap{max-width:980px;margin:0 auto;padding:18px}
  .card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:14px;margin:12px 0;box-shadow:var(--shadow)}
  .muted{color:var(--muted)}
  .top{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:center}
  .pill{display:inline-block;padding:4px 10px;border-radius:999px;background:rgba(42,140,255,.14);border:1px solid rgba(42,140,255,.25);font-weight:900;font-size:12px}
  .btn{display:inline-flex;gap:8px;align-items:center;border:0;border-radius:12px;padding:10px 12px;background:var(--accent);color:#06101a;font-weight:900;cursor:pointer}
  .btn2{display:inline-flex;gap:8px;align-items:center;border:1px solid var(--line);border-radius:12px;padding:10px 12px;background:transparent;color:var(--text);font-weight:900;cursor:pointer}
  input,textarea{
    width:100%;padding:10px 12px;border-radius:12px;border:1px solid var(--line);
    background:var(--panel2);color:var(--text);outline:none
  }
  textarea{min-height:220px;resize:vertical}
  .grid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:12px}
  .col7{grid-column:span 7}
  .col5{grid-column:span 5}
  @media(max-width:920px){.col7,.col5{grid-column:span 12}}
  .hr{height:1px;background:var(--line);margin:12px 0}
  .note-title{margin:0 0 6px;font-size:18px}
  .meta{font-size:12px;color:var(--muted)}
  .body{margin-top:10px;white-space:pre-wrap;line-height:1.55;color:rgba(232,238,248,.88)}
  .warn{background:#2a0f14;border-color:rgba(255,124,124,.25)}
  .ok{background:#0e2233;border-color:rgba(42,140,255,.25)}
</style>
</head>
<body>
<div class="wrap">

  <div class="card">
    <div class="top">
      <div>
        <div class="pill"><?php echo $isAdmin ? 'Admin' : 'Guest Posts'; ?></div>
        <h1 style="margin:10px 0 6px;font-size:22px"><?php echo h((string)$CFG['tool_name']); ?></h1>
        <div class="muted"><?php echo $isAdmin ? 'Review drafts, edit, approve, publish.' : 'Submit a draft for review. Plain text only (safe).'; ?></div>
      </div>
      <div style="display:flex;gap:10px;flex-wrap:wrap">
        <?php if (!$isAdmin): ?>
          <a class="btn" href="?submit=1">✍️ Submit a draft</a>
          <a class="btn2" href="./">📰 Latest posts</a>
        <?php else: ?>
          <a class="btn2" href="./">↩ Public view</a>
        <?php endif; ?>
      </div>
    </div>

    <?php if ($notice): ?>
      <div class="card ok"><?php echo h($notice); ?></div>
    <?php endif; ?>
    <?php if ($error): ?>
      <div class="card warn"><?php echo h($error); ?></div>
    <?php endif; ?>
  </div>

  <?php if ($isAdmin): ?>
    <div class="card">
      <b>Queue:</b> <?php echo count($data['queue']); ?> •
      <b>Published:</b> <?php echo count($data['posts']); ?>
      <div class="muted" style="margin-top:6px">Tip: Edit a draft before publishing. Slugs must be unique among published posts.</div>
    </div>

    <?php if (!$data['queue']): ?>
      <div class="card"><span class="muted">No drafts in the queue.</span></div>
    <?php endif; ?>

    <?php foreach ($data['queue'] as $q): ?>
      <div class="card">
        <div class="grid">
          <div class="col7">
            <h2 class="note-title"><?php echo h((string)$q['title']); ?></h2>
            <div class="meta">
              Submitted: <?php echo h((string)$q['createdAt']); ?>
              <?php if (!empty($q['author'])): ?> • Author: <?php echo h((string)$q['author']); ?><?php endif; ?>
              <?php if (!empty($q['email'])): ?> • Email: <?php echo h((string)$q['email']); ?><?php endif; ?>
              • Slug: <code><?php echo h((string)$q['slug']); ?></code>
            </div>
            <div class="body"><?php echo h((string)$q['body']); ?></div>
          </div>

          <div class="col5">
            <b>Edit</b>
            <form method="post" style="margin-top:10px;display:grid;gap:10px">
              <input type="hidden" name="csrf" value="<?php echo h(csrf_token()); ?>">
              <input type="hidden" name="action" value="admin_save">
              <input type="hidden" name="id" value="<?php echo h((string)$q['id']); ?>">

              <div>
                <div class="meta">Title</div>
                <input name="title" value="<?php echo h((string)$q['title']); ?>" maxlength="<?php echo (int)$CFG['max_title']; ?>">
              </div>

              <div>
                <div class="meta">Slug</div>
                <input name="slug" value="<?php echo h((string)$q['slug']); ?>">
              </div>

              <div>
                <div class="meta">Author</div>
                <input name="author" value="<?php echo h((string)($q['author'] ?? '')); ?>" maxlength="<?php echo (int)$CFG['max_name']; ?>">
              </div>

              <div>
                <div class="meta">Email</div>
                <input name="email" value="<?php echo h((string)($q['email'] ?? '')); ?>" maxlength="<?php echo (int)$CFG['max_email']; ?>">
              </div>

              <div>
                <div class="meta">Body (plain text)</div>
                <textarea name="body" maxlength="<?php echo (int)$CFG['max_body']; ?>"><?php echo h((string)$q['body']); ?></textarea>
              </div>

              <div style="display:flex;gap:10px;flex-wrap:wrap">
                <button class="btn2" type="submit">💾 Save</button>
              </div>
            </form>

            <div class="hr"></div>

            <form method="post" style="display:flex;gap:10px;flex-wrap:wrap">
              <input type="hidden" name="csrf" value="<?php echo h(csrf_token()); ?>">
              <input type="hidden" name="id" value="<?php echo h((string)$q['id']); ?>">
              <button class="btn" name="action" value="admin_approve" type="submit">✅ Publish</button>
              <button class="btn2" name="action" value="admin_deny" type="submit" onclick="return confirm('Remove this draft?');">🗑 Remove</button>
            </form>
          </div>
        </div>
      </div>
    <?php endforeach; ?>

  <?php elseif ($viewPost): ?>
    <div class="card">
      <a class="muted" href="./">← Back to posts</a>
      <h2 class="note-title" style="margin-top:10px"><?php echo h((string)$viewPost['title']); ?></h2>
      <div class="meta">
        Published: <?php echo h((string)($viewPost['publishedAt'] ?? '')); ?>
        <?php if (!empty($viewPost['author'])): ?> • By <?php echo h((string)$viewPost['author']); ?><?php endif; ?>
      </div>
      <div class="body"><?php echo h((string)$viewPost['body']); ?></div>
    </div>

  <?php elseif ($showSubmit): ?>
    <div class="card">
      <h2 class="note-title">Submit a guest post draft</h2>
      <div class="muted">Plain text only. Keep it useful. No link drops.</div>

      <form method="post" style="margin-top:12px;display:grid;gap:10px">
        <input type="hidden" name="csrf" value="<?php echo h(csrf_token()); ?>">
        <input type="hidden" name="action" value="submit_guest">

        <div>
          <div class="meta">Title</div>
          <input name="title" required maxlength="<?php echo (int)$CFG['max_title']; ?>" placeholder="A clear, specific title…">
        </div>

        <div class="grid">
          <div class="col7">
            <div class="meta">Draft (plain text)</div>
            <textarea name="body" required maxlength="<?php echo (int)$CFG['max_body']; ?>" placeholder="Write the full draft here…"></textarea>
          </div>
          <div class="col5">
            <div class="meta">Author name (optional)</div>
            <input name="name" maxlength="<?php echo (int)$CFG['max_name']; ?>" placeholder="Name or handle">

            <div class="meta" style="margin-top:10px">Email (optional)</div>
            <input name="email" maxlength="<?php echo (int)$CFG['max_email']; ?>" placeholder="you@example.com">

            <!-- honeypot -->
            <div style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden">
              <label>Leave blank <input name="<?php echo h((string)$CFG['honeypot']); ?>" autocomplete="off"></label>
            </div>

            <div class="muted" style="margin-top:10px;font-size:13px;line-height:1.4">
              Tips:
              <ul style="margin:8px 0 0; padding-left:18px">
                <li>Be original + practical.</li>
                <li>No link dumps.</li>
                <li>Include steps / code / examples.</li>
              </ul>
            </div>
          </div>
        </div>

        <div>
          <button class="btn" type="submit">📨 Submit draft</button>
          <a class="btn2" href="./">Cancel</a>
        </div>
      </form>
    </div>

  <?php else: ?>
    <div class="card">
      <div class="top">
        <div>
          <h2 class="note-title" style="margin:0">Latest guest posts</h2>
          <div class="muted">Curated + approved drafts only.</div>
        </div>
        <a class="btn" href="?submit=1">✍️ Submit a draft</a>
      </div>
    </div>

    <?php if (!$data['posts']): ?>
      <div class="card"><span class="muted">No posts yet.</span></div>
    <?php else: ?>
      <?php foreach (array_slice($data['posts'], 0, 30) as $p): ?>
        <div class="card">
          <h3 class="note-title" style="margin:0 0 6px">
            <a href="?post=<?php echo h((string)$p['slug']); ?>"><?php echo h((string)$p['title']); ?></a>
          </h3>
          <div class="meta">
            <?php echo h((string)($p['publishedAt'] ?? '')); ?>
            <?php if (!empty($p['author'])): ?> • By <?php echo h((string)$p['author']); ?><?php endif; ?>
          </div>
          <div class="muted" style="margin-top:8px"><?php echo h(excerpt((string)$p['body'], 220)); ?></div>
        </div>
      <?php endforeach; ?>
    <?php endif; ?>

  <?php endif; ?>

  <div class="card">
    <div class="muted" style="font-size:13px;line-height:1.4">
      <b>Owner tip:</b> Keep this curated. If you want to allow links later, add them yourself during review.
    </div>
  </div>

</div>
</body>
</html>

Comments (0)

No comments yet — be the first.

← Back to all scripts