こんにちは。スコアサーバー開発チームです。
CPCTF2021では、xxpoxx、oribe、sappi_redの3人でスコアサーバーの開発を行いました。
サーバーサイドをxxpoxx、oribeが、クライアントサイドをsappi_redが担当しました。
この記事では、スコアサーバーの機能紹介、サーバーサイドとクライアントサイドの紹介をしていきます。
機能紹介(xxpoxx)
スコアサーバーの機能について紹介します。以下のようなサイトを用いて参加者は問題を解いたりランキングを確認したりしていました。
一般ユーザー用機能
トップページにアクセスすると以下のような画面が表示されます。

登録をクリックし、ユーザー名とパスワードを設定して、CPCTFにサインアップすることができます。
ログインすると、問題一覧や質問やアナウンスやランキングやビジュアライザ、さらにはマイページを見ることができます。

問題一覧を開くと、以下のように問題の一覧を見ることができます。ジャンルフィルタや表示順の変更、正答済みの問題を非表示等の機能も実装されています。

それぞれの問題をクリックすると、問題を閲覧、ヒントを解放、またFLAGの提出ができるようになっています。

質問では、コンテスト中に生じた問題に関する質問をすることができます。

アナウンスでは、コンテスト中の運営からのアナウンスが表示されます。

ランキングでは、CPCTF参加者の点数推移やランキング、またその中でも新入生のみの点数推移やランキングを確認することができます。
マイページからは、自身の獲得点数の推移や得点、提出した問題の確認やTwitter連携、さらにはWebShellの認証情報の確認やメールアドレスの変更などができるようになっています。

以上が、一般ユーザーが利用できる機能でした。
アドミンユーザー用機能
ここからは、アドミンが利用できる機能を紹介していきます。
†アドミン昇格†を行うと、以下のようにアドミンモードでサイトを閲覧することができます。

アドミンモードでは、以下のような問題の登録や質問の回答、新規アナウンスの追加を行うことができます。


また、アドミンに昇格したユーザーが問題に解答を行っても、動的配点に影響が出ないようになっていたり、ランキングに含まれないようになっているなど、コンテストに配慮した工夫が施されています。
さらに、このサイトはダークモードでも閲覧でき、右上のボタンで切り替えることができます。

サーバーサイド
技術スタックについて(xxpoxx)
言語としてはWebアプリ開発勢の母語のような存在になりつつあるGoを採用しました。開発する2人がメインで書ける言語なので特に異論もなく、自然と採用されました。
採用したライブラリは以下のものです。
- Echo
- Goの軽量Webフレームワーク。traQで使われているので採用。
 
 - GORM
- GoのORMライブラリ。traQで使われているので採用。
 
 - gcache
- Goのキャッシュライブラリ。「動的配点にまつわるキャッシュ」で解説。
 
 - go-qidenticon
- Goの、アイコンを生成するライブラリ。「初期アイコン生成」で解説。
 
 - sendgrid-go
- メール配信サービスであるSendGridをGoから操作するためのライブラリ。部内で用いられているSendGridを扱うために採用。
 
 - gorilla/{websocket, sessions}
- GoのWebツールキット。visualizerやクライアントサイドにWebSocketを用いて情報を送るため、セッションの管理をするために採用。「他サービスとの連携」を参照。
 
 - jwt-go
- GoのJSON Web Tokensを実装しているライブラリ。アカウントのアクティベートのために採用。
 
 - Anaconda
- GoからTwitterAPIにアクセスするためのライブラリ。Twitter連携のために採用。
 
 
この他にもテストのためのmockライブラリやuuidのライブラリなども利用しています。実装を進めていくにあたって、様々な機能が必要となりました。それらを実現してるライブラリを採用していくような形で、技術スタックが選択されていきました。
使い慣れた言語、ライブラリを用いたことにより、たった2人、1か月の開発でCPCTF2021の開催にこぎ着けることができました。
性能について(oribe)
競技中には多くのトラフィックが発生しました。
インフラチームが用意してくれた監視基盤とデータベース上の情報からその様子の一部を紹介します。
ユーザー数の推移

ユーザー登録は前日の夜から可能でしたが、当日の10:00の時点では登録数は61人でした
うち17人は運営側のadminユーザーなので、実際に参加する人の登録数は44人ということですね。
競技開始時刻前後で一気に登録数が増え、14:30には140人(adminを除くと123人)になりました。
その後も途中参加の登録があり、最終的には158人(adminを除くと141人)になりました。
HTTPリクエスト数

これは5分単位でのHTTPリクエスト数のグラフです。
競技時間中は常にそれなりの量のHTTPリクエストが来ました。
リクエスト数のピークは競技開始から20分後の13:50頃で、この時間には5分間で10kものリクエストが飛んでいました。
また素晴らしいことに、サーバー側でトラブルが発生したことを意味するステータスコード500のレスポンスの件数は0件でした。
この膨大なリクエストの中にはBrute Force的な提出によるものも存在しました。
対象となった問題はこの記事で解説されている「OSINT/Student ID Card」です。
学籍番号を構成する要素の制約はTwitterで適切な検索を行うと得られるのですが、その制約情報にたどり着けなかった何人かの参加者が総当たりで提出したようです。
それによりこの問題は提出数が2085に対し正答者が44人、正答率が2.11%という大変「高難易度な」問題となりました。

POSTリクエストのピークは14:50前後

その頃に行われた同一ユーザーからのフラグ提出
どうやらスクリプトを用いた提出が行われたようです。
このような集中的なリクエストに対してもアプリケーション負荷、データ整合性の両者共に問題なく捌き切ることができました。
WebSocketのコネクション数

後述するクライアントサイドやビジュアライザとの連携にWebSocketを使用していました。
競技時間中は200~350程度のコネクションが貼られていました。
動的配点を見据えてのテーブル設計(oribe)
今回のCPCTFは動的配点を採用していました。
問題に正答した際に得られる点数は、他の参加者の解答状況と自身のヒントの開封状況によって決まります。
動的配点の詳細 イベント詳細ページより
動的配点について
大雑把に言うと
- 難易度で基礎点が決まり、解いた人数が多いほど得点が下がります
 - ヒントを開くと自分の得る得点が下がります
 - 解いた時点での得点を得るので、早解きをすると高得点が狙えます
 レベル別には以下の通りです。
難易度 ヒントなし ヒント1個 ヒント2個 ヒント3個 レベル0 10 10 10 10 レベル1 100~80 90~60 60~40 30~10 レベル2 200~160 180~150 120~80 60~20 レベル3 300~240 270~180 180~120 90~30 レベル4 400~320 360~240 240~160 120~40 レベル5 500~400 450~300 300~200 150~50 詳しい計算式
=レベル
満点 : 点
ヒント0個 : ~ 点
ヒント1個 : ~ 点
ヒント2個 : ~ 点
ヒント3個 : ~ 点各問題について、
(人 は 個ヒントを開けている)
としたとき個ヒントを開いたときの点数は
なお、小数点以下第三位を四捨五入します
動的配点の計算を行いやすいよう、下記のようなテーブル構成にしました。

正答して得る点数は、以下の3つの情報を取得することで算出できます。
- その問題の難易度を示すlevelの値(challengesから)
 - すでにその問題を正答した人達のhint_level(answersから)
 - 解答時の自身のヒントの開封状況(hintsから)
 
この3つのテーブルを参照すれば、各ユーザーの獲得点数、そして現在のスコアはいつでも正しく得ることができます。
しかし、ここで1つ問題があります。
スコアサーバーにはランキングページがあり、そこでは各ユーザーの現在のスコアだけでなくスコアの変動のグラフも表示しています。
このランキングの描画に必要な情報は上述の3つのテーブルを参照すれば得ることができます。
が、そのための処理は大変重いものになってしまいます。
ユーザーAの現在のスコアを得るための処理はこのようになります。
- ユーザーAが正答したときの解答全てをanswersから取得
 - 解答1つずつの点数を計算する
2-1. 解答した問題のlevelを取得
2-2. その解答よりも前の同じ問題への正答全てをanswersから取得
2-3. ユーザーAの解答時のヒントの開封状況を取得
2-4. 取得した情報を点数計算式に代入 - すべての解答の点数の総和を求める
 
ユーザーが正答した問題が多ければ多いほど処理が重くなる、典型的なですね。
全ユーザーのスコアをまとめて取ろうとしたら、ユーザーの数を、提出済みの解答の数をとするとになってしまいます。
この問題を解決するためにscore_logsというテーブルを追加することにしました。
score_logsにはユーザーが正答する度に、新たに得た点数と現在のスコアを保存します。

score_logsを追加したことにより、ユーザーの現在のスコアを得る処理は「指定したuser_idかつ直近のcreated_at」のデータを得ると言う大変シンプルなものとなりました。
全ユーザーのスコアをまとめて取得するのも、created_atによるフィルターを行うだけですね。
スコアの変動の情報もcreated_atでソートするだけで得られます。
データベースを用いて値のキャッシュを行うというのは少々邪道かもしれませんが、このテーブルが安定したアプリケーション動作に大きく貢献したことは間違いありません。
また、解答提出からscore_logsに獲得点数を保存するまでの処理はMutexにより排他制御を行い、獲得点数に不整合が発生しないようにしました。
ランキングにまつわるキャッシュ(xxpoxx)
ランキングページは、score_logテーブルを見て必要な情報をとり、ランキング形式に処理して返すという非常に重い処理がある上、アクセスが集中するという予見がかねてよりありました。これに対して今回のスコアサーバーでは、ランキングにキャッシュを挟むという方法で対策をとっていました。後述されるservice層に存在するGetRanking()メソッドでは、以下のようにしてインメモリキャッシュを挟んでいました。
if s.gc.Has(CacheRanking) {
    cachedRanking, err := s.gc.Get(CacheRanking)
    /*** convert process ***/
    return ranking, nil
}
/*** get ranking process ***/
s.gc.Set(CacheRanking, ranking)
return ranking, nil
キャッシュライブラリとしてgcacheを採用しました。Go言語のキャッシュライブラリでは、go-cacheなども存在しますが、gcacheは部内SNSサービスtraQでも用いており、goroutine safeであるという特徴などを踏まえて採用しました。
この結果、以下のように実際にランキングの取得処理にキャッシュを挟むことで、レスポンスの時間が非常に小さくなっていることが確認できます。

CPCTF2021の参加人数は約150人でした。ざっくりランキングページに150人がそれぞれ30秒に1回リクエストを送ってくると考えると、1秒につき5回の処理が走ることになります。今回の実装では、正答が送られてきたときにキャッシュを破棄するようにしています。正答の送られてきた間隔はビジュアライザの記録を確認したところ、約5秒に1回でした。キャッシュを破棄したあとに1度だけ生成処理を行うので、5秒につき1回の処理が走っていると考えてよいでしょう。つまり、1秒に5回の処理だったものが1秒につき0.2回となり、単純計算で25倍の性能改善ができたことがわかります。
さらに、コンテスト終了直後など、ランキングページに150人が同時にアクセスした際も、本来ならば1秒につき150回の処理が走ることになりますが、キャッシュのおかげで生成処理を行わずに、キャッシュされている結果を返すだけで捌けるようになりました。
このキャッシュのおかげかはわかりませんが、本番の膨大なリクエストでも、サーバーが落ちることなくコンテストを実施できました。
他サービスとの連携(oribe)
サーバーサイドはクライアントサイドの他に、ビジュアライザとWebShellのサーバーと連携を行っていました。

クライアントサイド
クライアントサイドからサーバーサイドへのAPIリクエストが行われる他に、サーバーサイドからイベントを配信するためにWebSocketを繋いでいました。
配信されるイベントはアナウンスや質問の投稿、問題の点数変動やランキングの更新などで、クライアントはそれらを受けてリアルタイムに描画を行いました。

ビジュアライザ
誰がどれほどのスコアを獲得したのかをかっこいい演出とともに伝え、参加者だけでなく観戦者をも盛り上げてくれたのがビジュアライザです。
ビジュアライザは初期化時にAPIリクエストで現在の情報を取得し、それ以降はサーバーサイドから配信されるWebSocketイベントをトリガーにして描画を行っていました。
配信されるイベントはユーザーの登録やスコアの獲得、ランキングの変動と競技残り時間の同期のための情報です。

WebShell
CPCTFでは自由に使えるLinux環境としてWebShellを用意しました。
参加者はマイページでWebShellのアカウントを発行でき、以後はそれを用いてWebShellを利用することができます。
WebShellアカウントの乱造や不適切な用途で使用されることをふせぐため、WebShellのアカウントとその認証を管理するアプリケーションへのリクエストはスコアサーバーのサーバーサイドを介してのみ行えるようにしました。
マイページで「WebShellを有効化する」や「WebShellの環境のリセット」のボタンを押すとスコアサーバーのサーバーサイドにHTTPリクエストが飛びます。
それを受けてスコアサーバーのサーバーサイドはAPI keyと共にWebShellのアプリケーションにリクエストを行い、その結果を元にクライアントへのレスポンスを返します。


初期アイコン生成(xxpoxx)
今回のスコアサーバーでは、アイコンとして初期アイコン、またはTwitter連携で取得したTwitterのアイコンを用いることができました。このうちの初期アイコンは以下のようなものです

この初期アイコンは、ユーザーの登録時に自動生成されるものとなっています。この初期アイコンの生成には、部内SNSサービスtraQでも用いられているgo-qidenticonを用いています。以下のようなコードで初期アイコンを生成し、保存したURLを返すようにしていました。
// GenerateIcon アイコンを生成し、ローカルに保存、配信のURLを返す(ex. http://localhost:3000/images/78636449-169b-4dcd-a157-c12cd219cb53.png)
func (s *Services) GenerateIcon(salt string, hostURL string) (iconURL string, err error) {
	icon := qidenticon.Render(qidenticon.Code(salt), iconSize, iconSettings)
	iconURL = uuid.New().String()
	if dir, err := os.Stat(os.Getenv("ICON_DIR")); os.IsNotExist(err) || !dir.IsDir() {
		if err := os.Mkdir(os.Getenv("ICON_DIR"), 0777); err != nil {
			return "", err
		}
	}
	iconPath := filepath.Join(os.Getenv("ICON_DIR"), iconURL+".png")
	f, err := os.Create(iconPath)
	if err != nil {
		return "", err
	}
	err = png.Encode(f, icon)
	if err != nil {
		return "", err
	}
	return hostURL + "/" + "images" + "/" + iconURL + ".png", nil
}
初期アイコンが並んだランキングを見て、開発者としてはニコニコしていました。初期アイコンが必要となるサービスで、是非利用してみてください。
ミニマムなアーキテクチャ(oribe)
今回のスコアサーバーの開発では開発期間が短かったこと、来年以降も使えるような保守性の高いものにしたかったことなどから、なるべくシンプルでかつユニットテストを書きやすいアーキテクチャにしようと考えました。
最終的には以下のような構成になりました。

repositoryはデータベース操作を行う層です。
基本的には引数で受け取ったパラメータを用いてのシンプルなCRUD処理のみを行います。
serviceはアプリケーションロジックを実装した層です。
前述のランキング計算やそのキャッシュといった複雑な実装が必要になる処理はこの層に集約しました。
handlerはAPIハンドラを実装した層です。
APIリクエストに基づいてrepository、またはserviceのメソッドを呼び出し、最終的なレスポンスを生成します。
複雑な処理を要する一部のAPIはserviceのメソッドにロジックの実装を移譲していますが、そうでないAPIの場合はこの層にロジックを書きました。
このrepositoryとserviceはinterfaceを挟んで実装するようにしたことで、mockを用いた他の層の実装に依存しないテストを書くことができました。
mockの生成にはgomockを使用しました。
// repository層でのinterface定義
type QuestionRepository interface {
	CreateQuestion(args CreateQuestionArgs) (*Question, error)
	UpdateQuestion(id uuid.UUID, args UpdateQuestionArgs) (*Question, error)
	GetQuestion(id uuid.UUID) (*Question, error)
	GetQuestionsByQuestionerID(questionerID uuid.UUID) ([]*Question, error)
	GetOpenQuestions() ([]*Question, error)
	GetClosedQuestions() ([]*Question, error)
	QuestionExists(id uuid.UUID) (bool, error)
}
// mockメソッドを用いたhandler層のメソッドのテスト
func TestHandlers_GetQuestions(t *testing.T) {
	t.Parallel()
	t.Run("Success", func(t *testing.T) {
		t.Parallel()
		ctrl := gomock.NewController(t)
		th := setupH(t, ctrl)
		accessUser := mustMakeUser(t, false, false)
		question1 := &model.Question{
			ID:           uuid.New(),
			QuestionerID: accessUser.ID,
			AnswererID:   nil,
			Text:         random.AlphaNumeric(t, 30),
			Answer:       "",
		}
		answererID := uuid.New()
		question2 := &model.Question{
			ID:           uuid.New(),
			QuestionerID: accessUser.ID,
			AnswererID:   &answererID,
			Text:         random.AlphaNumeric(t, 30),
			Answer:       random.AlphaNumeric(t, 30),
		}
		th.Repo.MockQuestionRepository.
			EXPECT().
			GetQuestionsByQuestionerID(accessUser.ID).
			Return([]*model.Question{question1, question2}, nil)
		var resBody []PublicQuestion
		statusCode, _ := th.doRequestWithLogin(t, accessUser, echo.GET, "/api/questions", nil, &resBody)
		assert.Equal(t, http.StatusOK, statusCode)
		if assert.Len(t, resBody, 2) {
			pq := resBody[0]
			assert.Equal(t, question1.ID, pq.ID)
			assert.Equal(t, question1.QuestionerID, pq.QuestionerID)
			assert.Equal(t, question1.Text, pq.Text)
			assert.Equal(t, question1.Answer, pq.Answer)
			assert.False(t, pq.Closed)
			pq = resBody[1]
			assert.Equal(t, question2.ID, pq.ID)
			assert.Equal(t, question2.QuestionerID, pq.QuestionerID)
			assert.Equal(t, question2.Text, pq.Text)
			assert.Equal(t, question2.Answer, pq.Answer)
			assert.True(t, pq.Closed)
		}
	})
	t.Run("FailedToGetQuestionsByQuestionerID", func(t *testing.T) {
		t.Parallel()
		ctrl := gomock.NewController(t)
		th := setupH(t, ctrl)
		accessUser := mustMakeUser(t, false, false)
		th.Repo.MockQuestionRepository.
			EXPECT().
			GetQuestionsByQuestionerID(accessUser.ID).
			Return(nil, errors.New("failed to GetQuestionsByQuestionerID"))
		statusCode, _ := th.doRequestWithLogin(t, accessUser, echo.GET, "/api/questions", nil, nil)
		assert.Equal(t, http.StatusInternalServerError, statusCode)
	})
	t.Run("WithoutLogin", func(t *testing.T) {
		t.Parallel()
		ctrl := gomock.NewController(t)
		th := setupH(t, ctrl)
		statusCode, _ := th.doRequest(t, echo.GET, "/api/questions", nil, nil)
		assert.Equal(t, http.StatusUnauthorized, statusCode)
	})
}
このアーキテクチャにしたことによる恩恵が最も大きかったのはserviceの実装でした。
前述の通りserviceでは複雑なロジックを担うメソッドを実装しているので、そのメソッド単位のテストで確認できるのは実装作業やデバックに大いに役立ちました。
またserviceにあるメソッドのいくつかは開発中に要求仕様が変わりましたが、他の層の実装やテストがその影響を受けずに済むというメリットがありました。
例えばserviceには正答時の獲得点数を算出するメソッドがありますが、開発期間中にルール上の点数の計算式が何度か変更されたことを受けてロジックの変更をする必要がありました。
そのメソッドを呼び出すhandlerメソッドは複数ありますが、適切に層同士の依存関係が分離されているためhandlerメソッドやそのテストを変更する必要は一切ありません。
ただ対象である獲得点数算出メソッドとそのテストを変更するだけで、新たな仕様への対応を完了できました。
クライアントサイド
技術スタックについて(sappi_red)
ここ最近はずっとViteを使っているので今回もビルドツールにはViteを使いました。やっぱりビルド速いと快適ですね。
クライアントは自分(@sappi_red)が一人で書くので、使い慣れていることとできる限りコードを減らしつつ自由度を高めることを念頭に置いて以下の選択をしました。
- Vue 3 Composition API with TypeScript: ここ最近ずっと書いてたので採用
 - VueUse: 次の節で説明します
 - Pinia: Vue 3 Composition APIと相性のよいことを謳っているストア、使ってみたかったので採用
 - Windi CSS: 次の次の節で説明します
 - markdown-it: マークダウンパーサ、traQで使われているので採用
 - prismjs: シンタックスハイライト、traQではhighlight.jsを使ってますがなんとなくこっちを採用
 - chart.js: グラフの描画、これは単純に見つけたから採用
 
OpenAPIでAPI仕様を書いてもらってたので、開発初期はStoplight Prismでモックサーバーを立てて利用したり、APIを叩くコードはOpenAPIGenaratorで生成したりしました。
他に利用したライブラリはこんな感じです。
- KaTeX: 数式表示
 - fast-average-color: アイコンからチャートの線の色を決めるのに利用、白くなっちゃったって声そこそこあったので
ignoredColorを指定すればよかったかも…って後悔した - fireworks-canvas: 花火のアニメーションを表示する、正解のときの画面で使った
 - vite-plugin-icons: これは正確にはライブラリではないけれど…。アイコンの表示に使った
 
このようにかなりいろいろなライブラリに頼りました。
VueUseについて(sappi_red)
VueUseはVueのComposition APIの汎用関数のライブラリです。
useDarkと言うダークテーマ切り替え用の関数だったり、useNowと言う現在の日時を利用できる関数だったりがあります。
使ってみようと思いつつも触れていなかったので、この機会に利用することに決めました。
useNowは以下のように開始状態や終了状態かどうかの判定に利用していました。
import { useNow } from '@vueuse/core'
import { computed } from 'vue'
import { useMainStore } from '/@/store'
export const useContestSchedule = () => {
  const main = useMainStore()
  const { now } = useNow({ interval: 1000 })
  const secondsUntilStart = computed(() =>
    main.schedule === null
      ? NaN
      : Math.ceil(
          (Date.parse(main.schedule.startTime) - now.value.getTime()) / 1000
        )
  )
  const isContestStarted = computed(() =>
    Number.isNaN(secondsUntilStart.value) ? null : secondsUntilStart.value <= 0
  )
  const secondsUntilEnd = computed(() =>
    main.schedule === null
      ? NaN
      : Math.ceil(
          (Date.parse(main.schedule.endTime) - now.value.getTime()) / 1000
        )
  )
  const isContestEnded = computed(() =>
    Number.isNaN(secondsUntilEnd.value) ? null : secondsUntilEnd.value <= 0
  )
  return {
    secondsUntilStart,
    isContestStarted,
    secondsUntilEnd,
    isContestEnded
  }
}
実装していて汎用性が高いと感じた二つの関数は、VueUseへのプルリクエストを出しました。
一つ目はtoRefsで、Vueにある既存のtoRefsをRef<{ a: string }>のようなものにも使えるようにした関数です。以下のようなコンポーネントを書くと、const data = ref({ a: 'a', b: 'b' })があったときに<comp v-model:data="data" />のように利用できます。
<template>
  <div>
    <input v-model="a" type="text" />
    <input v-model="b" type="text" />
  </div>
</template>
<script lang="ts">
import { toRefs, useVModel } from '@vueuse/core'
export default {
  setup(props) {
    const refs = toRefs(useVModel(props, 'data'))
    console.log(refs.a.value) // props.data.a
    refs.a.value = 'a' // emit('update:data', { ...props.data, a: 'a' })
    return { ...refs }
  }
}
</script>
これは、問題の編集画面をコンポーネントとして切り出すのに使ってました。
二つ目はautoResetRefで、以下のようなことができます。
import { autoResetRef } from '@vueuse/core'
const message = autoResetRef('default message', 1000)
const setMessage = () => {
  /* 'message has set'に変更する、1秒後には勝手に'default message'に戻る */
  message.value = 'message has set'
}
これはエラーメッセージ表示に利用してました。
利用した関数だと、useVModelやuseLocalStorageが便利でした。
Windi CSSについて(sappi_red)
Windi CSSはTailwind CSS互換のオンデマンドCSSフレームワークです。
開発中は必要になったスタイルを必要なったタイミングで生成して挿入し、ビルド時は必要になったスタイルだけを生成してバンドルするという挙動をします。
なので開発サーバーの起動時間を短縮することとバンドルサイズを小さくすることができます。さらには事前生成ではないので、m-0.004(margin: 0.001remのクラス)のように事前に定義されていない数値を含んだスタイルを利用することもできます。
Tailwind CSSのほうにあるTailwind JITとの違いはWindi CSSのリポジトリのディスカッションを見ると書いてあります。
実際にはViteでWindi CSSを利用するためにvite-plugin-windicssも使っています。
Windi CSSに関しても使ってみようと思っていて、ちょうどよかったので利用しました。
あと細かいバグ修正のPRを出したりしました。
Tailwind CSSも利用したことがなかったので、クラス名が長くなるのがちょっと気になってたんですが、これはある意味しっくりくるなと感じました。
使ってる間はその理由がいまいち言語化できなかったんですが、ユーティリティーファーストとTailwind CSSのススメ - Qiitaという記事で納得しました。
<button
  class="rounded whitespace-nowrap border-2 border-transparent focus:border-white focus:border-dotted focus:border-opacity-50 focus:outline-none"
  :class="[
    type === 'primary'
      ? 'bg-emerald-600 dark:bg-emerald-400 text-white hover:bg-emerald-500 dark:hover:bg-emerald-500'
      : type === 'secondary'
      ? 'bg-warm-gray-200 dark:bg-warm-gray-700 text-emerald-600 dark:text-emerald-400 hover:bg-warm-gray-300 dark:hover:bg-warm-gray-600'
      : '',
    size === 'large' ? 'text-xl font-bold py-3 px-8' : 'py-2 px-6'
  ]"
>
これは書いてたコードの一部ですが、すごいわかりにくくなってます。(本当はcomputedとしてくくりだしたほうがよいと思いますが、一旦そのことは忘れておいて…)
条件分岐が一か所にまとまってるというのはメリットですが、どのプロパティが条件によって変化するのかわかりにくいというのが微妙なところでした。
属性でかけるとよさそうな感じするなと思いつつ時間の余裕がなかったので、そのときはそのまま書き進めてました。
そう思ってたら4月下旬にAttributify Modeというまさにそういう機能がv3 betaで実装されました。(現在はv3のstableもリリースされてます、ただvite-plugin-windicssのv3対応版はまだbetaです)
コンポーネントのpropとややこしいというデメリットはありますが、先述のわかりにくい点はこれを利用すると解消されそうです。
今のところ触ってませんが今度触ってみたいと思ってます。
UXについて(sappi_red)
問題を解くことに集中できるようにすること、問題を解くのを楽しく感じられるようにすることにフォーカスをあてて、仕様を考えてました。
いくつか紹介していきます。
- 開始時に自動で問題一覧取得を行う (下記のようにカウントダウンがされます)

 - FLAGのペースト・リセットボタン

 - 残り時間を常に右下に表示

 - 獲得可能点数変動表示

 - 正解時の表示

 - 問題一覧での複数のフィルタと並び替え

 - 新しいアナウンスの表示

 
いろいろ実装したのが功を奏して、開催後のアンケートでも使いやすかったとの声が多かったのでよかったです。
終わりに
CPCTF2021のスコアサーバーはこのブログで紹介した様々な技術・工夫を取り入れて開発しました。
有志で集まった開発メンバーでしたが、完成度の高いものを作ることができたと感じています。
参加者の皆さんにも満足していただけていたら光栄です。