Hacktoberfest 2026:维护者为十月标记出来的 issue,仍然开放、适合新手。 浏览 Hacktoberfest issue

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

未关闭
#46 0 条评论 0 个 reaction 已指派 1 人 在 GitHub 查看

@aRustyDev 已经在做这个了。

开始于 2026年1月5日。

评估

这个 Issue 还没有评估数据。

描述

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

主要语言
Rust
星标
0
派生
1
PR 合并指标
30 天内没有已合并 PR

环境准备

  • 没有 Dockerfile 或 Docker Compose 文件
  • 没有 Pull Request 模板
  • 阅读贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

aRustyDev/mdbook-htmx 的其他 Issue

查看 aRustyDev/mdbook-htmx 的全部 Issue

相似的 Issue

更多 Rust Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。