Skip to content

paulfxyz/meet

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

2 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

meet

License: MIT PHP React SQLite Apache Tailwind CSS

Personal meeting link shortener. Turn long Zoom, Google Meet, or Teams URLs into clean, branded short links β€” with proper OG preview cards in WhatsApp, Slack, iMessage, and X.

https://us06web.zoom.us/j/84581102465?pwd=JM9zb3VPMZRDbDjP24GUT3LVJC9TRi.1
                        ↓
              https://meet.yourdomain.com/dcda3f17

Built for meet.paulfleury.com β€” fork it and make it yours in 10 minutes.


Why this exists

I use Zoom and cal.com for every meeting. The problem: Zoom URLs are hideous 80-character strings with session tokens. When you drop one into WhatsApp or send it over email, it looks like spam. cal.com gives you a booking page, not a direct join link.

What I wanted:

  • A root URL (meet.paulfleury.com) that forwards people to my calendar
  • A tool to shorten any one-off meeting link into a clean meet.paulfleury.com/xxxxxxxx URL
  • That short URL shows a proper preview card (title, description, image) when shared in any chat app
  • The redirect is instant β€” no spinner, no loading screen, just gone

This is that tool.


Demo

URL What happens
meet.paulfleury.com Instant redirect to cal.com/paulfxyz
meet.paulfleury.com/link Link creator UI
meet.paulfleury.com/dcda3f17 Instant redirect to the original Zoom URL

How it works β€” the full picture

Understanding the architecture helps if you're self-hosting or debugging.

Request flow

Browser hits meet.paulfleury.com/dcda3f17
        β”‚
        β–Ό
   nginx (SiteGround reverse proxy)
        β”‚
        β–Ό
   Apache + mod_rewrite (.htaccess)
        β”‚
        β”œβ”€ /               β†’ index.html  (JS redirects to cal.com)
        β”œβ”€ /link           β†’ app.html    (React SPA for the creator UI)
        β”œβ”€ /dcda3f17       β†’ dcda3f17.html  ← static HTML written by api.php
        β”œβ”€ /api/links      β†’ api.php?action=create
        └─ /api/links/hash β†’ api.php?action=get&hash=dcda3f17

The static HTML trick

When you create a short link, api.php does two things:

  1. Inserts a row into SQLite β€” stores hash, original URL, title, timestamp
  2. Writes a static .html file to disk β€” e.g. dcda3f17.html

That static file contains:

  • Full Open Graph + Twitter Card meta tags (hardcoded title, description, image)
  • <meta http-equiv="refresh" content="0;url=https://zoom.us/..."> β€” zero-delay redirect
  • A plain <a href> fallback for accessibility

No PHP runs when the short link is visited. No JavaScript either. It's pure HTML served directly by Apache. This is why the redirect works in every in-app browser, every bot crawler, and every context where JS is blocked or slow.

Why not just redirect from PHP?

A PHP redirect (header("Location: ...")) would work for browsers, but WhatsApp, Slack, and Telegram's crawlers would see an empty page with no meta tags β€” because the OG tags would need to be in the HTML response, not in a redirect header.

With static HTML, every request β€” whether from a human browser, WhatsApp's preview bot, or Slack's link unfurler β€” gets back a real HTML page with the correct OG tags baked in.

Why hash-based routing on the frontend?

The React SPA (used for /link) uses hash-based routing (/#/link, /#/:hash) via wouter's useHashLocation. This is intentional: the app is deployed to shared Apache hosting without Node.js. Path-based routing (e.g. /link serving the SPA directly) requires the server to return index.html for every path β€” which conflicts with the api.php routes and the static dcda3f17.html files. Hash routing sidesteps this entirely: Apache doesn't care what comes after #, so the SPA handles it client-side.

The short links themselves (/dcda3f17) are NOT hash-routed β€” they're served as real static HTML files by Apache. This is the key distinction:

URL Served by Contains
/#/link React SPA (hash route) Link creator component
/dcda3f17 Apache (static file) Hardcoded OG + meta refresh

The problems we hit β€” and how we solved them

Building this looked simple. It wasn't. Here's every real issue we ran into, in order.


Problem 1 β€” /link was redirecting to cal.com

What happened: Visiting /link sent you straight to cal.com instead of showing the creator UI.

Why: index.html had a JS snippet that fired on any path without a hash:

if (window.location.hash === '' || window.location.hash === '#') {
  window.location.replace('https://cal.com/paulfxyz');
}

Since /link has no hash, it triggered the cal.com redirect before React even loaded.

Fix: Apache needs to intercept /link before index.html is served. Added a dedicated .htaccess rule:

RewriteRule ^link/?$ app.html [L]

app.html is a separate SPA shell (same React bundle, no cal.com redirect script). The root index.html keeps its JS redirect for bare / visits.


Problem 2 β€” Hash URLs (/#/dcda3f17) were also going to cal.com

What happened: After fixing /link, the React SPA would generate a URL like /#/dcda3f17. But if you opened that URL cold (e.g. pasted it into a new tab), it went to cal.com.

Why: The JS check in index.html only looked at window.location.hash === '' β€” but /#/dcda3f17 has a non-empty hash. The real bug was that the check wasn't distinguishing between "no hash" and "hash present":

// Bug: this fires even when hash is '#/dcda3f17'
if (!window.location.hash) { ... }

Fix: Updated the condition to only redirect when hash is truly empty or just #:

if (window.location.hash === '' || window.location.hash === '#') {
  window.location.replace('https://cal.com/paulfxyz');
}

If a hash is present, the SPA loads and handles routing internally.


Problem 3 β€” Short links going to cal.com in WhatsApp's in-app browser

What happened: Tapping meet.paulfleury.com/dcda3f17 in WhatsApp opened the link and immediately redirected to cal.com, not Zoom. The same URL worked fine in Safari or Chrome.

Root cause: The .htaccess on the server was an old version β€” it was missing the hash rewrite rule entirely. The uploaded file was correct locally but the server had never received the update (FTP transfer had silently failed earlier). Without the rewrite rule, Apache fell through to index.html, which redirected to cal.com.

Additionally, we discovered that an extensionless file named dcda3f17 (no .html) had been written to disk by an earlier version of api.php. Apache's -f (file exists) check was matching this extensionless file before the rewrite rule ran, then serving it as an unknown binary type β€” which also fell through to index.html. A double failure.

Fix β€” two-part:

  1. Deleted all extensionless hash files from the server
  2. Fixed api.php to only ever write {hash}.html, never a bare {hash}:
    // Before (bug):
    file_put_contents(__DIR__ . "/{$safeHash}", $html);
    
    // After (fixed):
    file_put_contents(__DIR__ . "/{$safeHash}.html", $html);
  3. Re-uploaded the correct .htaccess with the hash rewrite rule placed before the -f passthrough:
    # Hash rewrite MUST come before -f check
    RewriteRule ^([a-f0-9]{8})/?$ $1.html [L]
    
    # THEN check for existing files
    RewriteCond %{REQUEST_FILENAME} -f
    RewriteRule ^ - [L]

Lesson: Rule order in .htaccess is load-bearing. If the -f check (does this file exist?) runs before the hash rewrite, Apache looks for a file named dcda3f17 with no extension. When that extensionless file existed, it matched the -f check and got served as garbage. When it didn't exist, the file check failed and fell through to index.html. Either way: wrong result. The hash rewrite must come first.


Problem 4 β€” OG previews showing generic meta tags in WhatsApp

What happened: Sharing meet.paulfleury.com/dcda3f17 in WhatsApp showed "Meeting Paul Fleury" (the generic site OG) instead of the per-link title like "Call with Agnes".

Expected behavior: Each short link has a custom og:title and og:description baked into its static HTML file. WhatsApp's crawler should read those.

Root cause: The hosting provider (SiteGround) runs nginx as a reverse proxy in front of Apache, with active bot protection. Any HTTP request from a non-browser IP β€” including WhatsApp's link preview crawler β€” gets intercepted and returned as HTTP 202 with a captcha redirect page. The crawler never reaches Apache. It never sees dcda3f17.html. It falls back to the cached root OG data.

Confirmed by:

curl -v -A "WhatsApp/2.0" https://meet.paulfleury.com/dcda3f17
# Returns: HTTP/2 202 with sg-captcha: challenge header
# Body: captcha redirect page, not dcda3f17.html

Workarounds explored:

  • Whitelist WhatsApp crawler IPs β†’ not viable (Meta rotates IPs constantly)
  • Disable bot protection for the subdomain β†’ would work, but risks abuse on shared hosting
  • Cloudflare Worker in front of the subdomain β†’ cleanest solution, serves static HTML to crawlers from the edge without touching SiteGround

Current state: The per-link OG tags are correct in the HTML files. The redirect works perfectly in all browsers and in-app WebViews (WhatsApp in-app browser, Telegram browser, etc.) β€” the issue is only with link preview generation, where the crawler itself gets blocked. The generic "Meeting Paul Fleury" preview is still meaningful and accurate for most use cases.


Architecture

meet/
β”œβ”€β”€ frontend/                        # React source (Vite + TypeScript)
β”‚   β”œβ”€β”€ client/
β”‚   β”‚   β”œβ”€β”€ index.html               # SPA shell for /link
β”‚   β”‚   └── src/
β”‚   β”‚       β”œβ”€β”€ App.tsx              # Hash router (wouter + useHashLocation)
β”‚   β”‚       β”œβ”€β”€ pages/
β”‚   β”‚       β”‚   β”œβ”€β”€ link-creator.tsx # /link β€” shortener UI
β”‚   β”‚       β”‚   β”œβ”€β”€ meeting-redirect.tsx  # /#/:hash β€” SPA fallback redirect
β”‚   β”‚       β”‚   └── redirect.tsx     # /#/ β€” cal.com redirect
β”‚   β”‚       └── lib/
β”‚   β”‚           └── queryClient.ts   # TanStack Query + apiRequest helper
β”‚   └── shared/
β”‚       └── schema.ts                # Shared Link type (Drizzle + Zod)
β”‚
└── public_html/                     # Files deployed to your web root
    β”œβ”€β”€ .htaccess                    # Apache rewrite rules (critical β€” read the comments)
    β”œβ”€β”€ index.html                   # Root shell: cal.com redirect if no hash
    β”œβ”€β”€ app.html                     # /link SPA shell (no cal.com redirect)
    β”œβ”€β”€ api.php                      # REST API: POST creates link, GET fetches it
    β”œβ”€β”€ data/
    β”‚   └── links.db                 # SQLite β€” auto-created on first request
    β”œβ”€β”€ assets/                      # Built JS + CSS (output of npm run build)
    β”œβ”€β”€ og-image.png                 # 1200Γ—630 OG image β€” replace with your own
    β”œβ”€β”€ favicon.ico / favicon-*.png  # Multi-size favicons
    β”œβ”€β”€ apple-touch-icon.png         # 180Γ—180 iOS icon
    β”œβ”€β”€ icon-192.png / icon-512.png  # PWA icons
    └── site.webmanifest             # PWA manifest

Stack

Layer Technology Why
Frontend React 18 + Vite + TypeScript Fast dev, type-safe, great DX
UI Tailwind CSS v3 + shadcn/ui Consistent, unstyled-first, no bloat
Routing wouter + useHashLocation Hash-based routing survives Apache passthrough
Data fetching TanStack Query v5 Caching, loading states, mutations
Backend PHP 8+ Runs on any shared host, zero config
Database SQLite3 Single file, no server, perfect for personal use
Hosting Apache + mod_rewrite .htaccess handles all URL mapping

Features

  • Instant redirect β€” <meta http-equiv="refresh" content="0;url=...">, fires before paint, zero JS required
  • No-JS redirect β€” works in every in-app browser (WhatsApp, Telegram, WeChat), even with scripting disabled
  • Static OG pages β€” each link gets its own .html file with hardcoded OG + Twitter Card tags
  • Clean URLs β€” /dcda3f17 (no # fragment), copyable and shareable as-is
  • Zero-setup database β€” SQLite auto-creates on first request; no migrations, no admin panel
  • Shared hosting compatible β€” PHP + Apache only; no Node.js, Docker, or server access needed in production
  • PWA manifest + icons β€” installable on iOS and Android home screen
  • MIT licensed β€” fork it, white-label it, ship it

Known limitations

  • OG crawler blocking on SiteGround β€” SiteGround's nginx bot protection intercepts social crawlers before they reach the static HTML files. Per-link OG tags work correctly in the HTML but may not appear in WhatsApp/Slack link previews on SiteGround-hosted instances. Fix: disable bot protection for the subdomain, or put Cloudflare in front.

  • No authentication on /link β€” anyone who finds /link can create short links. Fine for personal use; add HTTP Basic Auth in .htaccess if you want to restrict it.

  • No link management UI β€” there's no dashboard to view, delete, or edit existing links. Links live in links.db; you can inspect it with any SQLite browser (DB Browser for SQLite is great).

  • 8-char hash space β€” 4 random bytes β†’ 8 hex chars β†’ ~4.3 billion combinations. Collision probability at 1 million links is ~0.01%. Plenty for personal use.

  • Generated HTML files accumulate β€” every created link writes a {hash}.html file to disk. There's no cleanup mechanism. For personal use this is fine (files are tiny ~2KB each). For high-volume use, add a cron to prune old files.


Getting started

See INSTALL.md for the full self-hosting guide β€” covers build, FTP deploy, domain config, permissions, and troubleshooting.

Quick version:

git clone https://github.com/paulfxyz/meet.git
cd meet/frontend
npm install
npm run build
# Upload public_html/ to your Apache web root
# Point your domain DNS β€” done

License

MIT β€” see LICENSE.

Built by Paul Fleury Β· @paulfxyz

About

🀝 meet (1.0.0) β€” Personal meeting link shortener β€” turn Zoom/Meet URLs into clean, branded short links with OG preview cards

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors