NaClの前田です。
NaClの公式サイトを先日リニューアルしました。
サイト自体はJekyllで生成しているのですが、ニュースをesaで更新できるようにしたのでその方法を紹介します。
esaには標準のGitHub Webhookがあり、それを使ってHeadless CMSとして使っている例が見つかりました。
ただ標準のWebhookはあまり細かい設定ができずJekyllと相性が悪かったり、削除にも対応していないようなので、Generic WebhookをAWS Lambdaで実装してGitHubリポジトリを更新することにしました。
なるべくJekyll側の設定は最小限にしたかったのですが、_postsのデフォルトのパスだと日付が含まれていて、esa側で日付を変更した場合の対応が厄介なので、stack overflowの記事を参考に_plugins/no_date.rbを作成して、記事のパスが news/_posts/<esaのページID>.md
になるようにしました。
class Jekyll::PostReader
# Don't use DATE_FILENAME_MATCHER so we don't need to put those stupid dates
# in the filename. Also limit to just *.md, so it won't process binary
# files from e.g. drafts.
def read_posts(dir)
read_publishable(dir, "_posts", /.*\.md$/)
end
def read_drafts(dir)
read_publishable(dir, "_drafts", /.*\.md$/)
end
end
以下のようにfront matterにesaの記事の日付を記載して、URLには日付が含まれるようにしています。
---
title: 中高生国際Rubyプログラミングコンテストに協賛
date: 2023-12-04
layout: news
---
記事の作成・更新時には、octokit.gemを使ってGitHubリポジトリを更新します。
環境変数GITHUB_TOKENにはFine-grained personal access tokenを使用しています。
GITHUB_REPO = "NaCl-Ltd/www.netlab.jp"
GITHUB_REF = "heads/main"
github = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"])
sha_latest_commit = github.ref(GITHUB_REPO, GITHUB_REF).object.sha
sha_base_tree = github.commit(GITHUB_REPO, sha_latest_commit).commit.tree.sha
attachment_files, body =
extract_attachment_files(github, number, content)
blob_content = <<EOF
---
title: #{title}
date: #{date}
layout: news
---
#{body}
EOF
sha = github.create_blob(GITHUB_REPO, [blob_content].pack("m"), "base64")
new_tree = [
{
:path => filename,
:mode => "100644",
:type => "blob",
:sha => sha
},
*attachment_files
]
sha_new_tree = github.create_tree(GITHUB_REPO, new_tree,
{ base_tree: sha_base_tree }).sha
sha_new_commit = github.create_commit(GITHUB_REPO, commit_message,
sha_new_tree, sha_latest_commit).sha
github.update_ref(GITHUB_REPO, GITHUB_REF, sha_new_commit)
extract_attachment_filesでは画像などの添付ファイルを抽出してBLOBを作成しています。
def extract_attachment_files(github, number, content)
esa = Esa::Client.new(access_token: ENV["ESA_TOKEN"], current_team: "nacl")
attachment_files = []
body = content.gsub(%r'https://(files|dl).esa.io/uploads/[^\s")]+') { |url|
basename = File.basename(url)
path = "attachments/news/#{number}/#{basename}"
url = esa.signed_url(URI.parse(url).path).body["url"]
data = Net::HTTP.get(URI(url))
sha = github.create_blob(GITHUB_REPO, [data].pack("m"), "base64")
attachment_files << {
:path => path,
:mode => "100644",
:type => "blob",
:sha => sha
}
"/#{path}"
}
return attachment_files, body
end
create_treeに { base_tree: sha_base_tree }
を渡さずに、削除したいファイルを除外したnew_treeを指定すれば削除できるという記事があったのですが、この方法では削除されませんでした。
base_tree = github.tree(GITHUB_REPO, sha_base_tree, recursive: true)
new_tree = base_tree.tree.filter_map { |blob|
!%r'\A(news/_posts/#{number}\.md\z|attachments/news/#{number}/)'.match?(blob.path) &&
{
path: blob.path,
mode: blob.mode,
type: blob.type,
sha: blob.sha
}
}
sha_new_tree = github.create_tree(GITHUB_REPO, new_tree).sha
削除したいファイルのshaにnullを指定すればよいという記事があったので以下のように修正したところ記事の削除もできました。
base_tree = github.tree(GITHUB_REPO, sha_base_tree, recursive: true)
new_tree = base_tree.tree.filter_map { |blob|
%r'\A(news/_posts/#{number}\.md\z|attachments/news/#{number}/)'.match?(blob.path) &&
{
path: blob.path,
mode: blob.mode,
type: blob.type,
sha: nil
}
}
sha_new_tree = github.create_tree(GITHUB_REPO, new_tree,
{ base_tree: sha_base_tree }).sha
なお、当初はWebhookの設定でフィルタを指定すると記事の削除時にWebhookが呼ばれない問題がありましたが、Xで愚痴をこぼしたところesaの不具合だったようで修正されました。ありがとうございました。
require "openssl"
require "json"
require "rack"
require "octokit"
require "esa"
GITHUB_REPO = "NaCl-Ltd/www.netlab.jp"
GITHUB_REF = "heads/main"
def lambda_handler(event:, context:)
unless valid_signature?(event)
return { statusCode: 400, body: "Invalid signature" }
end
esa_event = JSON.parse(event["body"])
log(esa_event: esa_event)
year, month, day, title =
esa_event["post"]["name"].scan(%r"\APublic/News/(\d+)/(\d+)/(\d+)/(.*)")[0]
if title.nil?
log("Ignore: #{esa_event['post']['name']}")
else
number = esa_event["post"]["number"]
kind = esa_event["kind"]
case kind
when "post_create", "post_update"
if esa_event["post"]["wip"]
log("WIP: do nothing")
else
filename = "news/_posts/#{number}.md"
date = "#{year}-#{month}-#{day}"
content = esa_event["post"]["body_md"].gsub(/\r\n/, "\n")
post_news_entry(kind, number, filename, date, title, content)
end
when "post_delete"
delete_news_entry(number, title)
end
end
{ statusCode: 200, body: "Success!" }
end
def valid_signature?(event)
body = event["body"]
sig = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"),
ENV["ESA_SECRET"], body)
header_sig = event["headers"]["x-esa-signature"].delete_prefix("sha256=")
if Rack::Utils.secure_compare(sig, header_sig)
true
else
log_error("Invalid signature: #{sig} != #{header_sig}")
false
end
end
def post_news_entry(kind, number, filename, date, title, content)
verb = kind.slice(/post_(.*)/, 1).capitalize + "d"
msg = "#{verb} via esa webhook: number=#{number} title=#{title}"
commit(msg) do |github, sha_base_tree|
attachment_files, body =
extract_attachment_files(github, number, content)
blob_content = <<EOF
---
title: #{title}
date: #{date}
layout: news
---
#{body}
EOF
sha = github.create_blob(GITHUB_REPO, [blob_content].pack("m"), "base64")
[
{
:path => filename,
:mode => "100644",
:type => "blob",
:sha => sha
},
*attachment_files
]
end
end
def extract_attachment_files(github, number, content)
esa = Esa::Client.new(access_token: ENV["ESA_TOKEN"], current_team: "nacl")
attachment_files = []
body = content.gsub(%r'https://(files|dl).esa.io/uploads/[^\s")]+') { |url|
basename = File.basename(url)
path = "attachments/news/#{number}/#{basename}"
url = esa.signed_url(URI.parse(url).path).body["url"]
data = Net::HTTP.get(URI(url))
sha = github.create_blob(GITHUB_REPO, [data].pack("m"), "base64")
attachment_files << {
:path => path,
:mode => "100644",
:type => "blob",
:sha => sha
}
"/#{path}"
}
return attachment_files, body
end
def delete_news_entry(number, title)
msg = "Deleted via esa webhook: number=#{number} title=#{title}"
commit(msg) do |github, sha_base_tree|
base_tree = github.tree(GITHUB_REPO, sha_base_tree, recursive: true)
base_tree.tree.filter_map { |blob|
%r'\A(news/_posts/#{number}\.md\z|attachments/news/#{number}/)'.match?(blob.path) &&
{
path: blob.path,
mode: blob.mode,
type: blob.type,
sha: nil
}
}
end
end
def commit(commit_message)
github = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"])
sha_latest_commit = github.ref(GITHUB_REPO, GITHUB_REF).object.sha
sha_base_tree = github.commit(GITHUB_REPO, sha_latest_commit).commit.tree.sha
new_tree = yield(github, sha_base_tree)
log(new_tree: new_tree)
sha_new_tree = github.create_tree(GITHUB_REPO, new_tree,
{ base_tree: sha_base_tree }).sha
sha_new_commit = github.create_commit(GITHUB_REPO, commit_message,
sha_new_tree, sha_latest_commit).sha
github.update_ref(GITHUB_REPO, GITHUB_REF, sha_new_commit)
end
def log(msg = nil, **params)
puts(msg) if msg
puts(params.to_json) if !params.empty?
end
def log_error(msg)
STDERR.puts(msg)
end
esaのGeneric Webhookを実装することで、Jekyll側にあまり手を入れずに記事の更新・削除ができるようになりました。
Generic Webhookは便利なのでみなさんも活用してみてください。