mgx

MyGB: brew your own guestbook using Cloudflare Workers

Apologies if it's a bit rough -- paper cut on my index finger, but I wanted to share this anyway. Sign the MyGB guestbook here. ![](https://static.mighil.com/images/2026/mygb.webp) [MyGB](https://github.com/verfasor/MyGB) is a beefed-up version of the guestbook script originally mentioned in "[brewing a guestbook using cloudflare workers](https://mgx.bearblog.dev/brewing-a-guestbook-using-cloudflare-workers/)". This worker.js lets you host your own guestbook for free using Cloudflare's global network. It's designed to be lightweight and easy to embed anywhere. There is no middle man, no multi tenancy -- you own the code and you own the database. ## How it works * **The Engine (Cloudflare Workers):** The code runs on Cloudflare's edge servers. This makes your guestbook load instantly for visitors, no matter where they are. * **The Memory (Cloudflare D1):** Your data (messages, settings) is stored in D1, a SQL database that lives on the edge. * **The Look (HTML/CSS):** The user interface is generated directly by the worker. No complex build steps or frontend frameworks like React or Vue are needed..just fast, raw HTML and CSS. The entire application lives in a single file [worker.js](https://github.com/verfasor/MyGB/blob/main/worker.js), making it incredibly easy to manage and deploy. ## Philosophy **AI-Ready**: The code is extensively commented and structured to be easily understood. You can drop `worker.js` into your favorite AI agent or coding assistant to add new features, change the design, or customize it to your liking. **Open Source (AGPL v3)**: The source code is licensed under **GNU AGPL v3**. Please ensure that any "good" derivatives or improvements you make remain open source so others can benefit from them. ## Your project, your rules Consider MyGB as a primer or a starting foundation. You are encouraged to optimize the code, improve the database schema, or rewrite entire sections to fit your specific needs. The goal is to give you a solid, working guestbook that you can truly make your own. ## Deployment guide (no coding required) You can deploy this guestbook directly from the Cloudflare Dashboard without touching a command line. If you are a developer, you may refer to [getting-started.md](https://github.com/verfasor/MyGB/blob/main/getting-started.md) on GitHub repo. ### Prerequisites - A Cloudflare account (Free tier is fine) ### Step 1: create a D1 database ![](https://static.mighil.com/images/2026/mygb-create-d1-database.webp?v2) 1. Log in to your Cloudflare Dashboard. 2. Go to **Storage & databases** > **D1 SQL database**. 3. Click **Create database**. 4. Name it `guestbook-db` (or anything you like). 5. Click **Create**. ### Step 2: create the Worker 1. Go to **Compute & AI** > **Workers & Pages** > **Overview**. 2. Click **Create application**. ![](https://static.mighil.com/images/2026/mygb-create-cloudflare-worker.webp) 3. Choose **"Start with Hello World!"** 4. Name your worker (e.g., `my-guestbook`). ![](https://static.mighil.com/images/2026/mygb-set-worker-name.webp) 6. Click **Deploy** (this creates a default Hello World worker and redirects you to the Worker overview page). 7. Click **Edit code**. ### Step 3: add the code 1. In the online code editor, you'll see a `worker.js` file. 2. Delete all the existing code in that file. 3. Copy the entire content of [worker.js](https://github.com/verfasor/MyGB/blob/main/worker.js) from the repository and paste it into the editor. 4. Deploy it. ![](https://static.mighil.com/images/2026/mygb-worker-edit-page.webp) > Ignore the `{"success":false,"error":"Cannot read properties of undefined (reading 'prepare')"}` error -- we will bind Database in the next step. ### Step 4: bind the database ![](https://static.mighil.com/images/2026/mygb-database-binding.gif) 1. Go back to your Worker's overview page (click the back arrow or navigate via dashboard). 2. Click the **Bindings** tab. 3. Click **Add binding**. 4. Choose **D1 Database**. 5. Variable name: `DB` (Must be exactly `DB`). 6. Database: Select the database you created in Step 1. 7. Cloudflare will automatically redeploy. ### Step 5: configure environment variables ![](https://static.mighil.com/images/2026/mygb-set-env.gif) 1. In **Settings** tab of Worker, look for the **Variables and Secrets** section. 2. Click **Add**. 3. Add the following variables:
Variable Name Value Type Note
ADMIN_PASSWORD your-secure-password Secret Password for the admin panel
SESSION_SECRET long-random-string Secret Used to sign cookies (generate a random string)
API_URL https://your-worker.workers.dev Text (Optional) Required if you use a custom domain or embed on another site
4. Click **Deploy** to apply changes. ### Step 6: initialize the database The application will automatically create the necessary database tables (`entries` and `settings`) when you first access it. **No manual SQL execution is required!** If you prefer to manually initialize the database, you can run the following SQL in the D1 Console: ```sql CREATE TABLE IF NOT EXISTS entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, message TEXT NOT NULL, site TEXT, email TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), approved INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_approved ON entries(approved); CREATE INDEX IF NOT EXISTS idx_created_at ON entries(created_at); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT ); ``` ### Step 7: custom domain (optional) ![](https://static.mighil.com/images/2026/mygb-custom-domain.webp) Instead of the default `my-guestbook.your-subdomain.workers.dev`, you can use your own domain (e.g., `guestbook.example.com`). 1. Go to your Worker's **Settings** > **Domains & Routes**. 2. Click **Add Custom Domain**. 3. Enter the domain you want to use (it must be a domain active in your Cloudflare account). **Important for Turnstile:** If you use a custom domain, you must update your Turnstile widget settings: * Add your custom domain (e.g., `guestbook.example.com`) to the allowed domains list in the Turnstile dashboard. * If you plan to **embed** the guestbook on another site (e.g., `myblog.com`), add that domain to the Turnstile allowed list as well. > See Turnstile configuration in [security settings](#security). **Recommendation for API_URL:** If you are using a custom domain, it is highly recommended to add the `API_URL` variable in your Worker settings (e.g., `https://guestbook.example.com`). This ensures that if you visit the worker directly or use the embed code, the client-side scripts will always point to the correct custom domain API. ### Step 8: done! Visit your Worker's URL (e.g., `https://my-guestbook.your-subdomain.workers.dev`). - **Guestbook:** The home page is your guestbook. - **Admin Panel:** Go to `/login` to access the admin dashboard using the password you set. - **Embed:** Go to `/admin/embed` to get the code snippet for your website. ## Features & configuration Once logged into the Admin Panel, you can configure the guestbook via the **Settings** tab. These settings are stored in your D1 database. ### General settings - **Site Name:** The title of your guestbook page and HTML `` tag. - **Site Intro:** A welcome message displayed above the form. Supports basic HTML. - **Site Description:** Used for SEO and social media meta description tags. - **Site Icon & Cover Image:** Customize the look of your guestbook when shared on social media. - **Canonical URL:** Important for SEO if you embed the guestbook on another site. - **Search Engine Indexing:** Control whether search engines should index your guestbook. ### Navigation ![](https://static.mighil.com/images/2026/mygb-admin-navigation.webp) You can add custom links to the header (e.g., "Back to Home", "Portfolio") using the Navigation builder in settings. ### Moderation - **Require Approval:** If enabled, new entries will not appear publicly until you approve them in the Admin Panel. The Worker enables check-mark by default. - **Admin Actions:** You can Approve or Delete entries from the dashboard. ### Security MyGB supports **Cloudflare Turnstile** integration out of the box to prevent spam. The Worker enables it by default. Here is how to set it up: 1. **Get the Keys:** * Log in to your Cloudflare Dashboard. * Go to **Turnstile** (available in the Application security sidebar). * Click **Add Site**. * **Site Name:** e.g., `My Guestbook`. * **Domain:** Enter your worker's domain (e.g., `my-guestbook.your-subdomain.workers.dev`). If you use a custom domain, add it as well. Also include the Bear blog or website where the guestbook will be embedded. * **Widget Mode:** Select **Managed**. * Click **Create**. * Copy the **Site Key** and **Secret Key**. 2. **Enable in Guestbook:** * Log in to your Guestbook Admin Panel. * Go to **Settings**. * Scroll to **Security (Cloudflare Turnstile)**. * Check "Enable Turnstile CAPTCHA". * Paste your **Site Key** and **Secret Key**. * Click **Save Settings**. ![](https://static.mighil.com/images/2026/mygb-cf-turnstile.webp) ### Appearance (custom CSS) You can add custom CSS to style the guestbook to match your vibe. - Example: Change the background color. ```css body { background-color: #f0f0f0; } .card { border-radius: 0px; } ``` ### Data export You can download all your approved entries as **JSON** or **CSV** directly from the Settings page. ## How client-side JavaScript works ![](https://static.mighil.com/images/2026/mygb-client-js.webp) The guestbook uses lightweight, vanilla JavaScript to handle interactivity: 1. **Date Formatting:** Timestamps are converted to the visitor's local timezone in their browser. 2. **AJAX Form Submission:** New entries are submitted without reloading the page. 3. **"Load More" Pagination:** Older entries are fetched by clicking "Load More". 4. **Embed Widget:** The embed code loads a small script (`/client.js`) that renders the guestbook inside a container on your external site. You can get the copy-paste ready embed code from your Admin Panel at `/admin/embed`. ### The process (example) ![](https://static.mighil.com/images/2026/mygb-js-in-bearblog.webp) <small>Add MyGB widget to the blog</small> ![](https://static.mighil.com/images/2026/mygb-client-side.webp) <small>Add a sample entry and submit</small> ![](https://static.mighil.com/images/2026/mygb-admin-moderation.webp) <small>Approve or delete entry in /admin/.</small> ![](https://static.mighil.com/images/2026/mygb-render-entry.webp) <small>Render the entry in front-end.</small></div> <div class="post-tags"> <p style="padding-top:1rem;"><strong>Tagged in</strong> <a href="/archive?q=cloudflare">cloudflare</a>, <a href="/archive?q=tech">tech</a></p> </div> </main> <form id="upvote-form-fJjTCPiNxJfEUaHwIMgm" style="display: inline"> <small> <input type="hidden" name="csrfmiddlewaretoken" value="1za5dJgW8n__J5B5r8PDjuTUaTwdw16VOxx0cxIqKboJGSpFyunMs2A1yMlk7Gs9"> <button class="upvote-button" id="upvote-button-fJjTCPiNxJfEUaHwIMgm" type="button"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" class="css-i6dzq1" width="14" height="14"> <polyline points="17 11 12 6 7 11"></polyline> <polyline points="17 18 12 13 7 18"></polyline></svg> Upvote </button> </small> </form> <nav id="nav-below" class="post-navigation" aria-label="Posts"> <div class="nav-previous"><span class="prev"><a href="/subscriptions-in-2025" data-preview="I'll revisit this page next year." data-title="subscriptions in 2025" id="prev-post">Previous</a></span></div> <div class="nav-next"><span class="next"><a href="/eko-flim-2025-review" data-preview="Bahul Ramesh continues to be one of the finest writers in Malayalam cinema." data-title="eko (film, 2025)" id="next-post">Next</a></span></div></nav> <footer> <div><div id="custom-footer-text"><span id="sub"><button id="font-toggle" aria-label="Toggle font style">sans-serif</button> • <a href="/feed?type=rss">rss</a> • <a href="/feed">atom</a> • <a id="powered-by-bear" href="https://bearblog.dev?ref=mgx.me">powered by bear</a></span><div class="theme-toggle-container"><div id="theme-toggle" class="theme-toggle-switch" role="switch" aria-label="Theme toggle" aria-checked="false" tabindex="0"><div class="theme-option" data-theme="system"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#666" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"></rect><line x1="8" x2="16" y1="21" y2="21"></line><line x1="12" x2="12" y1="17" y2="21"></line></svg></div><div class="theme-option" data-theme="light"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#666" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"></path><circle cx="12" cy="12" r="4"></circle></svg></div><div class="theme-option" data-theme="dark"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#666" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg></div><div class="theme-slider"></div></div></div></div></div> <script>const toggle=document.getElementById('font-toggle');const root=document.documentElement;const fontStyles=['sans-serif','serif','monospace'];let currentIndex=0;function updateButtonText(){toggle.textContent=fontStyles[currentIndex]}function applyFontStyle(style){root.classList.remove('serif','monospace');if(style==='serif'){root.classList.add('serif')}else if(style==='monospace'){root.classList.add('monospace')}localStorage.setItem('fontStyle',style)}function cycleFont(){currentIndex=(currentIndex+1)%fontStyles.length;const newStyle=fontStyles[currentIndex];applyFontStyle(newStyle);updateButtonText()}const savedStyle=localStorage.getItem('fontStyle');if(savedStyle&&fontStyles.includes(savedStyle)){currentIndex=fontStyles.indexOf(savedStyle);applyFontStyle(savedStyle)}updateButtonText();toggle.addEventListener('click',cycleFont)</script> <script>const themeToggle=document.getElementById('theme-toggle');const themeOptions=themeToggle.querySelectorAll('.theme-option');function applyTheme(theme){const prefersDark=window.matchMedia('(prefers-color-scheme: dark)').matches;const finalTheme=theme==='system'?(prefersDark?'dark':'light'):theme;document.documentElement.setAttribute('data-theme',finalTheme);document.documentElement.style.setProperty('color-scheme',finalTheme);themeToggle.setAttribute('data-theme',theme);themeToggle.setAttribute('aria-checked',finalTheme==='dark'?'true':'false');themeOptions.forEach(option=>{option.classList.remove('active');if(option.dataset.theme===theme){option.classList.add('active')}})}function saveTheme(theme){localStorage.setItem('theme',theme)}function getSavedTheme(){return localStorage.getItem('theme')||'system'}function cycleTheme(){const themes=['system','light','dark'];const currentTheme=getSavedTheme();const currentIndex=themes.indexOf(currentTheme);const nextIndex=(currentIndex+1)%themes.length;const nextTheme=themes[nextIndex];applyTheme(nextTheme);saveTheme(nextTheme)}applyTheme(getSavedTheme());themeToggle.addEventListener('click',cycleTheme);themeOptions.forEach(option=>{option.addEventListener('click',(e)=>{e.stopPropagation();const theme=option.dataset.theme;applyTheme(theme);saveTheme(theme)})});window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change',()=>{if(getSavedTheme()==='system'){applyTheme('system')}})</script> <script>function toggleSearchPopup(){const searchPopup=document.getElementById('search-popup');if(searchPopup.style.display==='none'||searchPopup.style.display===''){openSearchPopup()}else{closeSearchPopup()}}function openSearchPopup(){document.getElementById('search-popup').style.display='flex';document.getElementById('search-input').focus();document.body.style.overflow='hidden'}function closeSearchPopup(){document.getElementById('search-popup').style.display='none';document.getElementById('search-input').value='';document.getElementById('search-results').innerHTML='';document.body.style.overflow=''} async function searchPosts(searchTerm){const response=await fetch('/api/search.php?q='+encodeURIComponent(searchTerm));const data=await response.json();return data.results}function displaySearchResults(results){const resultsHtml=results.map(post=>{let postHtml=`<a href="/${post.slug}"> <span class="post-title">${post.title}</span>${post.tags.length ? `<span class="post-tags"> in ${post.tags.join(', ')}</span>` : ''}`;if(post.contentMatch){postHtml+=` <span class="post-tags">& contains '${post.searchTerm}'</span>`} postHtml+=`</a>`;return postHtml}).join('');document.getElementById('search-results').innerHTML=resultsHtml||'<p>No results found</p>'} document.addEventListener('keydown',function(e){if((e.metaKey||e.ctrlKey)&&e.key==='k'){e.preventDefault();e.stopPropagation();toggleSearchPopup()}});document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeSearchPopup()}});document.getElementById('search-link').addEventListener('click',function(e){e.preventDefault();toggleSearchPopup()});document.getElementById('search-popup').addEventListener('click',function(e){if(e.target===this){closeSearchPopup()}});document.getElementById('search-popup').addEventListener('touchstart',function(e){if(e.target===this){closeSearchPopup()}},{passive:!0});document.getElementById('search-input').addEventListener('input',async function(e){const searchTerm=e.target.value;if(searchTerm===''){document.getElementById('search-results').innerHTML=''}else{const results=await searchPosts(searchTerm);displaySearchResults(results)}});</script> <script>document.addEventListener("DOMContentLoaded",()=>{const postId="fJjTCPiNxJfEUaHwIMgm";const upvoteButton=document.getElementById("upvote-button-"+postId);const csrfToken=document.querySelector('#upvote-form-'+postId+' input[name="csrfmiddlewaretoken"]').value;const localStorageKey="upvoted_"+postId;if(localStorage.getItem(localStorageKey)){upvoteButton.disabled=!0;upvoteButton.textContent="Upvoted"} upvoteButton.addEventListener("click",async(event)=>{event.preventDefault();if(!upvoteButton.disabled){try{const response=await fetch(`/api/upvote.php?postId=${postId}`,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","X-CSRF-Token":csrfToken,},});const result=await response.json();if(result.success){localStorage.setItem(localStorageKey,"true");upvoteButton.disabled=!0;upvoteButton.textContent="Upvoted"}else{console.error("Upvote failed:",result.error);alert("Failed to upvote. Please try again.")}}catch(error){console.error("Error:",error);alert("Failed to upvote. Please try again.")}}})});</script> <script>(function(){function formatDateTime(dateString){const date=new Date(dateString);const now=new Date();const options={hour:'2-digit',minute:'2-digit'};const time=date.toLocaleTimeString(undefined,options);if(date.getDate()===now.getDate()&&date.getMonth()===now.getMonth()&&date.getFullYear()===now.getFullYear()){return `Today \u2022 ${time}`}else{const dateOptions={year:'numeric',month:'short',day:'numeric'};const formattedDate=date.toLocaleDateString(undefined,dateOptions);return `${formattedDate} \u2022 ${time}`}} document.addEventListener("DOMContentLoaded",()=>{const postDateElement=document.getElementById("post-date");postDateElement.textContent=formatDateTime(postDateElement.getAttribute("datetime"))})})();</script> <script>function createPreviewElement(){const preview=document.createElement('div');preview.className='link-preview';preview.style.cssText='display:none;position:absolute;line-height:1.3;background:var(--background-color);border:1px solid color-mix(in srgb,var(--text-color) 10%,transparent);border-radius:10px;padding:10px;width:calc(var(--max-width) / 2.5);z-index:1000;';document.body.appendChild(preview);return preview} const preview=createPreviewElement();let currentLink=null;let hideTimeout=null;async function showPreview(link,event){if(window.innerWidth<=768){return} clearTimeout(hideTimeout);const url=link.href;const imageRegex=/\.(jpg|jpeg|png|gif|webp)$/i;const videoRegex=/\.(mp4|webm|ogg)$/i;if(imageRegex.test(url)){preview.innerHTML=`<img src="${url}" style="margin-bottom:-5px;max-width:calc(var(--max-width) / 2.5);min-width:0;border-radius:4px;" />`;preview.style.display='block';positionPreview(event);return} if(videoRegex.test(url)){preview.innerHTML=`<video controls style="max-width:100%;border-radius:4px;"><source src="${url}" type="video/mp4">Your browser does not support the video tag.</video>`;preview.style.display='block';positionPreview(event);return} try{const response=await fetch(`/api/preview.php?url=${encodeURIComponent(url)}`);if(response.status===204){preview.style.display='none';return} const data=await response.json();if(!data.title&&!data.description&&!data.ogImage){preview.style.display='none';return} preview.innerHTML=`${data.title ? `<h4 style="margin:0;font-size:16px;">${data.title}</h4>` : ''} ${data.description ? `<p style="margin:5px 0;font-size:14px;">${data.description}</p>` : ''} ${data.ogImage ? `<img src="${data.ogImage}" style="margin-bottom:-5px;margin-top:5px;max-width:calc(var(--max-width) / 2.5);min-width:0 !important;border-radius:4px;"/>` : ''} `;preview.style.display='block';positionPreview(event)}catch(error){preview.style.display='none'}} function positionPreview(event){const rect=event.target.getBoundingClientRect();const previewHeight=preview.offsetHeight||150;const viewportHeight=window.innerHeight;const scrollY=window.scrollY;const buffer=10;const spaceAbove=rect.top;const spaceBelow=viewportHeight-rect.bottom;preview.style.left=`${rect.left}px`;if(spaceBelow>=previewHeight+buffer){preview.style.top=`${rect.bottom + scrollY + buffer}px`;preview.dataset.position='below'}else if(spaceAbove>=previewHeight+buffer){preview.style.top=`${rect.top + scrollY - previewHeight - buffer}px`;preview.dataset.position='above'}else{if(spaceBelow>spaceAbove){preview.style.top=`${rect.bottom + scrollY + buffer}px`;preview.dataset.position='below'}else{preview.style.top=`${rect.top + scrollY - previewHeight - buffer}px`;preview.dataset.position='above'}} const previewRect=preview.getBoundingClientRect();const previewWidth=preview.offsetWidth;if(previewRect.right>window.innerWidth){preview.style.left=`${window.innerWidth - previewWidth - buffer}px`} if(previewRect.left<0){preview.style.left=`${buffer}px`}} function setupLinkPreview(){document.querySelectorAll('main.single-post p a, main.single-post li a, .message a:not(.orma-date a)').forEach(link=>{link.addEventListener('mouseenter',(event)=>{currentLink=link;showPreview(link,event)});link.addEventListener('mouseleave',()=>{hideTimeout=setTimeout(()=>{if(preview.matches(':hover'))return;preview.style.display='none'},300)})});preview.addEventListener('mouseleave',()=>{hideTimeout=setTimeout(()=>{preview.style.display='none'},300)})} document.addEventListener('DOMContentLoaded',setupLinkPreview);</script> <script>document.querySelectorAll('.post-navigation a').forEach(link=>{link.addEventListener('mouseenter',()=>{link.textContent=link.getAttribute('data-title')});link.addEventListener('mouseleave',()=>{link.textContent=link.id==='prev-post' ? 'Previous':'Next'})});</script> <script>document.addEventListener('keydown',function(e){if(e.key==='ArrowLeft'){const nextLink=document.getElementById('prev-post');if(nextLink){window.location.href=nextLink.href}}else if(e.key==='ArrowRight'){const prevLink=document.getElementById('next-post');if(prevLink){window.location.href=prevLink.href}}})</script> <!--check sp#m source--><script defer data-domain="mgx.me" src="https://d.mgx.me/js/script.js"></script> </footer> </body> </html>