Website Feedback Button — Screenshot + Comment

January 10, 2026

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 .json files on your server (no database).
  • Includes basic spam protection: honeypot + rate limit.

Install:

  1. Create a folder: /tools/feedback/ (or anywhere you want)
  2. Put the PHP file below in it as: feedback.php
  3. Make sure this folder is writable: /tools/feedback/_inbox/ (the script will create it)
  4. 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.

← Back to all scripts