おそらくはそれさえも平凡な日々

R2を同期するr2syncというツールをRustで書いてcrate公開した

https://crates.io/crates/r2sync

コマンドラインツールであり以下でインストールできる。

$ brew install Songmu/tap/r2sync
# or
$ cargo install r2sync

これはローカルディレクトリの中身をCloudflare R2に簡易的に同期するごく単純なツールで以下のように使う。

$ r2sync ./dir r2://your-bucket/path

リモートに同一ファイルが存在する場合にputをスキップするようになっていて、それが欲しくて作った。ちなみに、--public-domain というオプションを付けると、同一ファイルチェックを公開URL経由で行うようになってAPIアクセスを減らせる。

$ r2sync --public-domain files.example.com ./dir r2://your-bucket/path

ファイルの同一性チェックは、Content-LengthとETagを見ている。S3やR2はETagがコンテンツのMD5ハッシュ値なので、それで同一性チェックをしている。この挙動が未来永劫担保されるかわからないが、単にContent-Lengthだけ見るのも嫌だったし、実際にContent-Lengthだけ主に見ている aws s3 sync がたまにハマるという話も聞くのでそうした。

GitHub Actions

カスタムアクションも公開していて、以下のように使える。oss4.funでも導入した。

- uses: Songmu/r2sync@v0
  with:
    r2_account_id: ${{ secrets.R2_ACCOUNT_ID }}
    r2_access_key_id: ${{ secrets.R2_ACCESS_KEY_ID }}
    r2_secret_access_key: ${{ secrets.R2_SECRET_ACCESS_KEY }}
    src: ./audio
    dest: r2://<your-bucket>/audio
    public_domain: files.example.com

作った動機

ポッドキャストの音声ファイルのアップロードをGitHub Actionsでやっているが、ディレクトリ内の全部の音声ファイルを毎回素朴にputしていたので流石に富豪すぎるので解決しようと考えたのが契機。

案外、既存の良いツールが見つけられなかったのと、aws s3 sync を使っても良かったのだけど、前述のETagの話もあったし、せっかくR2はエグレス料金が無料なのだからファイルの同一性チェックを公開URL経由でおこなうアイデアを盛り込んで作った。

Rust

習作がてらちょっとしたものをRustで作ってみたいと思っていたので、ちょうどよい題材だった。strとStringの使い分けとか、unwrapを使いすぎだったりとかまだまだお作法が分からない部分が多いが、とりあえずcrate公開までいけたのは良かった。Resultとかパターンマッチ含めた言語自体の書き味はかなり良い。

思っていたよりクロスビルド周りが難しくて、各プラットフォームにバイナリを提供するのに手こずった。とりあえず、GitHub Actions上で作るのは一旦断念して手元でバイナリをビルドしてGitHub Releasesにghrでアップロードするという一昔前のスタイルでお茶を濁した。

Rustを書き始めるにあたって「Rustの練習帳」が参考になった。Rustの考え方やコマンドラインツール作成について実践を通して学べる点で有益だった。Goでもこういう本があると良さそう(すでにあるかも)、とか思った。

「趣味でOSSをやっている者だ」というポッドキャストを始めた

最近御存知の通り(?)ポッドキャストづいていて、ポッドキャストについて色々調べてサイト構築ツールなどを作っていたが、ツールを作ったらやはり使いたくなってポッドキャストを始めてみることにした。

以前アナウンスした拙作のポッドキャスト生成OSSのPodbardの実例を示す場にもしたかったので、運営リポジトリも公開している。是非参考にしてみてください。一応、同期しているprivateリポジトリもあって、そのあたりの仕組みは別途解説するかも。

更新頻度はあまり考えてないけど、月に数本、できれば毎週、30分程度のエピソードを出したいと思っている。第2回目までは録り終わっていて今週公開予定。ゲストも次の次まで決まっているので一応しばらく続くと思いたい。

名前の由来

お気付きの通り、ワンパンマンの有名なセリフ「趣味でヒーローをやっている者だ」のオマージュ。力が抜けつつも強そうで良い。

このセリフの英訳が I'm just a guy who’s a hero for fun. らしいので、ポッドキャストの英題も "Just a guy who develops OSS for fun." として、そこからサイトのドメインやハッシュタグを決めた次第。#oss4fun です。よろしく。思いがけず"ossan"に空目した、という意見もあって、なるほど、となった。

ドメインは取るつもり無かったのだけど、空いていたし、短くて気に入ったので取得した。funドメイン、割とお安めで良かった。

購読お待ちしています

少し話してしまったが、ポッドキャストについてはポッドキャスト内で話せればと思うので、ここではこれくらいにしておきます。もう少しノウハウ溜まってきたら、それはそれでエントリを書くかも知れない。

まずは購読してもらえると嬉しいですが、上記のハッシュタグかお便りフォーム(https://oss4.fun/voice)で感想や要望などいただけると更に大変嬉しいです。何も考えてないけど、上記リポジトリのDiscussionsとかに何か投稿してもらえるとだいぶ面白いと思うのでチャレンジャー求む。

おまけ

独自YAMLファイルをJSON SchemaでLSP補完する

Podbardpodbard.yamlに設定を記述するが、これをエディタで補完したりヒントを出せたりするようにした。

yaml-language-serverとJSON Schema

普段vimで開発してて、GitHub ActionsのYAMLを触ってるときなどに、エディタが適切にヒントを出してくれるのを便利に感じつつ「多分LSPがうまいことやってくれてるんだろうな」くらいに考えて深く追いかけていなかった。これは、JSON Schemaで実現されていることを、今回podbard.yamlの仕様をJSON Schemaで記述している過程で発見した。

GitHub ActionsのJSON Schemaは https://json.schemastore.org/github-action.jsonhttps://json.schemastore.org/github-workflow.json で公開されており、YAMLファイルを編集するときに、yaml-language-serverが、それをいい感じに読み込んで支援してくれる。LSPなので当然VSCodeやその他エディタでも利用できる。

JSON Schema Store

このいい感じにスキーマを読み込むための、世界共通のスキーマ類を管理しているのが、JSON Schema Storeというサイト。ここで、https://raw.githubusercontent.com/SchemaStore/schemastore/master/src/api/json/catalog.jsonという大きなJSONファイルが公開されており、どういったファイルがどのスキーマに対応するか、という情報が記述されている。以下はGitHub ActionsのWorkflowの例。

{
  "name": "GitHub Workflow",
  "description": "YAML GitHub Workflow",
  "fileMatch": [
    "**/.github/workflows/*.yml",
    "**/.github/workflows/*.yaml",
    "**/.gitea/workflows/*.yml",
    "**/.gitea/workflows/*.yaml",
    "**/.forgejo/workflows/*.yml",
    "**/.forgejo/workflows/*.yaml"
  ],
  "url": "https://json.schemastore.org/github-workflow.json"
}

yaml-language-serveryaml.schemaStore.enable という設定があり、これがtrueだとこのサイトから自動でスキーマを引っ張ってきてくれる。デフォルトでtrue

独自のJSON Schema

もちろん独自のJSON Schema設定も記述できる。README.mdに詳細に書かれているが、 主に、yaml.schemas 設定と、YAMLファイル内にモードラインコメントを記述する方法がある。モードラインコメントは、プロジェクトのリポジトリ内で、チームメンバーに共通でスキーマを読み込んでもらいたい場合に重宝しそうです。これは至極単純で、YAMLファイル内に以下のようなコメントを記述するだけです。

# yaml-language-server: $schema=<urlToTheSchema>

$schema に指定する文字列は公開URL、絶対・相対パス形式、いずれも可能です。ちなみに、JSON Schemaは本来JSONで記述しますが、YAMLで記述したものを直接読み込ませることも可能でした。

今回のPodbardの場合、以下のコメントを書けばLSPの支援が効くようになった。GitHubのrawコンテンツを指定するだけでお手軽。

# yaml-language-server: $schema=https://raw.githubusercontent.com/Songmu/podbard/main/schema.yaml

JSON Schema Storeへの登録

さて、今回のPodbardの場合、できればモードラインコメントせずとも補完が効くようにしたい。これは https://github.com/SchemaStore/schemastorecatalog.json を編集してpull requestを送れば良いようだ。

しかし、ここにマイナーOSSの設定ファイルのスキーマを登録するのは少し気後れする。そこで、直近のpull requestをいくつか観察してみると、割と個人レベルでのOSSであっても、快く迅速に取り込んでくれている様子だったので、以下のpull requestを送ったら半日程度でシュッとマージしてくれてありがたかった。

https://github.com/SchemaStore/schemastore/pull/4091

{
  "name": "podbard.yaml",
  "description": "Configuration file for Podbard - a podcast site generator",
  "fileMatch": ["podbard.yaml"],
  "url": "https://raw.githubusercontent.com/Songmu/pokkdbard/main/schema.yaml"
}

変更内容は上記で、スキーマのURLにyamlを直接指定する暴挙にでているが、すでに登録されている設定でもYAMLを直接しているものもあったので、いけるかな思って恐る恐るpull requestを出してみたが取り込んでもらえた。まあ、podbard.yamlはYAMLでしか設定ファイルを提供するつもりは無いので、スキーマもYAMLで良いでしょというところ。

vim-lsp-settingsへの対応

これはおまけだが、大変お世話になっているvim-lsp-settingsでは、上記のJSON Schema Storeのcatalog.json をリポジトリ内に同期して保持し、それを読み込むようになっている。yaml-language-serverは前述の通り、実はJSON Schema Storeを直接見に行くようになっていたので対応不要だったが、このスキーマ情報は当然 json-languageserverやその他LSPでも使われるものなので、更新しておく価値はある。ということで以下のpull requestで取り込んでもらった。

https://github.com/mattn/vim-lsp-settings/pull/774

この記事で書いたように、このcatalog.jsonは結構カジュアルに更新されるものなので、自動更新の仕組みが入っても良いとも思ったが、そこまではやらなかった。

まとめ

YAML設定ファイルに対してJSON Schemaを書いておくことで簡単にエディタの支援が効くようになるのは、今更の話かもしれないが、かなりお役立ち情報だった。

また、JSON Schema Storeへの登録は割と気軽にできることが分かったのも収穫だった。当然節度は必要だが、皆さんも機会があれば自作OSS等定義した有用なスキーマを登録してみてはいかがだろうか。

Podbardというポッドキャストサイト構築ツールを作った

https://github.com/Songmu/podbard

結果としてできたものはyattecastHugoの間の子のようなモノになった。音声ファイルとそれに対応するエピソードファイルをfrontmatter付きのMarkdownで記述する。最終的に静的サイトとしてポッドキャストサイトを生成する。

podbard-starterというテンプレートリポジトリがあるので、ここからリポジトリを作ればすぐにポッドキャストサイトを作成できる。このテンプレートはGitHub Pagesにデプロイするモノだが、Cloudflare Pagesにデプロイする、podbard-cloudflare-starterや、それを応用してプライベートプッドキャストを構築する、podbard-private-podcast-starterというのも用意している。

まだ不十分だがドキュメントも以下に用意してある。このドキュメントサイトもPodbardを使ってドッグフーディングしているのがおもしろポイント。ドキュメントをOpenAIのText to speechに読み上げさせたものを音声としている。

https://junkyard.song.mu/podbard/

作った動機

ポッドキャストはブログの拡張技術なので、そもそも作りたいと思っていた。発想はyattecastとほとんど同じだが、社内ポッドキャスト構築にあたって、GitHub Page以外にも簡単にデプロイできることや、音声ファイルをS3やR2などのオブジェクトストレージに分離するアプローチを取りやすくしている。

今だったら、Cloudflare Pagesにコンテンツを配置して、Cloudflare R2に音声ファイルを置くのが個人で始めるにはお手軽のおすすめ構成。

ブログとポッドキャスト

古いWebエンジニアであれば、ポッドキャストがブログの拡張から始まったことは知っている人は多いと思う。ブログの各エントリに音声ファイルを添付する、技術的にはブログのRSSの各itemにenclosure要素というものを追加し、そこに音声ファイルのURLを指定しておくだけでポッドキャストになる。例えばこんな具合。

<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
  <channel>
    <title>Podcast Title</title>
    <link>https://example.com</link>
    <language>en-us</language>
    <description>This is a sample podcast description.</description>
    <item>
      <title>Episode 1 Title</title>
      <description>This is a description of episode 1.</description>
      <link>https://example.com/episode1</link>
      <guid>https://example.com/episode1</guid>
      <pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
      <enclosure url="https://example.com/episode1.mp3" length="12345678" type="audio/mpeg"/>
    </item>
  </channel>
</rss>

ポッドキャストは、オープンで枯れた規格であり、それは今も変わらない。なので、ブログ同様に専用サービスを利用せずとも、個人でサイト運営を始められる気軽な選択肢があって欲しい。ポッドキャストはそういうIndieな活動でもあるからだ。

専用サービスを使わずともポッドキャストができることはもっと知られて欲しいし、少なくともRSSを配信していないサービスがポッドキャストとか言われてしまうのはちょっと勘弁、という気持ちがある。

そういう思いや、単なる技術的興味、そして必要にかられたのにかこつけて、カッとなって作ったのがこのPodbard。

Podbardの由来など

当初は音声ファイル一覧からRSSを生成するだけのシンプルなものを考えていたが、やっぱり音声のメタ情報を書く場所が必要だと言うことに気づき、それをfrontmatter付きのMarkdownにするのが良かろうという考えに至った。結果としてyattecastと同じ発想に至っていた。

また、私は当ブログのエンジンであるRijiの開発や、はてなブログ管理ツールであるblogsyncのメンテナをしていていたりもするので、「またブログツールを作ってるな…」みたいな気持ちにもなった。

Podbardは、ポッドキャストのPodと吟遊詩人のbardを掛け合わせた言葉。色々候補名は考えたが、類似の名前も使われてなさそうだったんでこれにした。少し中世の雰囲気を出したかったので良かったと思う。今調べていて気づいたけど、GoogleのGeminiの旧称がBardでしたね。

今回名前候補を考える壁打ちだったり、starterのデフォルトCSSものベース作成などでChat GPTを活用した。あと、アイキャッチイラストもImageFX に出力してもらうなど、生成AIを活用した。音声読み上げにText to speechを使った話も冒頭に書いたが、こういう個人サービス開発でAIツールがかなり便利に活用できることを感じた開発だった。

Cloudflare PagesにそれなりにちゃんとBasic認証をかける

前回の、社内プライベートポッドキャスト実現方法で、ポッドキャストサイトを静的配信しつつBasic認証をかけるというアイデアを書いた。しかし、Basic認証などなかなか使わなくなり、ネイティブでサポートしている静的ホスティングサービスも少ない。今回はCloudflare PagesのFunctions機能でリクエストをラップするミドルウェアを書けば実現できることが分かり、その方式を採用することにした。多少実装必要になるのと、認証周りを自前で書くのはあまりやりたくはないが、廉価に比較的省力で実現できるので受け入れる。

ネット上にいくつかサンプルは見つかるが、今回実装するにあたっては以下の点を留意した。

これらを以下のように解決することとした。

ちなみに、定数時間比較については、サボって普通の文字列比較をしていたら、同僚が指摘をしてくれた。持つべきものは優秀な同僚である。やはりこういう処理は自分では書きたくないですね。定数時間比較は、Node.jsにもcrypto.timingSafeEqual が標準搭載なので便利な世の中になった。ところで、文字列の定数時間比較については10年前にも元同僚が、GitHubのWebhookの署名文字列の検証関連で指摘してくれたことも思い出したので昔から周囲に恵まれている。

ということで、作ったものが以下。これをプロジェクトリポジトリにfunctions/_middleware.ts として配置し、Cloudflare Pagesにデプロイすれば、Basic認証をかけてくれる。便利。これは、podbardのプライベートポッドキャストのテンプレートリポジトリでも公開している ものと同じなので、何か問題があれば指摘してもらえると嬉しいです。

// Created by Masayuki Matsuki (Songmu) on 2024-09-11
// Copyright © 2024 Masayuki Matsuki. Licensed under MIT.
//
// ref. https://developers.cloudflare.com/pages/functions/middleware/
// ref. https://developers.cloudflare.com/pages/functions/api-reference/#eventcontext
type EventContext = {
  request: Request;
  next: (input?: Request | string, init?: RequestInit) => Promise<Response>;
  env: {
    ASSETS: object;
    CF_PAGES: string;
    CF_PAGES_BRANCH: string;
    CF_PAGES_COMMIT_SHA: string;
    CF_PAGES_URL: string;
    [key: string]: any;
  };
};

type Middleware = (context: EventContext) => Promise<Response>;

type Middlewares = Middleware | Middleware[];

const errorHandler: Middleware = async ({ next }: EventContext):
  try {
    return await next();
  } catch (err: unknown) {
    console.log(`Error: ${err.message}\n${err.stack}`);
    return new Response("Internal Server Error. Please contact the admin", { status: 500 });
  }
};

const passEnvPrefix = "PASSWORD_";

const basicAuth: Middleware = async ({ request, next, env }: EventContext): Promise<Response> => {
  if (!request.headers.has("Authorization")) {
    return new Response("You need to login.", {
      status: 401,
      headers: {
        "WWW-Authenticate": 'Basic realm="Input username and password"',
      },
    });
  }
  const authorizationHeader = request.headers.get("Authorization");
  if (!authorizationHeader) {
    return new Response("Authorization header is missing.", {
      status: 400,
    });
  }

  const [scheme, encoded] = authorizationHeader.split(" ");
  if (!encoded || scheme !== "Basic") {
    return new Response("Malformed authorization header.", {
      status: 400,
    });
  }

  const buffer = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0));
  const decoded = new TextDecoder().decode(buffer).normalize();
  const index = decoded.indexOf(":");

  if (index === -1 || /[\0-\x1F\x7F]/.test(decoded)) {
    return new Response("Invalid authorization value.", {
      status: 400,
    });
  }

  const username = decoded.substring(0, index);
  const password = decoded.substring(index + 1);
  if (password === "") {
    return new Response("Password is empty.", {
      status: 401,
    });
  }

  const key = passEnvPrefix + username.toUpperCase();
  const storedPassword = env[key];
  const userExists = typeof storedPassword === "string";
  // Even if the user does not exist, a constant time comparison with a dummy string is performed
  // to prevent timing attacks.
  const matchPassword = await compareStrings(password, userExists ? storedPassword : "dummy");
  if (!userExists || !matchPassword) {
    return new Response("Invalid username or password.", {
      status: 401,
    });
  }

  return await next();
};

async function sha256(str: string): Promise<ArrayBuffer> {
  const encoder = new TextEncoder();
  const data = encoder.encode(str);
  return crypto.subtle.digest("SHA-256", data);
}

async function compareStrings(a: string, b: string): Promise<boolean> {
  const hashA = await sha256(a);
  const hashB = await sha256(b);

  // The buffer lengths must be the same, and they were aligned to the same length by hashing,
  // but we are checking to be sure because timeSafeEqual will throw an exception if the lengths
  // are different.
  // ref. https://developers.cloudflare.com/workers/examples/protect-against-timing-attacks/
  if (hashA.byteLength !== hashB.byteLength) {
    return false;
  }
  // ref. https://developers.cloudflare.com/workers/examples/protect-against-timing-attacks/
  return crypto.subtle.timingSafeEqual(hashA, hashB);
}

export const onRequest: Middlewares = [errorHandler, basicAuth];

社内プライベートポッドキャスト実現方法

所属している、ヘンリー社には、社内ラジオコンテンツがあり、Notion上に音声ファイルを置く形で実現されている。これを、ポッドキャスト化してポッドキャストクライアントで聞きたいというのが動機。ちゃんとしたオープンな規格としてのポッドキャストにしたい。

もちろん、公開はせずプライベートなものにしたい。ただ、ポッドキャストはオープンコンテンツ前提の規格になっているため完全な実現は難しい。認証のかかっていないRSSフィード及び、そのRSSフィードに埋め込まれたMP3等の音声ファイルにも認証がかかっていないことが前提となるからだ。

やるからには、あまりコストを掛けずに静的配信をベースにしたい。お手軽なプライベートポッドキャストサービスもあまりないようだ。

基本方針

それに対する現実的な妥当解を考え、その実現のために、まずポッドキャストサイトを生成するpodbardというOSSを作った。そして、それを使って、簡単にプライベートポッドキャストを開始できる、podbard-private-podcast-starterというテンプレートリポジトリも公開した。これは、コンテンツをCloudflare Pagesに、音声ファイルをR2にdeployする仕組みになっている。外部サービスを使わず、自前でポッドキャストサイトを生成するものだ。

podbardやこのCloudflareを利用したこのテンプレートに関して別途解説するが、ここでは、実験結果も踏まえ、プライベートポッドキャストの実現方法を解説する。完全にプライベートにするのは難しいが、多重に防御することで実現しようとしている。具体的には以下。ちなみに、上記のテンプレートはこれら対策がすべて盛り込まれている。

これで、よっぽどの機密情報が話されているとかではなければ、実用上は十分プライベートと言えるのではないだろうか。代表的な動画配信サイトのプライベート動画であっても、実は生の動画URLを入手できればダウンロード可能だったりする。ちなみに、GitHubのアップロード画像は、ちゃんと認証がかかるようになりましたね。

Spotifyにもプライベートポッドキャストの機能があるようだが、「プライベートRSSフィード」という言葉があるので、概ね似たような仕組みになっているのではないかと思う。Basic認証は使ってない気はするが。

さて、防御方法についてそれぞれ解説していく。

推測しづらいURLにする

例えば、以下のような具合。

FQDN(ホスト名)は平文DNSから漏れる可能性があるが、パスをランダム文字列にすればhttpsであればテクニカルにはURLが判明する可能性は低い。

Basic認証をかける

冒頭に「認証はかけられない」と書いたが、HTTPに備わっているステートレスな認証方式であるBasic認証はかけられる。これによって、URLが漏洩しても直ちに影響はないし、ユーザー毎に異なるパスワードも設定できるので退職時等の失効も可能。

ちなみに、Basic認証は以下のように認証情報をURLに含められる。この文字列が漏洩してしまうと、クリック一発でコンテンツにアクセスできてしまうので注意。

https://username:p4ssw0rd@mypodcast.example.com/{random-path}/feed.xml

この完全なフィードURLをポッドキャストアプリに登録してやれば、ポッドキャストの購読ができる。もしくは、認証情報を含まないURLであっても、認証情報を聞かれるダイアログが開いたのでそれに入力すれば購読できる。iPhoneのポッドキャストアプリや、私が使っているOvercast では、いずれの方法でも購読が確認できた。

クローラーを避ける

検索エンジンのクローラーを避け、余計なインデックス登録をブロックする。robots.txtを配置する。HTMLにはmetaタグを記述すればよいが、HTML以外のコンテンツのインデックス登録を防ぐために、HTTPヘッダも付与できると良い。

robots.txt

User-Agent: *
Disallow: /

参考:

リファラでのURL漏洩を防ぐ

Show Note上のリンクなど、外部サイト遷移時のリファラによるURL漏洩は避けたい。とは言え、現代のブラウザのリファラポリシーは基本的にはstrict-origin-when-cross-originになっており、外部遷移時の漏洩は実はそこまで気にする必要はない。URLのパスは漏洩せず、FQDNが渡るだけである。平文DNSがまだ多い現時点では、FQDNは漏れうるものだという前提に立った方が良い。

とは言え、要らぬ情報が外部に渡ることは避けたいだろうので、その場合はクローラー避け同様に、メタタグ及び可能ならHTTPヘッダにリファラポリシーを設定すると良い。

ポリシーはno-referrer でもよいが、同一オリジン内であれば、リファラがわたっても問題ないし、寧ろ遷移元情報が分かったほうが良いという話もあるので、same-originが良いのではないでしょうか。

参考:

音声ファイルに認証をかけないことについて

そもそも、サイトコンテンツは静的に書き出してBasic認証をかぶせる、音声は何らかのオブジェクトストレージにアップロードする、というのがお手軽なのでそうしたかった。

音声ファイルにもBasic認証をかけ、RSS内の音声ファイルのURLを https://username:p4ssw0rd@audiobucket.mypodcast.example.com/{random-path}/foo.mp3 のように、認証情報を含めた記述にするというのも機能するのかも知れないが(未検証)、その場合、RSSフィードをユーザーごとに動的配信したり、全ユーザー分書き出すなどの対応が必要になるので、やらないことにした。

何にせよ、せいぜいBasic認証しかかけられないし、それでも結局、完全なURL文字列が漏れたら、一発でアクセス可能なので、あまりリスク軽減になっていないというのもある。なので、オブジェクトストレージのバケットのルートにrobots.txtを置いておくくらいの対策に留めた。

まとめ

ということで、プライベートポッドキャスト実現方法について書いてみた。ご意見あればフィードバックいただけると嬉しいです。

また、podbard及び、podbard-private-podcast-starterは一応使い始められるようになっている。テンプレートリポジトリについては、そこから新たにリポジトリを作れば、すぐにこのページで解説した方法でのプライベートポッドキャストが実現できるようになっている。

使い方などは追って解説エントリを書きますが、是非試してみてほしいし、わからないことがあれば聞いてくれればできる限り回答したいので、ぜひお試しください。

Macでオンラインミーティング中に電球を点ける

夏休みに入って、子供たちが仕事部屋に乱入してくることが増えた。何番煎じかわからないが、オンラインミーティングが始まったら電気を点ける仕組みを作って投入した。カメラがついている時にミーティング中だという判定をしてライトを点灯する。概要は以下。

私以外にこの仕組みを使う人がいるとは思わないが、以下の手順で導入できる。

カメラのon/off検知

yoshioriさんがLinuxで同じようなことをやっているが、カメラの利用検知はMacだと案外難しい。そこで、カメラやマイクのon/offを検知してくれるOverSightというソフトウェアをインストールして利用する。これ自体危険性を孕むソフトウェアだが、そういう不正なカメラやマイク利用を検知するためのソフトウェアであり、ソースもGitHub上で公開されている。

検知をトリガーにプログラムを実行する

このOverSight、カメラやマイクのon/offのイベントを通知してくれるだけではなく、そのイベントをトリガーにしてプログラムを実行できる。

今回、その仕組みに乗っかってライトを制御するためにGoで作ったのが github.com/Songmu/mtglight 。これを適当なところに配置して、OverSightの設定画面から以下のようにしていする。ここでは直接していせずに .sh を指定しているが、これはシェルスクリプトでラップしているため。排他制御が気にならなければ別に直接しても良い。

この画面に示されているように、プログラムにはいくつかのコマンドライン引数が渡される。このコマンドライン引数を見ることで、イベントの種類やデバイスの状態を判断できる。

-device に cameraかmicrophone、-event に onかoff、-activeCount にアクティブなデバイス利用数の合算値が渡される。合算値というのはカメラとマイクの合計で、複数のオンラインミーティングを開いているときなどに複数プロセスからデバイスが利用されている場合には、その延べカウントになる。カメラとマイクの合算値になるのは少し使い勝手が悪い。

ちなみに、この -process という引数はデバイス呼び出し元のPIDが取れるので、そのプロセスの死活監視をすればミーティング開催中判定ができるのではないかと期待したが、その用途には使えなかった。このプロセスはオンラインMTG自体のプロセスではなく、デバイス管理プロセスかなにか別のプロセスであり、ミーティング終了後も基本的には生存し続けていたからだ。

今回、一番気になるのがカメラなので、cameraのonイベントが送られてきた時に点灯するようにした。また、ライトを消灯するのは、cameraのoffイベントが送られてきたときではなく、activeCountが0になった時に消灯するようにした。これは、cameraのoffイベントが通知されたとしても、別のウィンドウではまだカメラが使われている可能性があるからである。activeCountが0であればマイクも含めてすべてのデバイスがoffになったということなので、消灯してもよかろうという判断。また、これによって、MTG中に一時的にカメラをoffにしても消灯されてしまうこともない。

プログラムからIoTライトを操作する

プログラムからは Yeelight の電球を操作している。前項の mtglight のソースコードを読むとわかるが、同一ネットワークにつながったYeelightの電球を探し、黄色を最大輝度で決め打ちで点灯するようにしている。なので、自宅の外のネットワークに繋いだ時に、そこでYeelightが使われていたら勝手に操作してしまうかもしれない😎

操作するのは何でも良くて、yoshioriさんがやっているようにスマートプラグを使うのは自由度が高くて良いと思う。 mtglight でそのあたり抽象化しても良いかと思ったが、別にそこまでするまでもないと思ったのでやっていない。

多重実行を防ぐ排他制御をしこむ

mtglight はOverSightからイベントドリブンで呼び出されるため、多重起動される可能性がある。そこをちゃんと排他に直列で実行されるようにしたい。そこで setlock を使う。

setlock はバッチの多重起動を防ぐための古式ゆかしいコマンド。daemontoolsに同梱されており、MacでもHomebrewをお使いであれば、 brew install daemontools でインストールできる。Homebrewを使っていない場合、頑張って自分でビルドするか、moznionさんが作ったGo製の go-setlock があるのでそれを使っても良いかもしれない。

このsetlockを使って以下のようなシェルスクリプトをmtglightが配置されたディレクトリに配置する。これでこのスクリプトが多重で呼び出された場合であっても、mtglight は多重起動されず、別プロセスの終了を待って、直列で実行されるようになる。便利。

#!/bin/sh
cd $(dirname $0)
exec /opt/homebrew/bin/setlock ./mtglight.lock ./mtglight "$@"

まとめ

Yeelightはともかく、OverSightを使ってデバイスのon/offを検知するのと、それによってプログラムをトリガーして、排他制御にsetlockを使う、というのは応用が効くと思うので、他の方も是非試してみてほしい。

builderscon 2024に行って代打登壇もしてきた

復活のbuilderscon 2024に行ってきた。プロポーザル出して落ちており、チケット買うのも失念していたのだけど、大吉祥寺.pmでnasa9084さんから「実はあまり宣伝もして無いんですけどチケット売り切れそうなんですよね」みたいな話を聞いてその場で焦ってチケットを買った、ということがあった。その時に近くを通りがかった稲尾さんに声をかけてチケットを買わせるなどもした。

なので、今回は気楽に聴衆として参加予定だった。そうしたら前日夕方に、nasa9084 さんから「登壇者が一人急遽参加ができなくなりそうで、可能なら代打してくれないか」という連絡があった。なかなかないケースではあるが、そこに面白みも感じたし、お声がけは嬉しく引き受けることにした。登壇者が個人都合により登壇できなくなるということは確率的には起きうることで仕方がない。とは言え、シングルトラックのカンファレンスはこういう時に大変だな、とは思った。

今回元々出していたプロポーザルは、YAPC::Hiroshimaで話した内容をベースに、もう少し技術に踏み込んだ話にする予定だった。IndieWeb、Fediverse、Activity Pubなどについて調べて解説し実際に自分のWebサイトに組み込む、みたいなことをしたかったのだが、流石に前日では間に合わず、YAPC::Hiroshima以降のアップデートや、IndieWeb関連の話をした。

https://junkyard.song.mu/slides/builderscon-2024/#0

代打登壇ではあるが、buildersconに登壇できたのは本当に嬉しかった。これまでも今回も圧倒的な発表ばかりで「本物のbuilder」しか登壇できない、自分なぞが登壇して良いカンファレンスではない、位に思っていたが(でもプロポーザルは出していたわけだけど)、登壇できると素直に嬉しいものでした。

復活のbuildersconは参加者全員の熱量が高くて良かった。参加率もとても高く、部屋も埋まっていた。さっきシングルトラックのリスクについて書いたけど、逆に一つの部屋で皆で同じ話を聞くという体験が、一体感や密度の高いコミュニケーションにつながっていたように感じる。登壇後の質疑応答タイムに全トークでめっちゃ活発に質問が出てすごかった。

buildersconには、その「知らなかった、を聞く」というテーマにふさわしい、ノージャンルの技術トークを聞く楽しさはもちろんのこと、何より、そのbuildersconというカンファレンス名に「私たちは作り手なのだ」というメッセージが込められているように感じて、勝手にエモいと思っている。

今回のrebootは良かったので、今後もどういう形であれ、こういう熱量のある登壇者と、熱量がある聴衆の会が続いてくれることを願います。

HonoとCloudflare Pagesでプロフィールサイト兼短縮URLサービスを構築した

関連: NFCタグ入りの自己紹介アイコンバッジを自作する

song.mu という結構良い短いドメインを確保しているので、これをプロフィールサイト兼、個人用短縮URLサービスにしたいと長らく思っていたので重い腰を上げて作った。

最近オフラインイベントが増えている中で、こういうプロフィールサイトを活用しているケースを見るようになったのがきっかけ。Webエンジニアとしてはこういうの自作したいし、自分のドメインでホストしたいと思っていたのだ。

song.mu がリンクが並んだプロフィールページで、 song.mu/blog でブログに飛び、 song.mu/x でTwitterに飛ぶ、みたいな具合。

技術スタック

こういうの作る時は興味がある技術の砂場にしたいので、HonoSSGしてCloudflare Pagesでホストしている。ローカル開発でのTypeScript実行環境も mise で管理するようにして、ランタイムもBunを採用した。

当初はCloudflare Workersを使う想定だったが、別にリダイレクトするだけだったら、_redirects ファイルを生成するだけで良いということに気づいたのでPagesを使うことにした。短縮URL登録も別に動的に登録する仕組みを作らないで、設定を記述してサイトをデプロイすれば反映されるようにすればいいとは元々思っていた。以下のような build.ts を書いてサイトと_redirects ファイルを書き出している。

import { toSSG } from "hono/bun";
import app from "./src/index";
import { urls } from "./src/urls";

const comment = "# The following lines are generated by build.ts.\n";

const redirects = Object.entries(urls)
  .map(([key, site]) => `${key} ${site.url} 301`)
  .join("\n");

await Bun.write("static/_redirects", comment + redirects + "\n");

toSSG(app);

別にサイトのソースコードも公開しても良いと思っていたのだが、一部秘密にしておきたいURLも含まれてしまったので非公開にせざるを得なくなってしまった。

Hono

Honoは良かった。テンプレートがJSXで普通に書けるし、css HelperでCSSも普通に書けて、既存のフロント開発と同じ雰囲気で書ける。vim-jsx-prettyとかvim-styled-componentsでちゃんとシンタックスハイライトが利くのが嬉しい。

あと、Vite Plugins for Honoでvite使って開発できるのが良くて、高速なホットリロード体験が快適だった。

この辺、元々は標準APIのみ使うところからスタートしていたり、既存のエコシステムにうまく相乗りするように作られているところがセンスの良さを感じた。

Bun

Bunも良かった。速くて快適。コマンド体系的にも bunbunx コマンドのみでランタイムやパッケージマネージャーが備える一通りの操作をおこなえるのは明確。既存のコマンドに対するDrop in Replacementとして動くのがわかりやすい。

Cloudflare Pages

以前、このサイトを引っ越した時に利用を検討したのだけど,断念することになって残念だったので、今回使えて良かった。Cloudflareの管理画面からGitHubの連携設定が簡単で、すぐデプロイできるのはお手軽で良い。ただ、GitHubへの広い読み取り権限を渡してしまうので、GitHub Actionsでdeployする方法に変更するほうが安全で、柔軟にもなるので、そのうち切り替えたい。

ちなみに、Cloudflareの環境上でBunを使うためには、環境変数 BUN_VERSION を指定すればbunコマンドを利用できるようになる。

ref. https://developers.cloudflare.com/pages/configuration/language-support-and-tools/

ちなみに、ドキュメントはされていないが、asdfの .tool-versions を見てくれるという話もあるようで、ドキュメントされてほしいし、この記事に書いてあるようにmise.tomlもサポートされてほしい。

再現性の無い多様なキャリアの前例があることに価値がある

大吉祥寺.pmの前夜祭「生存者バイアスナイト」で話してきた。

https://junkyard.song.mu/slides/survivor-bias-night/#0

同年代の優秀なエンジニアの方から「自分のキャリアは再現性が無いから他人の参考にならない」という話をよく聞く。果たしてそうだろうか。私はそういう人たちにもっと自分の経験の話をしてもらいたいと常々思っていた。

彼らは「思い込みの結果として上手く行った」「単に運が良かった」「だから普遍的なノウハウにならない」そんなふうに自覚している。だから、そんな普遍的ではないノウハウを偉そうに声高に話したがらないし、ましてや、それを押し付けるような老害的振る舞いになることを恐れているようにも見える。そういう謙虚なスタンスは好ましくも思う。

でも実際は一つ一つの経験には大きな意味がある。普遍的ではないかも知れないが、それでも話してみると、自分が思っている以上に多くの人の参考になったり、勇気づけられたりするものだ。むしろ、キャリアの進み方が普遍的な一本道に収束せず、多様な生き方があることに価値があり、業界に持続性がもたらされるのだ。

だから「生存者バイアスだから」とか「再現性がないから」とかそういう理由で経験を話さないままでいてほしくない。人間は、誰もがバイアスまみれで勘違いしながら生きている。それでも、そういう経験に価値がないわけではないのだ。

そういう課題意識もあって話したのがこのトークだ。私としては、自分がバイアスまみれであることを前提に置き、それをなるべくメタ認知しようと心がけることで、時にはその認知を上手く活用できるし、同時に自分の成功体験を他人に過度に押し付けるような老害化問題を緩和できる。そのように思っている。

人に「べき論」を押し付けず、自分の心躍った体験や思いを話せば誰かの参考になるかも知れないし、参考にしてもらえると嬉しい。それが、あまり人を傷つけない、伝わりやすい発信になるとも思っている。もちろん100%人を傷つけない発信など無いのだが。

人間はバイアスから完全に解脱することは難しいし、残念ながら私も無自覚な差別を色々していると思う。それでも自分の中の多くのバイアスから自由になり、差別を減らしたい。差別が少なく、より多くの人が幸せに生きられる世の中であって欲しいと思う。そっちのほうが自分も安心して生きられるからだ。


というキレイめなトークをしてきた。会の趣旨としては「自分はこういう経験をしたから生き残ったのじゃ、ガッハッハ」みたいなオフレコトークが期待されていた部分もあったようで、確かにキレイな話にしすぎたかもしれないと少し反省。そういう話も機会があればしてみたい。

ちなみに、大吉祥寺.pmの懇親会で「インターネット上で自分の過激な発言に起因して炎上した人がインターネットに文句言うのおかしくてインターネットが下手くそなだけじゃん。僕は大きな炎上したこと無いし」とか可燃性の高そうな発言をしてたんだけど、onkさんに「Songmuさんが炎上してないの運が良いだけですよ」って突っ込まれて、これは完全に生存者バイアスの話でしたね。