Website Feedback Button — Screenshot + Comment
This is a lightweight feedback button you can drop onto any website. Visitors can click it, type a quick note, and submit feedback that includes the current page URL (and an optional screenshot).
👉 Run it here: /tools/feedback/feedback.php
What it does:
- Adds a small floating Feedback button (bottom-right).
- Opens a clean modal for a comment + optional email.
- Captures the current page URL automatically.
- Optional: lets the user attach a screenshot image (simple file upload).
- Saves feedback as
.jsonfiles on your server (no database). - Includes basic spam protection: honeypot + rate limit.
Install:
- Create a folder:
/tools/feedback/(or anywhere you want) - Put the PHP file below in it as:
feedback.php - Make sure this folder is writable:
/tools/feedback/_inbox/(the script will create it) - Paste the embed snippet (also below) on any pages you want the button to appear
<?php
declare(strict_types=1);
/**
* Tiny Website Feedback Button (No DB)
* Endpoint: POST to this file
* Stores submissions as JSON files in /_inbox
*
* Frontend embed snippet is at the bottom of this file (copy it into your site).
*/
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
$CFG = [
'inbox_dir' => __DIR__ . '/_inbox',
'max_text' => 2000,
'max_email' => 120,
'max_file_mb' => 3, // screenshot max size
'allowed_ext' => ['png','jpg','jpeg','webp'],
'rate_hits' => 5, // max submits...
'rate_window' => 600, // ...per 10 minutes
];
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }
function ensure_dir(string $dir): bool {
return is_dir($dir) || @mkdir($dir, 0700, true);
}
function ip(): string { return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; }
function rl_path(string $key): string {
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'fb_rl_' . sha1($key) . '.json';
}
function rate_limit(string $scope, int $maxHits, int $window): array {
$key = $scope . '|' . ip() . '|' . (int)floor(time() / max(1, $window));
$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 < 2048) { 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 json_out(array $a, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($a, JSON_UNESCAPED_SLASHES);
exit;
}
function clean_text(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 valid_email(string $s): bool {
if ($s === '') return true;
if (strlen($s) > 200) return false;
return filter_var($s, FILTER_VALIDATE_EMAIL) !== false;
}
function save_upload(array $file, array $CFG): array {
// returns [saved(bool), path(string), error(string)]
if (empty($file['tmp_name']) || (int)($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
return [false, '', ''];
}
if ((int)$file['error'] !== UPLOAD_ERR_OK) return [false, '', 'Upload error'];
$maxBytes = (int)$CFG['max_file_mb'] * 1024 * 1024;
if ((int)$file['size'] > $maxBytes) return [false, '', 'File too large'];
$name = (string)($file['name'] ?? '');
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if ($ext === '' || !in_array($ext, $CFG['allowed_ext'], true)) return [false, '', 'Unsupported file type'];
$destName = gmdate('Ymd_His') . '_' . bin2hex(random_bytes(6)) . '.' . $ext;
$destPath = rtrim($CFG['inbox_dir'], DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $destName;
if (!@move_uploaded_file((string)$file['tmp_name'], $destPath)) return [false, '', 'Failed to store upload'];
return [true, $destName, ''];
}
// ---------- handle POST ----------
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
[$ok, $retry] = rate_limit('fb_submit', (int)$CFG['rate_hits'], (int)$CFG['rate_window']);
if (!$ok) json_out(['ok'=>false,'error'=>'Rate limited. Try again soon.','retry_after'=>$retry], 429);
if (!ensure_dir($CFG['inbox_dir'])) json_out(['ok'=>false,'error'=>'Inbox directory not writable'], 500);
// Honeypot
$hp = (string)($_POST['companyfax'] ?? '');
if ($hp !== '') json_out(['ok'=>false,'error'=>'Spam detected'], 400);
$msg = clean_text((string)($_POST['message'] ?? ''), (int)$CFG['max_text']);
$url = clean_text((string)($_POST['page_url'] ?? ''), 500);
$mail = clean_text((string)($_POST['email'] ?? ''), (int)$CFG['max_email']);
if ($msg === '' || strlen($msg) < 3) json_out(['ok'=>false,'error'=>'Please enter a message.'], 400);
if ($mail !== '' && !valid_email($mail)) json_out(['ok'=>false,'error'=>'Email looks invalid.'], 400);
$shotName = '';
if (!empty($_FILES['screenshot'])) {
[$saved, $name, $uerr] = save_upload($_FILES['screenshot'], $CFG);
if ($uerr !== '') json_out(['ok'=>false,'error'=>$uerr], 400);
if ($saved) $shotName = $name;
}
$entry = [
'createdAt' => gmdate('c'),
'ip' => ip(),
'pageUrl' => $url,
'email' => $mail,
'message' => $msg,
'screenshot'=> $shotName,
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
];
$id = gmdate('Ymd_His') . '_' . bin2hex(random_bytes(8));
$path = rtrim($CFG['inbox_dir'], DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $id . '.json';
if (@file_put_contents($path, json_encode($entry, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES), LOCK_EX) === false) {
json_out(['ok'=>false,'error'=>'Failed to save feedback'], 500);
}
json_out(['ok'=>true,'id'=>$id]);
}
// ---------- simple embed snippet (copy/paste) ----------
?>
<!--
EMBED SNIPPET (copy into your site near </body>)
<script>
(function(){
var ENDPOINT = "/tools/feedback/feedback.php"; // change path if needed
// Button
var btn = document.createElement("button");
btn.textContent = "Feedback";
btn.setAttribute("type","button");
btn.style.position="fixed";
btn.style.right="14px";
btn.style.bottom="14px";
btn.style.zIndex="9999";
btn.style.border="0";
btn.style.borderRadius="999px";
btn.style.padding="10px 14px";
btn.style.fontWeight="800";
btn.style.cursor="pointer";
btn.style.background="#2a8cff";
btn.style.color="#06101a";
btn.style.boxShadow="0 18px 50px rgba(0,0,0,.35)";
// Modal
var modal = document.createElement("div");
modal.style.position="fixed";
modal.style.inset="0";
modal.style.background="rgba(0,0,0,.55)";
modal.style.zIndex="10000";
modal.style.display="none";
modal.style.alignItems="center";
modal.style.justifyContent="center";
modal.innerHTML = ''
+ '<div style="width:min(520px,92vw);background:#111a26;border:1px solid rgba(255,255,255,.10);border-radius:16px;padding:14px;color:#e8eef8;font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;">'
+ ' <div style="display:flex;justify-content:space-between;align-items:center;gap:10px;">'
+ ' <div style="font-weight:900;font-size:16px;">Send feedback</div>'
+ ' <button type="button" data-x style="background:transparent;border:0;color:#e8eef8;font-size:20px;cursor:pointer;">×</button>'
+ ' </div>'
+ ' <div style="color:rgba(232,238,248,.75);font-size:13px;margin-top:6px;">Includes this page URL automatically.</div>'
+ ' <form data-f style="margin-top:12px;display:grid;gap:10px;">'
+ ' <input name="email" placeholder="Email (optional)" style="width:100%;padding:10px;border-radius:12px;border:1px solid rgba(255,255,255,.12);background:#0c1420;color:#e8eef8;">'
+ ' <textarea name="message" required placeholder="What’s wrong or what could be better?" style="width:100%;min-height:110px;padding:10px;border-radius:12px;border:1px solid rgba(255,255,255,.12);background:#0c1420;color:#e8eef8;resize:vertical;"></textarea>'
+ ' <input type="file" name="screenshot" accept="image/*" style="color:rgba(232,238,248,.75);font-size:13px;">'
+ ' <input name="companyfax" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;">'
+ ' <button type="submit" style="border:0;border-radius:12px;padding:10px 12px;font-weight:900;background:#2a8cff;color:#06101a;cursor:pointer;">Send</button>'
+ ' <div data-msg style="font-size:13px;color:rgba(232,238,248,.75)"></div>'
+ ' </form>'
+ '</div>';
function open(){ modal.style.display="flex"; }
function close(){ modal.style.display="none"; }
btn.addEventListener("click", open);
modal.addEventListener("click", function(e){ if(e.target===modal) close(); });
modal.querySelector("[data-x]").addEventListener("click", close);
modal.querySelector("[data-f]").addEventListener("submit", function(e){
e.preventDefault();
var msg = modal.querySelector("[data-msg]");
msg.textContent = "Sending...";
var fd = new FormData(e.target);
fd.append("action","send");
fd.append("page_url", location.href);
fetch(ENDPOINT, { method:"POST", body: fd, credentials:"omit" })
.then(function(r){ return r.json().catch(function(){ return {ok:false,error:"Bad response"}; }); })
.then(function(j){
if (j.ok){
msg.textContent = "Sent. Thank you!";
e.target.reset();
setTimeout(close, 650);
} else {
msg.textContent = j.error || "Failed to send.";
}
})
.catch(function(){
msg.textContent = "Network error.";
});
});
document.body.appendChild(btn);
document.body.appendChild(modal);
})();
</script>
-->
Comments (0)
No comments yet — be the first.