Guest Post Inbox (Approve + Publish, No DB)
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:
- Create:
/tools/guestpost/ - Save the script below as:
/tools/guestpost/index.php - Edit the
CONFIGat the top (set your site name + admin token) - Make sure the folder is writable (it will create
guestpost.json) - Visit:
- Public:
/tools/guestpost/ - Admin:
/tools/guestpost/?admin=YOUR_TOKEN
- Public:
<?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.