Perl Hackers Hub

第59回Fediverse入門―非中央集権型SNSサーバを作ろう!(2)

前回の(1)こちらから。

Perlでの実装

それでは、筆者が作成したPerl製の発信専用1ユーザーActivityPubサーバであるActubを例に、Perlでの実装について説明します。Actubは、WebフレームワークにMojoliciousを使い、Fediverseに参加するための最小限の機能を実装することを目標にしています。Actubは購読機能を持たないのでほかのユーザーをフォローしてタイムラインを読むことはできませんが、ほかのサーバのユーザーにフォローされることでFediverseに情報を発信できます。

ActivityPubはWeb APIベースのプロトコルですので、Actubも「リクエストを受け付けて、情報を加工し、レスポンスを返す」という一般的なWeb APIサーバと同様の動作をします。以降では、一般的な処理については説明せず、Actubで特徴的な処理を行っている部分について説明します。

AS2オブジェクトモデルの作成

Actubでは、ActivityPubのデータモデルであるAS2オブジェクトを表現するためのクラスを作成します。

個々のオブジェクトを表現するクラスを作成する前に、まずベースとなるクラスを作成します。

package WWW::ActivityPub::Base;

use strict;
use warnings;

use Class::Tiny;

sub TO_JSON {
    my %ret;
    my $self = shift;
    for (keys %$self){
    my $key = $_;
    my $jsonkey = $key;
    if($key eq 'context'){
        $jsonkey = '@context';
        if(!defined $$self{$_}){next;}
    }
    $ret{$jsonkey} = $$self{$_};
  }
  return \%ret;
}

1;

ここではClass::Tinyモジュールを用いています。これは属性のゲッタとセッタを設定するだけのシンプルなモジュールですが、コアモジュール以外に依存のない軽量なモジュールです。これを使って属性名と同じ名前のメソッドでAS2の各属性にアクセスできるようにしたいところですが、AS2には@context属性があり、@はメソッド名に使えないため、この名前のままではメソッド名として使えません。この問題に対応するため、個々のクラス定義では@contextではなくcontext属性として定義し、JSON直列化の際にcontext属性を@contextに変換して出力するためのTO_JSON関数を定義しています。

この下準備により、たとえばFollowクラスの定義は次のようにシンプルなものになっています。

package WWW::ActivityPub::Follow;

use strict;
use warnings;

use parent qw(WWW::ActivityPub::Base);

use Class::Tiny qw(
    context id type object
    );

1;

MIME型の登録

GETリクエストに対する返却処理は通常のレスポンスなので実装上特筆することはあまりありませんが、前述のとおりMIME型としてapplication/ld+json; profile="https://www.w3.org/ns/activitystreams"を使う必要があるため、あらかじめMojoliciousのstartupフック内で次のようにしてこのMIME型を登録しておきます。

sub startup {
    my $self = shift;
    ...;
    $self->types->type(as =>
      'application/ld+json; profile=' .
      '"https://www.w3.org/ns/activitystreams"');
}

これにより、実際にレスポンスを出力するときには次のようなコードでMIME型を指定して出力できます。

# $outは出力するデータ
$self->render(text => $out,
              format => 'as');

送信キューの登録

Actubは送信処理を非同期で行う実装となっているので、購読アクターからFollowオブジェクトが送信されると、対応するAcceptオブジェクトをジョブキューに追加します。ここでのジョブキューシステムはJonkモジュールを使っています。これはデータベースをバックエンドに使う軽量なジョブキューシステムで、次のようなコードでキューに追加します。

# $dbhはデータベースハンドル
my $jonk = Jonk->new($dbh);
...;
my $job_id = $jonk->insert('post', $queuestr);

送信処理

Actubでは、送信処理はcronで定期的に起動される別スクリプトに分離されています。本項ではその概要を示します。

データのキューからの取り出し

スクリプトが起動されると、次のようなコードでジョブキューをチェックし、キューにデータがあれば送信処理を呼び出します。

my $dbhj = DBI->connect(
  "dbi:SQLite:dbname=actub_job.sqlite","","");

my $jonk = Jonk->new($dbhj =>
 {functions => [qw/post/]}) or die;
my $job = $jonk->find_job;
if (defined $job) {
    # 送信処理
    do_post($job->arg);
}

HTTP Signaturesによる署名

ActivityPubのオブジェクトを送信する際には、HTTPSignaturesによる署名が必要です。HTTP Signaturesで使用できる署名方法はいくつかありますが、Fediverseで使われているのはrsa-sha256と呼ばれる方法です。rsa-sha256では次の手順で署名します。

  1. 署名対象データをSHA256アルゴリズムでハッシュ化する
  2. ハッシュをRSAアルゴリズムで署名する
  3. 署名をBase64形式でエンコードする

HTTP Signaturesでは署名対象データも選択できますが、Actubでは仕様で最小限含めることが求められているDateフィールドを対象としています。

通常、LWPライブラリを使ってPOST処理を行う場合は、postメソッドに必要な引数を与える形で実装します。しかし今回はHTTPリクエストヘッダのDateフィールドの値に署名をする必要があるため、次のように手順を分割して処理を行います。

my $contenttype = 'application/ld+json; ' .
  'profile="https://www.w3.org/ns/activitystreams"';

# リクエストオブジェクトを作成
my $req = POST(
    $url,
    'Content-Type' => $contenttype,
    Content => $content
);

# Dateフィールドに現在時刻を設定
$req->headers->date(time);
# Dateフィールドの文字列表現を取得
my $date = $req->headers->header('date');
# 取得した文字列表現に署名するメソッドを呼び出し
my $sign = Actub::Signature::sign('date: ' . $date);
# 署名した結果を仕様が求める形に整形
my $signature = sprintf
  'keyId="%s",algorithm="rsa-sha256",signature="%s"',
    $from, $sign;
# 署名した結果をSignatureヘッダに設定
$req->headers->push_header(
  Signature => $signature);
# リクエストを実行
my $res = $ua->request($req);

署名処理本体はCrypt::OpenSSL::RSAモジュールを使って次のように行います。

sub sign {
    my ($data) = shift;

    # 秘密鍵を取得
    my $pk = get_pk();

    # 署名
    my $key =
        Crypt::OpenSSL::RSA->new_private_key($pk);
    $key->use_sha256_hash();
    my $s = $key->sign($data);

    # Base64エンコードした文字列を返却
    return encode_base64($s, "");
}

通知を受け取ったアクターは、通知の送付元アクターのアクターオブジェクトに含まれているpublicKey属性の値を使って、HTTP Signaturesの署名を検証します。

コンテントネゴシエーションによる情報の提供

前述のとおりActivityPubにおいてGETメソッドで情報を取得する際には、リクエストのAcceptヘッダにapplication/ld+json; profile="https://www.w3.org/ns/activitystreams"を指定することになっています。ただ、これ以外の値を指定された場合に、AS2以外の形式の情報を返すこと(コンテントネゴシエーション)も認められています。これを利用して、同じURLでも、ActivityPubの情報を要求された場合はAS2形式の情報を、それ以外の場合はHTML形式での情報を返すようにしています。

sub entry {
    my $self = shift;
    ...;
    # 指定されたIDの短文情報を取得
    my $entry = Actub::Model::Entry::read_row($dbh, $self->param('id'));

    if(is_ap($self->req->headers->accept)){
        ...; # $outにAS2形式のデータを設定
        $self->render(text => $out, format => 'as');
    } else {
        $self->render(template => 'entry', e => $entry);
    }
}

# AS2を要求しているかのチェック
sub is_ap {
    my $arg = shift // '';
    my (@params) = split /,/, $arg;

    for(@params){
        s/^ +//;
        s/ +$//;
        if($_ eq 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' ||
           $_ eq 'application/activity+json'){ return 1; }
    }
    return 0;
}

is_ap関数は、リクエストで指定されたMIME型がActivityPubのものかを判定しています。ActivityPub仕様ではapplication/activity+jsonが指定された場合もAS2形式の情報を返すべき(SHOULD)とされているため、ここではどちらを指定されてもActivityPubのものとして判定しています。

<続きの(3)こちら。>

WEB+DB PRESS

本誌最新号をチェック!
WEB+DB PRESS Vol.130

2022年8月24日発売
B5判/168ページ
定価1,628円
(本体1,480円+税10%)
ISBN978-4-297-13000-8

  • 特集1
    イミュータブルデータモデルで始める
    実践データモデリング

    業務の複雑さをシンプルに表現!
  • 特集2
    いまはじめるFlutter
    iOS/Android両対応アプリを開発してみよう
  • 特集3
    作って学ぶWeb3
    ブロックチェーン、スマートコントラクト、NFT

おすすめ記事

記事・ニュース一覧