Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ gem 'propshaft'
gem 'recaptcha'
gem 'puma', '>= 5.0'
gem 'rails', '~> 8.1.2'
gem 'redcarpet'
gem 'solid_cache'
gem 'solid_queue'
gem 'stimulus-rails'
Expand Down
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ GEM
psych (>= 4.0.0)
tsort
recaptcha (5.21.1)
redcarpet (3.6.1)
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
Expand Down Expand Up @@ -503,6 +504,7 @@ DEPENDENCIES
puma (>= 5.0)
rails (~> 8.1.2)
recaptcha
redcarpet
rspec-rails
rubocop-capybara
rubocop-factory_bot
Expand Down
13 changes: 13 additions & 0 deletions app/controllers/markdown_previews_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

class MarkdownPreviewsController < ApplicationController
include MarkdownHelper

before_action :authenticate_user!

# POST /markdown_previews
def create
html = render_markdown(params[:text])
render json: { html: html }
end
Comment on lines +9 to +12

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

このメソッドは簡潔にできます。render_markdown ヘルパーは既に空のテキスト入力を処理して空文字列を返すため、コントローラーでの明示的なチェックは不要です。

  def create
    html = render_markdown(params[:text])

    render json: { html: html }
  end

end
99 changes: 99 additions & 0 deletions app/helpers/markdown_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# frozen_string_literal: true

module MarkdownHelper
include ActionView::Helpers::SanitizeHelper
include ActionView::Helpers::UrlHelper
# Markdownテキストをレンダリング
# カスタムボタンタグ: <button-link url="...">テキスト</button-link>
def render_markdown(text)
return '' if text.blank?

# 1. カスタムボタンタグを一時的にプレースホルダーに置き換え
text_with_placeholders, buttons = extract_button_links(text)

# 2. Markdownをパース
html = markdown_to_html(text_with_placeholders)

# 3. プレースホルダーをボタンHTMLに戻す
html = restore_button_placeholders(html, buttons)

# 4. サニタイズ(許可リスト方式)
sanitize_markdown_html(html)
end

private

# カスタムボタンタグを抽出してプレースホルダーに置き換え
def extract_button_links(text)
buttons = []
text_with_placeholders = text.gsub(/<button-link\s+url="([^"]+)">([^<]+)<\/button-link>/m) do

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

現在の正規表現 /<button-link\s+url="([^"]+)">([^<]+)<\/button-link>/m は、ボタンのラベルに < を含めることができず、堅牢性に欠けます。例えば、1 < 2 のようなテキストは使用できません。

より柔軟な非貪欲マッチ (.*?) を使用することをお勧めします。

/<button-link\s+url="([^"]+)">(.*?)<\/button-link>/m

これにより、ラベル内のテキストが link_to ヘルパーによって安全にエスケープされるようになります。

    text_with_placeholders = text.gsub(/<button-link\s+url="([^"]+)">(.*?)<\/button-link>/m) do

url = Regexp.last_match(1)
label = Regexp.last_match(2)

# URLの基本的なバリデーション
next '' unless url.match?(%r{\Ahttps?://})

# 一意なプレースホルダーを生成(ユーザー入力との衝突を防ぐ)
placeholder = "BUTTONPLACEHOLDER-#{SecureRandom.hex(8)}"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

現在のプレースホルダーの実装では、ユーザーが BUTTONPLACEHOLDER-xxxx のような文字列を意図的に、あるいは偶然入力した場合に、意図しないボタンに置換されてしまうわずかな可能性があります。

このリスクを軽減するために、プレースホルダーをHTMLコメントで囲むことをお勧めします。ユーザーがHTMLコメントを直接入力する可能性は低く、より安全になります。また、もし入力されたとしてもHTMLコメントはレンダリングされないため、問題が発生しにくいです。

      placeholder = "<!-- BUTTONPLACEHOLDER-#{SecureRandom.hex(8)} -->"

buttons << { url: url, label: label, placeholder: placeholder }
placeholder
end

[ text_with_placeholders, buttons ]
end

# MarkdownをHTMLに変換
def markdown_to_html(text)
renderer = Redcarpet::Render::HTML.new(
escape_html: false,
hard_wrap: true,
link_attributes: { rel: 'nofollow noopener noreferrer', target: '_blank' }
)

markdown = Redcarpet::Markdown.new(
renderer,
autolink: true,
tables: true,
fenced_code_blocks: true,
strikethrough: true,
no_intra_emphasis: true,
space_after_headers: true
)

markdown.render(text)
end

# プレースホルダーをボタンHTMLに戻す
def restore_button_placeholders(html, buttons)
return html if buttons.empty?

buttons.each do |button|
button_html = link_to(
button[:label],
button[:url],
target: '_blank',
rel: 'noopener noreferrer',
class: 'inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-500 transition-colors no-underline'
)
html = html.gsub(button[:placeholder], button_html)
end

html
end

# HTMLをサニタイズ
def sanitize_markdown_html(html)
Rails::HTML5::SafeListSanitizer.new.sanitize(
html,
tags: %w[
p br strong em del code pre
h1 h2 h3 h4 h5 h6
ul ol li
a
table thead tbody tr th td
blockquote
],
attributes: %w[href target rel class align]
)
end
end
67 changes: 67 additions & 0 deletions app/javascript/controllers/markdown_editor_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
static targets = ['input', 'preview', 'editTab', 'previewTab', 'editPane', 'previewPane'];
static values = {
previewUrl: String,
};

connect() {
this.showEdit();
}

showEdit() {
this.editPaneTarget.classList.remove('hidden');
this.previewPaneTarget.classList.add('hidden');

this.editTabTarget.classList.add('border-b-2', 'border-blue-600', 'text-blue-600');
this.editTabTarget.classList.remove('text-gray-600');

this.previewTabTarget.classList.remove('border-b-2', 'border-blue-600', 'text-blue-600');
this.previewTabTarget.classList.add('text-gray-600');
}

async showPreview() {
this.editPaneTarget.classList.add('hidden');
this.previewPaneTarget.classList.remove('hidden');

this.previewTabTarget.classList.add('border-b-2', 'border-blue-600', 'text-blue-600');
this.previewTabTarget.classList.remove('text-gray-600');

this.editTabTarget.classList.remove('border-b-2', 'border-blue-600', 'text-blue-600');
this.editTabTarget.classList.add('text-gray-600');

// プレビューを更新
await this.updatePreview();
}

async updatePreview() {
const text = this.inputTarget.value;

if (!text.trim()) {
this.previewTarget.innerHTML = '<p class="text-gray-500">プレビューするテキストがありません</p>';
return;
}

try {
const response = await fetch(this.previewUrlValue, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({ text }),
});

if (!response.ok) {
throw new Error('Preview failed');
}

const data = await response.json();
this.previewTarget.innerHTML = data.html;
} catch (error) {
console.error('Preview error:', error);
this.previewTarget.innerHTML = '<p class="text-red-500">プレビューの生成に失敗しました</p>';
}
}
}
138 changes: 128 additions & 10 deletions app/views/home/help.html.erb
Original file line number Diff line number Diff line change
@@ -1,14 +1,132 @@
<% content_for :title, full_title("ヘルプ") %>

<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-6">ヘルプ</h1>

<div class="space-y-6">
<div>
<h2 class="text-xl font-semibold">よくある質問</h2>
<p>...</p>
</div>

<!-- ヘルプの内容 -->
<div class="container mx-auto px-4 py-8 max-w-4xl">
<h1 class="text-3xl font-bold mb-8">ヘルプ</h1>

<div class="space-y-8">
<section id="markdown" class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-2xl font-bold mb-6 pb-2 border-b">マークダウン記法ガイド</h2>

<p class="text-gray-700 mb-6">
イベント説明文と開催レポートでは、マークダウン記法を使って文章を装飾できます。
</p>

<div class="space-y-6">
<!-- 見出し -->
<div>
<h3 class="text-lg font-semibold mb-3">見出し</h3>
<div class="bg-gray-50 p-4 rounded border mb-2">
<code class="text-sm">
# 大見出し<br>
## 中見出し<br>
### 小見出し
</code>
</div>
</div>

<!-- 太字・斜体 -->
<div>
<h3 class="text-lg font-semibold mb-3">太字・斜体</h3>
<div class="bg-gray-50 p-4 rounded border mb-2">
<code class="text-sm">
**太字にする**<br>
*斜体にする*<br>
~~取り消し線~~
</code>
</div>
</div>

<!-- リンク -->
<div>
<h3 class="text-lg font-semibold mb-3">リンク</h3>
<div class="bg-gray-50 p-4 rounded border mb-2">
<code class="text-sm">
[リンクテキスト](https://example.com)
</code>
</div>
<p class="text-sm text-gray-600">通常のテキストリンクになります。</p>
</div>

<!-- ボタンリンク -->
<div>
<h3 class="text-lg font-semibold mb-3">ボタンリンク(カスタム記法)</h3>
<div class="bg-gray-50 p-4 rounded border mb-2">
<code class="text-sm">
&lt;button-link url="https://slack.com/channels/xxx"&gt;Slackに参加&lt;/button-link&gt;
</code>
</div>
<p class="text-sm text-gray-600 mb-2">青いボタンとして表示されます。チャットへのリンクなどに便利です。</p>
<div class="p-4 bg-white border rounded">
<p class="text-xs text-gray-500 mb-2">プレビュー:</p>
<a href="#" class="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-500 transition-colors no-underline">
Slackに参加
</a>
</div>
</div>

<!-- リスト -->
<div>
<h3 class="text-lg font-semibold mb-3">リスト</h3>
<div class="bg-gray-50 p-4 rounded border mb-2">
<code class="text-sm">
- 項目1<br>
- 項目2<br>
- 項目3<br>
<br>
1. 番号付き項目1<br>
2. 番号付き項目2<br>
3. 番号付き項目3
</code>
</div>
</div>

<!-- 引用 -->
<div>
<h3 class="text-lg font-semibold mb-3">引用</h3>
<div class="bg-gray-50 p-4 rounded border mb-2">
<code class="text-sm">
&gt; これは引用文です
</code>
</div>
</div>

<!-- コードブロック -->
<div>
<h3 class="text-lg font-semibold mb-3">コードブロック</h3>
<div class="bg-gray-50 p-4 rounded border mb-2">
<code class="text-sm">
```<br>
コードをここに書く<br>
```
</code>
</div>
</div>

<!-- テーブル -->
<div>
<h3 class="text-lg font-semibold mb-3">テーブル</h3>
<div class="bg-gray-50 p-4 rounded border mb-2">
<code class="text-sm">
| 列1 | 列2 | 列3 |<br>
| --- | --- | --- |<br>
| 値1 | 値2 | 値3 |<br>
| 値4 | 値5 | 値6 |
</code>
</div>
</div>
</div>

<div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded">
<h4 class="font-semibold text-blue-900 mb-2">プレビュー機能</h4>
<p class="text-sm text-blue-800">
イベント作成・編集画面では「プレビュー」タブで実際の見た目を確認できます。
</p>
</div>
</section>

<section class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-2xl font-bold mb-4 pb-2 border-b">よくある質問</h2>
<p class="text-gray-600">準備中...</p>
</section>
</div>
</div>
Loading