📝

GoでゼロからBlogエンジンを作る - Mermaidサーバーサイドレンダリング実装の実践

に公開

はじめに

既存のBlogエンジンは多数存在しますが、セキュリティ要件やカスタマイズ性の観点から、独自実装を検討するケースがあります。本記事では、Goの標準ライブラリのみでBlogエンジンを実装し、特にMermaid図のサーバーサイドSVGレンダリングという独自機能を実現した事例を紹介します。

画面イメージ

  • mermaid記法もレンダリングしています

画面イメージ

本プロジェクトの差別化ポイント

一般的なBlogエンジンと比較して、以下の点で差別化しています。

フレームワークレスアーキテクチャ

GinやEchoなどのWebフレームワークを使わず、Goの標準net/httpのみで実装しました。これにより以下のメリットがあります。

  • 依存関係の最小化によるメンテナンス性向上
  • フレームワーク特有のバグや脆弱性からの独立
  • 実行バイナリサイズの削減
  • 標準ライブラリの深い理解による応用力の向上

Mermaidサーバーサイドレンダリング

多くのBlogエンジンではMermaid図をクライアントサイドJavaScriptで描画しますが、本実装ではサーバーサイドでSVGに変換します。

クライアントサイド描画の課題を解決しています。

  • JavaScriptが無効な環境でも図が表示されます
  • 初回表示時のちらつきがありません
  • SEO対策として検索エンジンが図の内容を認識できます
  • セキュリティリスクが低減されます(クライアント側でのスクリプト実行なし)

実装方法については後述します。

OWASP TOP10完全対応のセキュリティ設計

セキュリティを最優先事項として設計しました。

主なセキュリティ対策は以下の通りです。

  • SQLインジェクション対策(BUN ORMによるプリペアドステートメント)
  • XSS対策(CSP、テンプレート自動エスケープ、プレースホルダーベースSVG挿入)
  • CSRF対策(SameSite Cookie)
  • セキュリティヘッダー完備(HSTS、X-Frame-Options、X-Content-Type-Options等)
  • JWT認証(HS256、Access/Refresh Token、ブラックリスト管理)
  • bcryptパスワードハッシュ(cost=12)
  • 権限ベースアクセス制御(Admin/Editor)

全管理APIがJWT認証で保護されており、セキュリティテストで動作確認済みです。

Clean Architectureによる保守性

レイヤー分離を徹底し、ビジネスロジックとインフラストラクチャを完全に分離しました。

レイヤー構成は以下の通りです。

Entity層(ドメインモデル)
  ↓
Repository層(データアクセスインターフェース)
  ↓
UseCase層(ビジネスロジック)
  ↓
Interface層(HTTPハンドラー、プレゼンテーション)

すべての依存関係をDI(依存性注入)で管理し、各レイヤーが疎結合になっています。

Mermaidサーバーサイドレンダリングの実装詳細

本プロジェクトの最大の特徴である、Mermaidサーバーサイドレンダリングの実装について詳しく解説します。

アーキテクチャ概要

Markdownレンダリングパイプラインは以下のステップで処理されます。

  1. Mermaidコードブロックを抽出してプレースホルダーに置換
  2. goldmarkでMarkdownをHTMLに変換(この時点でプレースホルダーがHTMLエスケープされる)
  3. Mermaid CLIでSVGを生成
  4. エスケープされたプレースホルダーをSVGに置換

XSS対策を考慮した実装

単純にMermaid SVGを埋め込むだけではXSS脆弱性が発生します。そのため、以下の戦略を採用しました。

goldmarkのhtml.WithUnsafe()オプションは使用しません。これにより、ユーザー入力のHTMLはすべてエスケープされます。

Mermaid SVGは信頼できるMermaid CLIが生成するため、プレースホルダー方式で後から挿入します。この方式により、ユーザー入力とシステム生成コンテンツを明確に分離できます。

実装コードの一部を示します。

// Mermaidコードブロックを一時プレースホルダーに置換してSVGを保存
processedSource, svgMap, err := r.extractMermaidBlocks(source)
if err != nil {
    return "", fmt.Errorf("failed to process mermaid blocks: %w", err)
}

// Markdownをレンダリング(HTMLエスケープされる)
var buf bytes.Buffer
if err := r.md.Convert([]byte(processedSource), &buf); err != nil {
    return "", fmt.Errorf("failed to render markdown: %w", err)
}

// プレースホルダーをSVGに置き換え
result := buf.String()
for placeholder, svg := range svgMap {
    escapedPlaceholder := htmllib.EscapeString(placeholder)
    result = strings.ReplaceAll(result, escapedPlaceholder, svg)
}

プレースホルダー形式はMERMAIDSVGPLACEHOLDER{counter}LEN{length}とし、通常のMarkdown記法と衝突しない設計にしました。

並行処理時の競合状態対策

複数のリクエストが同時にMermaidレンダリングを行う場合、一時ファイル名の衝突が発生する可能性があります。

当初はPIDベースのファイル名を使用していましたが、UUIDベースに変更して一意性を保証しました。

// 一時ファイルを作成(UUIDで一意性を保証し、競合状態を回避)
uniqueID := uuid.New().String()
inputFile := filepath.Join(r.tmpDir, fmt.Sprintf("mermaid-%s.mmd", uniqueID))
outputFile := filepath.Join(r.tmpDir, fmt.Sprintf("mermaid-%s.svg", uniqueID))

// 入力ファイルにMermaidコードを書き込み(所有者のみ読み書き可能)
if err := os.WriteFile(inputFile, []byte(mermaidCode), 0600); err != nil {
    return "", fmt.Errorf("failed to write input file: %w", err)
}

ファイルパーミッションも0600に設定し、マルチテナント環境での情報漏洩を防いでいます。

Docker環境でのChromium実行

Mermaid CLIはPuppeteerを使用してChromiumでレンダリングするため、Docker環境では特別な設定が必要です。

services:
  app:
    cap_add:
      - SYS_ADMIN
    security_opt:
      - seccomp:unconfined

これらの設定はセキュリティ上のトレードオフですが、コメントで警告を明記し、本番環境では別コンテナ分離などの対策を推奨しています。

パフォーマンス最適化

正規表現コンパイルはパッケージレベル変数として事前に行い、リクエストごとのオーバーヘッドを削減しました。

// mermaidBlockRe Mermaidコードブロックマッチ用の正規表現(パフォーマンス最適化のため事前コンパイル)
var mermaidBlockRe = regexp.MustCompile("(?s)```mermaid\\s*\\n(.*?)```")

並行処理安全性については、extractMermaidBlocks関数内のローカル変数(counter、svgMap)が各呼び出しごとに生成されるため、複数goroutineからの同時呼び出しにも対応しています。

JWT認証の実装

管理機能へのアクセスはJWTで保護しています。実装の特徴を紹介します。

Access TokenとRefresh Tokenの分離

短命のAccess Token(15分)と長命のRefresh Token(7日)を分離し、セキュリティとユーザビリティを両立しました。

トークンブラックリスト機能

ログアウト時にはトークンをブラックリストに追加し、盗まれたトークンの悪用を防ぎます。MySQL上にブラックリストテーブルを保持し、有効期限切れトークンは定期的にクリーンアップされます。

ミドルウェアによる認証

認証ミドルウェアは以下のチェックを行います。

  • Authorizationヘッダーの存在確認
  • Bearer トークンの抽出
  • トークンの署名検証
  • トークンの有効期限確認
  • ブラックリストチェック
  • ユーザー情報のコンテキスト格納

実装例を示します。

func (m *authMiddleware) Authenticate(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        authHeader := r.Header.Get("Authorization")
        if authHeader == "" {
            http.Error(w, "Authorization header required", http.StatusUnauthorized)
            return
        }

        token := strings.TrimPrefix(authHeader, "Bearer ")
        claims, err := m.jwtManager.ValidateToken(token)
        if err != nil {
            http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
            return
        }

        // コンテキストにユーザー情報を格納
        ctx := context.WithValue(r.Context(), "user", claims)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

権限ベースアクセス制御

Admin、Editorの2つのロールを定義し、エンドポイントごとに必要な権限を設定しています。

mux.Handle("/api/admin/posts",
    authMiddleware.Authenticate(
        authMiddleware.RequireRole(entity.RoleAdmin, entity.RoleEditor)(
            http.HandlerFunc(postHandler.List),
        ),
    ),
)

テスト戦略

品質保証のため、包括的なテスト戦略を採用しました。

TestContainersによる統合テスト

MySQLのTestContainerを使用し、実際のデータベースを使った統合テストを実現しています。共有コンテナ方式により、テスト実行時間を短縮しました。

SQLマイグレーションの自動読み込み

テストコードとマイグレーションファイルの二重管理を避けるため、本番で使用する同じマイグレーションファイルをテストでも読み込みます。

func setupTestDB(t *testing.T) *sql.DB {
    // TestContainerでMySQL起動
    container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: mysqlC,
        Started:          true,
    })
    
    // マイグレーション実行
    migrations, _ := os.ReadFile("../../scripts/migrations/001_initial_schema.sql")
    db.Exec(string(migrations))
    
    return db
}

カバレッジ

現時点でのテストカバレッジは以下の通りです。

  • Entity層: 100%
  • Auth層: 85.1%
  • Persistence層: 64.1%
  • UseCase層: 51.1%
  • Middleware層: 50.0%
  • 全体: 40.5%

API設計

RESTful API設計に準拠し、29のエンドポイントを提供しています。

エンドポイント分類

認証API(4エンドポイント)、公開API(11エンドポイント)、管理API(14エンドポイント)に分類されます。

公開APIは認証不要でアクセスでき、管理APIはすべてJWT認証で保護されています。

公開記事フィルタリング

セキュリティ上重要な実装として、公開APIでは下書き記事を返却しないようフィルタリングしています。

func (h *PostHandler) GetByID(w http.ResponseWriter, r *http.Request) {
    // 記事取得処理...
    
    // 公開記事のみ返却(下書きは認証が必要)
    if post.Status != entity.StatusPublished {
        presenter.JSONError(w, http.StatusNotFound, "Post not found")
        return
    }
    
    presenter.JSONResponse(w, http.StatusOK, post)
}

この実装により、認証なしでは下書き記事にアクセスできません。

ハイブリッド型アーキテクチャ

HTML SSRとREST APIの両方を提供する、ハイブリッド型アーキテクチャを採用しました。

一般ユーザー向けにはSSRでHTMLを返し、管理画面や外部連携用にはJSON APIを提供します。これにより、SEO対策とAPI拡張性を両立しています。

得られた知見

本プロジェクトを通じて得られた知見を共有します。

標準ライブラリの実力

Goの標準ライブラリは非常に強力で、Webアプリケーション開発に必要な機能がほぼ揃っています。フレームワークなしでも十分に実用的なアプリケーションを構築できます。

ただし、ルーティングやミドルウェアチェーンは自前実装が必要で、学習コストがかかります。

セキュリティ設計の重要性

後からセキュリティ対策を追加するのは困難です。設計段階から以下を考慮すべきです。

  • 入力値の検証とサニタイゼーション
  • 出力のエスケープ処理
  • 認証・認可の設計
  • セキュリティヘッダーの設定
  • ログ出力(監査証跡)

特にXSS対策では、信頼できるコンテンツと信頼できないコンテンツの境界を明確にすることが重要です。

Clean Architectureの実践

レイヤー分離により、以下のメリットが得られました。

  • ビジネスロジックのテストが容易になります
  • データベースの切り替えが可能になります(現在はMySQL、将来的にPostgreSQLへの移行も容易)
  • 新機能追加時の影響範囲が明確になります

一方で、小規模プロジェクトでは過剰設計になる可能性があります。プロジェクトの規模と要件に応じた判断が必要です。

Docker環境での開発

Docker Composeにより、開発環境の構築が容易になりました。ただし、ChromiumをDockerで実行する際のセキュリティ設定には注意が必要です。

本番環境では、Mermaidレンダリング専用のコンテナを分離し、最小権限の原則に従うべきです。

まとめ

Goの標準ライブラリのみでBlogエンジンを実装し、Mermaidサーバーサイドレンダリングという独自機能を実現しました。

本実装の特徴をまとめます。

  • フレームワークレスでシンプルな実装
  • セキュリティファーストの設計
  • Clean Architectureによる保守性
  • サーバーサイドMermaidレンダリング
  • JWT認証とロールベースアクセス制御
  • 包括的なテスト戦略

ソースコードはblog-engineとして公開しています。Blogエンジンの自作を検討している方の参考になれば幸いです。

参考文献

Discussion