GitHub - abishekvenkat/ghostmark: A minimalist, single-file, serverless bookmark manager.

5 min read Original article ↗

GhostMark

A minimalist, single-file, serverless bookmark manager.

  • Runtime: Vercel Serverless Functions
  • Database: Secret GitHub Gist
  • Frontend: SSR HTML with PWA support

Features

  • Dark mode UI with clean, minimal design
  • PWA support (installable on iOS/Android)
  • CLI for terminal usage
  • iOS Scriptable integration for quick saving from Share Sheet
  • Duplicate URL prevention
  • Delete bookmarks from web UI or CLI
  • No external dependencies (native fetch only)

Setup

1. Create a Secret Gist

  1. Go to gist.github.com
  2. Create a Secret gist with:
    • Filename: bookmarks.json
    • Content: []
  3. Save and copy the Gist ID from the URL:
    https://gist.github.com/username/THIS_IS_YOUR_GIST_ID
    

2. Generate a GitHub Personal Access Token

  1. Go to GitHub Settings → Developer settings → Personal access tokens
  2. Generate a new token (classic) with the gist scope
  3. Copy the token

3. Generate a Secret Key

4. Deploy to Vercel

5. Set Environment Variables

In your Vercel dashboard (Settings → Environment Variables), add:

Variable Value
GIST_ID Your GitHub Gist ID
GH_TOKEN Your GitHub Personal Access Token
GHOSTMARK_KEY Your generated secret key

Redeploy after adding environment variables:


Usage

Browser

Visit your deployed URL with your key:

https://your-app.vercel.app/?key=YOUR_KEY

Add to home screen for PWA experience (removes browser UI on iOS).

CLI

Add to your ~/.zshrc or ~/.bashrc:

# GhostMark CLI
ghostmark() {
    local endpoint="$GHOSTMARK_URL"
    local key="$GHOSTMARK_KEY"

    if [[ -z "$endpoint" || -z "$key" ]]; then
        echo "Error: GHOSTMARK_URL and GHOSTMARK_KEY must be set"
        return 1
    fi

    if [[ "$1" == "-l" && -n "$2" ]]; then
        # Add bookmark
        response=$(curl -s -X POST "$endpoint" \
            -H "Authorization: Bearer $key" \
            -H "Content-Type: application/json" \
            -d "{\"url\": \"$2\"}")

        if echo "$response" | grep -q '"status":"saved"'; then
            echo "✓ Saved"
        elif echo "$response" | grep -q '"error"'; then
            echo "$(echo "$response" | jq -r '.error')"
        else
            echo "✗ Unknown error"
        fi

    elif [[ "$1" == "-d" ]]; then
        # Delete bookmark
        local search="$2"
        local bookmarks=$(curl -s "$endpoint" \
            -H "Authorization: Bearer $key" \
            -H "Accept: application/json")

        # Filter and format bookmarks
        local filtered
        if [[ -n "$search" ]]; then
            filtered=$(echo "$bookmarks" | jq -r --arg s "$search" \
                '[.[] | select(.url | ascii_downcase | contains($s | ascii_downcase))] | .[:20]')
        else
            filtered=$(echo "$bookmarks" | jq -r '.[:20]')
        fi

        local count=$(echo "$filtered" | jq -r 'length')

        if [[ "$count" == "0" ]]; then
            if [[ -n "$search" ]]; then
                echo "No bookmarks found matching '$search'"
            else
                echo "No bookmarks found"
            fi
            return 0
        fi

        # Display numbered list
        echo ""
        if [[ -n "$search" ]]; then
            echo "Bookmarks matching '$search' (showing up to 20):"
        else
            echo "Recent bookmarks (showing up to 20):"
        fi
        echo ""
        echo "$filtered" | jq -r 'to_entries | .[] | "  \(.key + 1). \(.value.date[0:10])  \(.value.url | sub("^https?://"; "") | sub("/$"; ""))"'
        echo ""

        # Prompt for selection
        echo -n "Enter number to delete (or 'q' to cancel): "
        read selection

        if [[ "$selection" == "q" || -z "$selection" ]]; then
            echo "Cancelled"
            return 0
        fi

        # Validate number
        if ! [[ "$selection" =~ ^[0-9]+$ ]] || [[ "$selection" -lt 1 ]] || [[ "$selection" -gt "$count" ]]; then
            echo "✗ Invalid selection"
            return 1
        fi

        # Get URL at index
        local url=$(echo "$filtered" | jq -r ".[$((selection - 1))].url")

        # Delete
        response=$(curl -s -X DELETE "$endpoint" \
            -H "Authorization: Bearer $key" \
            -H "Content-Type: application/json" \
            -d "{\"url\": \"$url\"}")

        if echo "$response" | grep -q '"status":"deleted"'; then
            echo "✓ Deleted"
        elif echo "$response" | grep -q '"error"'; then
            echo "$(echo "$response" | jq -r '.error')"
        else
            echo "✗ Unknown error"
        fi

    else
        # List bookmarks
        curl -s "$endpoint" \
            -H "Authorization: Bearer $key" \
            -H "Accept: application/json" | \
            jq -r '.[] | "\(.date[0:10])  \(.url)"'
    fi
}

export GHOSTMARK_URL="https://your-app.vercel.app/api"
export GHOSTMARK_KEY="your-secret-key"

Then reload your shell:

Commands:

ghostmark                  # List all bookmarks
ghostmark -l <url>         # Save a bookmark
ghostmark -d               # Delete (shows recent 20, pick by number)
ghostmark -d <search>      # Delete (filter by search term first)

iOS (Scriptable)

Install Scriptable from the App Store, create a new script, and paste:

// GhostMark - Save Bookmark
// Run from Share Sheet to save URLs

const GHOSTMARK_URL = "https://your-app.vercel.app/api";
const GHOSTMARK_KEY = "your-secret-key";

async function saveBookmark(url) {
  const req = new Request(GHOSTMARK_URL);
  req.method = "POST";
  req.headers = {
    "Authorization": `Bearer ${GHOSTMARK_KEY}`,
    "Content-Type": "application/json"
  };
  req.body = JSON.stringify({ url });

  try {
    const res = await req.loadJSON();

    if (res.status === "saved") {
      const notification = new Notification();
      notification.title = "GhostMark";
      notification.body = "✓ Saved";
      notification.schedule();
    } else if (res.error) {
      const notification = new Notification();
      notification.title = "GhostMark";
      notification.body = `✗ ${res.error}`;
      notification.schedule();
    }
  } catch (e) {
    const notification = new Notification();
    notification.title = "GhostMark";
    notification.body = "✗ Failed to save";
    notification.schedule();
  }
}

// Get URL from Share Sheet
if (args.urls && args.urls.length > 0) {
  await saveBookmark(args.urls[0]);
} else if (args.plainTexts && args.plainTexts.length > 0) {
  const text = args.plainTexts[0];
  if (text.startsWith("http")) {
    await saveBookmark(text);
  } else {
    const alert = new Alert();
    alert.title = "GhostMark";
    alert.message = "No URL found";
    alert.present();
  }
} else {
  const alert = new Alert();
  alert.title = "GhostMark";
  alert.message = "Share a URL to save it";
  alert.present();
}

Script.complete();

Setup:

  1. Tap the script settings icon
  2. Enable Share Sheet
  3. Share any URL → Select Scriptable → Select "GhostMark"

API Reference

Authentication

All requests require authentication via:

  • Header: Authorization: Bearer YOUR_KEY
  • Query param: ?key=YOUR_KEY

Endpoints

GET /api

List all bookmarks.

Headers:

  • Accept: application/json → Returns JSON array
  • Default → Returns HTML page

Response (JSON):

[
  { "url": "https://example.com", "date": "2025-01-07T12:00:00.000Z" }
]

POST /api

Add a new bookmark.

Body:

{ "url": "https://example.com" }

Response:

Errors:

  • 400 — URL is required
  • 401 — Unauthorized
  • 409 — URL already bookmarked

DELETE /api

Delete a bookmark.

Body:

{ "url": "https://example.com" }

Response:

Errors:

  • 400 — URL is required
  • 401 — Unauthorized
  • 404 — Bookmark not found

Environment Variables

Variable Description
GIST_ID GitHub Gist ID (from gist URL)
GH_TOKEN GitHub Personal Access Token (gist scope)
GHOSTMARK_KEY Secret key for API authentication

License

MIT