不適切なワードをリスト化したけど直接リポジトリに入れたくなかったので暗号化したメモ (JavaScriptベース)

# はじめに
サービス内でユーザに何かを入力させるとき、不適切な言葉（差別的な言葉や卑猥な言葉）を入力させたくないという要望は往々にしてあると思います。

不適切なワードをフィルタリングするためのAPIもいくつかあるようですが、そこまでお金をかけたくないサービスやAPIリクエストが多くなると思われる常時入力を監視したい状況には不適だと思います。

https://dev.classmethod.jp/articles/profanity-filter-saas/

自前で実装しようと思い、不適切なワードのフィルタリングをやってくれるライブラリを探したところ、下記の`bad-words`が良さそうだったので使ってみましたが、
リポジトリ内に平文のまま不適切な言葉が存在することに抵抗があったため暗号化して利用時に復号してみようと思い立ち、実際に試してみました。

https://www.npmjs.com/package/bad-words

# 今回作るものの仕組み
シンプルに暗号化と復号さえできれば良いのでこうしました。
本来なら暗号鍵の配置場所を考えるべきだと思いますが、今回はただ見た目上隠したいだけなので同じ場所に入れています。
![名称未設定ファイル.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303906/53197f33-3dbb-b465-fef9-83f9ee567272.png)

## 暗号化
暗号化には色々な方式がありますが、今回は`aes-256-cbc`を利用しました。
ただ不適切なワードを表に出したくないだけの利用用途としてはオーバースペックですが、
ECBはどんな用途であっても利用したくないので、とりあえずでCBC方式にしています。
※暗号化に関しては門外漢なのでここでは説明しませんが、下記を見ておくとざっくりわかると思います。

https://www.tohoho-web.com/ex/crypt.html

# 実装
## 暗号化スクリプト
今回はプロジェクト外のファイルとして作成して
`node encryptBadWords.js`で呼び出して利用しています。

```encryptBadWords.js
import { promises as fs } from 'fs'
import crypto from 'crypto'

// 暗号化に使用する鍵と初期化ベクトルを生成
const algorithm = 'aes-256-cbc'
const key = crypto.randomBytes(32)
const iv = crypto.randomBytes(16)

const encryptWord = async (word) => {
  const cipher = crypto.createCipheriv(algorithm, key, iv)
  let encrypted = cipher.update(word.trim(), 'utf8', 'hex')
  encrypted += cipher.final('hex')
  return encrypted
};

const updateFile = async (filePath, data) => {
  try {
    await fs.writeFile(filePath, data)
    console.log(`${filePath}に保存しました。`)
  } catch (error) {
    console.error('ファイルの書き込みに失敗しました:', error)
  }
}

const encryptBadWords = async () => {
  try {
    const data = await fs.readFile('bad-words/bad-words-ja.txt', 'utf8')
    const words = data.split('\n')
    const encryptedWords = await Promise.all(words.map(encryptWord))
    
    await updateFile('server/bad-words/bad-words-ja-encrypted.txt', encryptedWords.join('\n'))
    await updateFile('server/bad-words/bad-words-ja-encryption-key.txt', key.toString('hex') + '\n' + iv.toString('hex'))
  } catch (error) {
    console.error('ファイルの読み込みに失敗しました:', error)
  }
}

encryptBadWords()
```

## 復号スクリプト
復号はこんな仕組みで動かしています。
```decryptBadWords.ts
import fs from 'fs'
import crypto from 'crypto'

const algorithm = 'aes-256-cbc'

// 鍵とIVを読み込む
const readKeyAndIv = () => {
  const keyData = fs.readFileSync('server/bad-words/bad-words-ja-encryption-key.txt', 'utf8')
  const [keyHex, ivHex] = keyData.split('\n')
  return {
    key: Buffer.from(keyHex, 'hex'),
    iv: Buffer.from(ivHex, 'hex')
  };
};

// 暗号化された言葉を復号化する関数
export const decryptWords = () => {
  const { key, iv } = readKeyAndIv()
  const data = fs.readFileSync('server/bad-words/bad-words-ja-encrypted.txt', 'utf8')
  const encryptedWords = data.split('\n')
  return encryptedWords.map(encrypted => {
    const decipher = crypto.createDecipheriv(algorithm, key, iv)
    let decrypted = decipher.update(encrypted, 'hex', 'utf8')
    decrypted += decipher.final('utf8')
    return decrypted
  });
};
```

# 使い方（Nuxt）
今回Nuxtでこの仕組みを利用したため、その前提で使い方を書きます。
（他のフレームワーク、環境でも問題なく利用はできると思います。）

## 復号APIの作成
復号スクリプトをutilとして呼び出すAPIを作成します。
```server/api/decryptBadWords.ts
import { decryptWords } from '../utils/decryptBadWords'

export default defineEventHandler((event) => {
  const decryptedWords = decryptWords()
  return {
    words: decryptedWords
  };
});
```

## コンポーネントでAPI利用
ユーザ名入力など今回利用したいタイミングでAPIを叩き、取得したデータを`bad-words`のカスタムフィルターとして追加します。

```UserName.vue
<script setup lang="ts">
const { data: badWords } = useFetch('/api/decryptBadWords')
const filter = new Filter({ placeHolder: '' })
if (badWords.value?.words) {
  const badWordsArray = badWords.value?.words
  filter.addWords(...badWordsArray)
}
</script>
```

あとはvalidate関数を作成し、input時に実行させれば完成です。
```UserName.vue
<script setup lang="ts">
~既存コード~

const validateInput = (event: Event) => {
  const value = (event.target as HTMLInputElement).value
  userName.value = filter.clean(value)
}
</script>

<template>
<input
  type="text"
  placeholder="Enter your name"
  v-model="userName"
  @input="validateInput"
/>
</template>
```