Hacktoberfest 2026: los issues que los mantenedores marcaron para octubre, abiertos y aptos para principiantes. Explorar issues de Hacktoberfest

docs(examples): Secure Self-Hosted Meilisearch via Cloudflare

Abierto
#46 0 comentarios 0 reacciones 1 asignado Ver en GitHub

@aRustyDev ya está trabajando en esto.

Desde el 5/1/2026.

Evaluación

Este issue todavía no se ha evaluado.

Descripción

documentation

Example: Secure Self-Hosted Meilisearch via Cloudflare

This example demonstrates a production-grade architecture for serving an HTMX documentation site with a privately-hosted Meilisearch instance, using Cloudflare Workers as a secure proxy layer.


Architecture Overview

Browser (HTMX)
   ↓
   │ POST /search
   ↓
Cloudflare Worker (Policy Enforcement Layer)
   ↓ (internal CF network)
   │ CF-Access-Client-Id / CF-Access-Client-Secret
   ↓
Cloudflare Tunnel (cloudflared)
   ↓
   │ localhost:7700
   ↓
Private Meilisearch (LAN / VPC)
Key Security Properties
  • Meilisearch is never internet-facing - only accessible via Cloudflare Tunnel
  • Worker is the only public entry point - acts as policy enforcement
  • API keys never reach the browser - server-side only
  • Query constraints are enforced - Worker sanitizes all inputs

Why This Pattern?

Even with Meilisearch's "search-only" keys, direct exposure risks:

Risk Impact
Index enumeration Attackers can infer document structure
Filter abuse Bypass intended visibility constraints
Query DoS Expensive fuzzy searches can overload
Future config mistakes Public surface stays exposed forever

Workers provide:

  • Frozen query shape (no arbitrary filters/sorts)
  • Authentication enforcement
  • Rate limiting per user
  • Response caching
  • Centralized query logging

Component 1: Cloudflare Tunnel Configuration

config.yml
tunnel: meilisearch-tunnel
credentials-file: /etc/cloudflared/meili.json

ingress:
  # Only this hostname routes to Meilisearch
  - hostname: meili.internal.example.com
    service: http://localhost:7700
  # Everything else gets 404
  - service: http_status:404
Run the Tunnel
# As a service (recommended)
cloudflared service install
systemctl start cloudflared

# Or manually for testing
cloudflared tunnel run meilisearch-tunnel
Cloudflare Access Policy

In Zero Trust Dashboard:

  1. Application: meili.internal.example.com
  2. Policy Type: Service Token
  3. Create Token → Use CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET in Worker

Component 2: Cloudflare Worker (TypeScript)

wrangler.toml
name = "docs-search"
main = "src/worker.ts"
compatibility_date = "2024-01-01"

[vars]
MEILI_HOST = "https://meili.internal.example.com"
MEILI_INDEX = "docs"

# Secrets (set via wrangler secret put)
# MEILI_SEARCH_KEY
# CF_ACCESS_CLIENT_ID
# CF_ACCESS_CLIENT_SECRET
src/worker.ts
import { Hono } from "hono";
import { cors } from "hono/cors";

// Environment bindings
interface Env {
  MEILI_HOST: string;
  MEILI_INDEX: string;
  MEILI_SEARCH_KEY: string;
  CF_ACCESS_CLIENT_ID: string;
  CF_ACCESS_CLIENT_SECRET: string;
}

const app = new Hono<{ Bindings: Env }>();

// CORS for HTMX requests
app.use("/search", cors({ origin: "*" }));

// Health check (no auth required)
app.get("/health", (c) => c.json({ status: "ok" }));

// Search endpoint - returns HTML partial for HTMX
app.post("/search", async (c) => {
  // ---- 1. Authentication gate ----
  const user = await verifySession(c.req);
  if (!user) {
    return c.html(
      `<div class="search-error">Please sign in to search</div>`,
      401
    );
  }

  // ---- 2. Parse + sanitize input ----
  let body: { q?: string; scope?: string };
  try {
    body = await c.req.json();
  } catch {
    return c.html(`<div class="search-error">Invalid request</div>`, 400);
  }

  const query = String(body.q ?? "").slice(0, 128).trim();
  if (query.length < 2) {
    return c.html(`<p class="search-hint">Type at least 2 characters</p>`);
  }

  const scope = body.scope || "all";

  // ---- 3. Build constrained Meilisearch payload ----
  // IMPORTANT: Never expose raw user input to filters
  const meiliPayload = {
    q: query,
    limit: 10,
    attributesToRetrieve: ["title", "section", "path", "excerpt"],
    attributesToHighlight: ["title", "excerpt"],
    highlightPreTag: "<mark>",
    highlightPostTag: "</mark>",
    // Scope filter using approved values only
    filter: scope !== "all" ? `scopes = "${escapeFilter(scope)}"` : undefined,
  };

  // ---- 4. Proxy to Meilisearch via Tunnel ----
  const meiliResponse = await fetch(
    `${c.env.MEILI_HOST}/indexes/${c.env.MEILI_INDEX}/search`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${c.env.MEILI_SEARCH_KEY}`,
        "Content-Type": "application/json",
        // Cloudflare Access → Tunnel authentication
        "CF-Access-Client-Id": c.env.CF_ACCESS_CLIENT_ID,
        "CF-Access-Client-Secret": c.env.CF_ACCESS_CLIENT_SECRET,
      },
      body: JSON.stringify(meiliPayload),
    }
  );

  if (!meiliResponse.ok) {
    console.error("Meilisearch error:", await meiliResponse.text());
    return c.html(`<div class="search-error">Search unavailable</div>`, 502);
  }

  const data = (await meiliResponse.json()) as MeiliSearchResponse;

  // ---- 5. Runtime authorization filter (defense in depth) ----
  const authorizedHits = data.hits.filter((hit) => {
    // Public pages always visible
    if (hit.auth?.access === "public") return true;
    // Authenticated pages require login
    if (hit.auth?.access === "authenticated") return !!user;
    // Role-based pages require matching role
    if (hit.auth?.access === "roles") {
      return hit.auth.roles?.some((role) => user.roles.includes(role));
    }
    return false;
  });

  // ---- 6. Return HTML partial for HTMX ----
  return c.html(renderResults(authorizedHits));
});

// ----- Helper Types -----

interface User {
  id: string;
  roles: string[];
}

interface MeiliHit {
  title: string;
  section: string;
  path: string;
  excerpt: string;
  auth?: {
    access: "public" | "authenticated" | "roles";
    roles?: string[];
  };
  _formatted?: {
    title?: string;
    excerpt?: string;
  };
}

interface MeiliSearchResponse {
  hits: MeiliHit[];
  query: string;
  processingTimeMs: number;
  estimatedTotalHits: number;
}

// ----- Helper Functions -----

async function verifySession(req: Request): Promise<User | null> {
  // Option 1: Cloudflare Access JWT
  const cfAccessJWT = req.headers.get("Cf-Access-Jwt-Assertion");
  if (cfAccessJWT) {
    // Validate JWT and extract user info
    // See: https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/
    return { id: "user-from-jwt", roles: ["authenticated"] };
  }

  // Option 2: Session cookie
  const sessionCookie = req.headers.get("Cookie")?.match(/session=([^;]+)/)?.[1];
  if (sessionCookie) {
    // Validate session and look up user
    return { id: "user-from-session", roles: ["authenticated"] };
  }

  return null;
}

function escapeFilter(value: string): string {
  // Whitelist valid scope names
  const validScopes = ["all", "developers", "managers", "sre"];
  return validScopes.includes(value) ? value : "all";
}

function escape(s: string = ""): string {
  return s.replace(
    /[&<>"']/g,
    (m) =>
      ({
        "&": "&amp;",
        "<": "&lt;",
        ">": "&gt;",
        '"': "&quot;",
        "'": "&#39;",
      })[m]!
  );
}

function renderResults(hits: MeiliHit[]): string {
  if (!hits.length) {
    return `<p class="search-empty">No results found</p>`;
  }

  return `
    <ul class="search-results">
      ${hits
        .map(
          (hit) => `
        <li class="search-result">
          <a href="${escape(hit.path)}" hx-boost="true" hx-target="#content">
            <strong class="result-title">${hit._formatted?.title ?? escape(hit.title)}</strong>
            <span class="result-section">${escape(hit.section)}</span>
          </a>
          <p class="result-excerpt">${hit._formatted?.excerpt ?? escape(hit.excerpt)}</p>
        </li>
      `
        )
        .join("")}
    </ul>
  `;
}

export default app;
Deploy
# Set secrets
wrangler secret put MEILI_SEARCH_KEY
wrangler secret put CF_ACCESS_CLIENT_ID
wrangler secret put CF_ACCESS_CLIENT_SECRET

# Deploy
wrangler deploy

Component 3: Meilisearch Configuration

Index Schema
{
  "primaryKey": "id"
}
Index Settings
curl -X PATCH 'http://localhost:7700/indexes/docs/settings' \
  -H 'Authorization: Bearer MASTER_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "searchableAttributes": [
      "title",
      "section",
      "content"
    ],
    "displayedAttributes": [
      "title",
      "section",
      "path",
      "excerpt",
      "auth",
      "scopes"
    ],
    "filterableAttributes": [
      "scopes",
      "auth.access"
    ],
    "rankingRules": [
      "words",
      "typo",
      "proximity",
      "attribute",
      "exactness",
      "desc(weight)"
    ],
    "stopWords": ["the", "and", "or", "a", "an"],
    "typoTolerance": {
      "enabled": true,
      "minWordSizeForTypos": {
        "oneTypo": 4,
        "twoTypos": 7
      }
    }
  }'
Document Shape
{
  "id": "getting-started/install#macos",
  "title": "Installation",
  "section": "Getting Started",
  "path": "/docs/install#macos",
  "content": "Full markdown-stripped content for searching...",
  "excerpt": "How to install the CLI on macOS...",
  "weight": 10,
  "scopes": ["developers", "sre"],
  "auth": {
    "access": "public"
  }
}
Security Notes
  • content is searchable but NOT displayed (prevents data leakage)
  • excerpt is a curated summary, safe to display
  • scopes and auth enable filtering
  • weight allows boosting important pages

Component 4: HTMX Integration

Search Input
<div class="search-container">
  <input
    type="search"
    name="q"
    placeholder="Search docs..."
    autocomplete="off"
    hx-post="/search"
    hx-trigger="keyup changed delay:250ms, search"
    hx-target="#search-results"
    hx-swap="innerHTML"
    hx-include="[name='scope']"
    hx-indicator="#search-spinner"
  />

  <select name="scope" hx-swap-oob="true">
    <option value="all">All</option>
    <option value="developers">Developers</option>
    <option value="managers">Managers</option>
    <option value="sre">SRE</option>
  </select>

  <span id="search-spinner" class="htmx-indicator">
    <svg class="spinner">...</svg>
  </span>
</div>

<div id="search-results" aria-live="polite"></div>
Progressive Enhancement (No-JS Fallback)
<noscript>
  <form action="/search" method="GET">
    <input type="search" name="q" placeholder="Search docs..." />
    <select name="scope">
      <option value="all">All</option>
      <option value="developers">Developers</option>
    </select>
    <button type="submit">Search</button>
  </form>
</noscript>
CSS for Search Results
.search-results {
  list-style: none;
  padding: 0;
  margin: 1rem 0;
}

.search-result {
  padding: 0.75rem;
  border-bottom: 1px solid var(--border-color);
}

.search-result a {
  display: block;
  text-decoration: none;
}

.result-title {
  display: block;
  color: var(--link-color);
}

.result-title mark {
  background: var(--highlight-color);
  padding: 0 2px;
}

.result-section {
  font-size: 0.875rem;
  color: var(--text-muted);
}

.result-excerpt {
  margin-top: 0.25rem;
  font-size: 0.875rem;
  color: var(--text-secondary);
}

.result-excerpt mark {
  background: var(--highlight-color);
}

.search-empty {
  color: var(--text-muted);
  text-align: center;
  padding: 2rem;
}

.htmx-indicator {
  display: none;
}

.htmx-request .htmx-indicator {
  display: inline-block;
}

Component 5: Build-Time Indexing

scripts/index-docs.ts
import { readFileSync } from "fs";
import { glob } from "glob";

interface SearchDocument {
  id: string;
  title: string;
  section: string;
  path: string;
  content: string;
  excerpt: string;
  weight: number;
  scopes: string[];
  auth: {
    access: "public" | "authenticated" | "roles";
    roles?: string[];
  };
}

async function indexDocuments() {
  const MEILI_HOST = process.env.MEILI_HOST || "http://localhost:7700";
  const MEILI_ADMIN_KEY = process.env.MEILI_ADMIN_KEY!;

  // Load search index generated by mdbook-htmx
  const searchIndex = JSON.parse(
    readFileSync("book/htmx/search-index.json", "utf-8")
  );

  const documents: SearchDocument[] = searchIndex.documents.map(
    (doc: any, idx: number) => ({
      id: doc.path.replace(/\//g, "-").replace(/^-/, "") || `doc-${idx}`,
      title: doc.title,
      section: doc.headings?.[0]?.text || "Documentation",
      path: doc.path,
      content: stripMarkdown(doc.body),
      excerpt: generateExcerpt(doc.body, 150),
      weight: calculateWeight(doc),
      scopes: doc.scopes || ["all"],
      auth: doc.auth || { access: "public" },
    })
  );

  // Batch update (replace all documents)
  const response = await fetch(
    `${MEILI_HOST}/indexes/docs/documents?primaryKey=id`,
    {
      method: "PUT",
      headers: {
        Authorization: `Bearer ${MEILI_ADMIN_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(documents),
    }
  );

  if (!response.ok) {
    throw new Error(`Indexing failed: ${await response.text()}`);
  }

  const task = await response.json();
  console.log(`Indexed ${documents.length} documents. Task: ${task.taskUid}`);

  // Wait for task completion
  await waitForTask(MEILI_HOST, MEILI_ADMIN_KEY, task.taskUid);
}

function stripMarkdown(text: string): string {
  return text
    .replace(/```[\s\S]*?```/g, "") // Code blocks
    .replace(/`[^`]+`/g, "") // Inline code
    .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Links
    .replace(/[#*_~`]/g, "") // Formatting
    .replace(/\n+/g, " ") // Newlines
    .trim();
}

function generateExcerpt(text: string, maxLength: number): string {
  const stripped = stripMarkdown(text);
  if (stripped.length <= maxLength) return stripped;
  return stripped.slice(0, maxLength).replace(/\s+\S*$/, "") + "...";
}

function calculateWeight(doc: any): number {
  // Boost getting started, overview pages
  if (doc.path.includes("getting-started")) return 20;
  if (doc.path.includes("overview")) return 15;
  if (doc.path.includes("quickstart")) return 18;
  return 10;
}

async function waitForTask(
  host: string,
  key: string,
  taskUid: number
): Promise<void> {
  const maxAttempts = 30;
  for (let i = 0; i < maxAttempts; i++) {
    const response = await fetch(`${host}/tasks/${taskUid}`, {
      headers: { Authorization: `Bearer ${key}` },
    });
    const task = await response.json();

    if (task.status === "succeeded") {
      console.log("Indexing completed successfully");
      return;
    }
    if (task.status === "failed") {
      throw new Error(`Indexing failed: ${task.error?.message}`);
    }

    await new Promise((r) => setTimeout(r, 1000));
  }
  throw new Error("Indexing timed out");
}

indexDocuments().catch(console.error);
CI/CD Integration
# .github/workflows/deploy-docs.yml
name: Deploy Docs

on:
  push:
    branches: [main]
    paths: ["docs/**"]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build mdbook-htmx
        run: mdbook build

      - name: Index to Meilisearch
        env:
          MEILI_HOST: ${{ secrets.MEILI_HOST }}
          MEILI_ADMIN_KEY: ${{ secrets.MEILI_ADMIN_KEY }}
        run: npx tsx scripts/index-docs.ts

      - name: Deploy Worker
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CF_API_TOKEN }}
          command: deploy

      - name: Deploy Assets
        run: |
          # Deploy static files to R2 or Pages
          wrangler r2 object put docs-bucket --file book/htmx/

Performance Considerations

Metric Typical Value
Worker → Tunnel latency 5-20ms
Meilisearch search latency <10ms
Total E2E latency <50ms
Optimization Strategies
  1. Cache popular queries in Worker KV:
const cacheKey = `search:${scope}:${query}`;
const cached = await c.env.SEARCH_CACHE.get(cacheKey);
if (cached) return c.html(cached);

// ... perform search ...

await c.env.SEARCH_CACHE.put(cacheKey, html, { expirationTtl: 300 });
  1. Pre-warm common queries on deploy

  2. Use Meilisearch's typo tolerance instead of client-side fuzzy matching


Security Checklist

  • Meilisearch only accessible via Cloudflare Tunnel
  • Tunnel protected by Service Token (not just IP allowlist)
  • Worker validates authentication before searching
  • API keys stored in Wrangler secrets, never in code
  • Query input sanitized (length limit, whitelist filters)
  • Results filtered by authorization at runtime
  • content field searchable but not displayed
  • No raw error messages exposed to clients

Troubleshooting

Worker can't reach Meilisearch
# Verify tunnel is running
cloudflared tunnel info meilisearch-tunnel

# Check Cloudflare Access logs in Zero Trust Dashboard
Search returns empty results
# Check Meilisearch has documents
curl -H "Authorization: Bearer $MEILI_ADMIN_KEY" \
  http://localhost:7700/indexes/docs/stats

# Check index settings
curl -H "Authorization: Bearer $MEILI_ADMIN_KEY" \
  http://localhost:7700/indexes/docs/settings
HTMX requests fail
// Check browser console for CORS errors
// Verify hx-post URL matches Worker route
// Check HX-Request header is present

References

Lenguaje dominante
Rust
Estrellas
0
Forks
1
Métricas de merge de PR
Sin PR fusionados en 30 d

Preparar el entorno

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de aRustyDev/mdbook-htmx

Todos los issues de aRustyDev/mdbook-htmx

Issues similares

Más issues de Rust

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.