2023年8月27日~2023年9月5日
結構本気で今作っているサイトを運営したいと考え始めたので、サイトを運営するなら何が必要か、どこを修正するべきかを考える。
同じエンジニアを目指す就活生が参加するインターンシップで色々聞いてみたので、そういうのも参考にしつつ。
とりあえず、改善点は色々あると思うんだけど、頂いたアドバイスの中で一番響いたのが「ハックされるのも体験の内」という言葉。
確かに、実際ハッキングされてみないと分からんよね。
だから、セキュリティを強化するのも勿論なんだけど、どちらかというとハッキングされても問題ないような情報だけを扱うとか、リスクマネジメントでいう「低減」をメインに進めていきたい。
修正・実装したいこと
これを言っちゃうとあれなんだけど、理想はQiitaのDTM版なので、Qiitaを全力でパク参考にする。
Webアプリケーションの機能的なtodo
- 記事一覧の修正
- 記事数毎にページを分ける
- いいね順、新着順、おすすめ順などの実装
- 音源投稿の修正
- 音源の投稿方法……もうちょっと楽にしたいよね
- 著作権が配慮されるようにしたいよね
- ユーザーページの実装
- ユーザーページ欲しいよね
- ユーザー登録をメール方式にする
- それに伴いメールサーバーが必要?
- メール認証があるといいけど、どうだろうね。
- メールアドレスやそのパスワードを保管するのは、リスク「低減」にだいぶ反する気がするので、最後でもいいかもしれない。
セキュリティ関連のtodo
- ログ・サーバー稼働状況の監視状況の作成
- 定期バックアップの作成
- レート制限(アクセス回数制限)の実装
- WAF(Web Application Firewall)の実装
- 通報機能の実装
- HTTPSの取得
- ドメインの取得
とりあえず、これらを最低限やっておきたいのかな。
セキュリティとは関係ないけど、現在の仕組みだと50記事毎にページを分けるとか、音源の投稿が無法地帯だったり、音源の利用方法が煩雑だったりっていう基礎的な問題があるのでそこらへんの修正が先になるのかなぁ。
機能修正していこう。
30件毎にページを分けてくれるようにする
最初はQiitaに倣って「もっと読む」ボタンにしようと思ったんだけど、これJavascriptが必須そうなんだよね。
なので、よくあるページ分けで実装することにする。
因みに、こういうのをペジネーションと言うみたい。
実装方法はこちらの記事を参考に。
一回テストで5ページずつの区分けにしてみた。
問題なさそう。
ナビゲーションバーをQiitaみたいにする
Qiitaのナビゲーションバーをみてみると
検索機能がついていたり、色々便利そうなナビゲーションバーになっている。
これを参考にする。
色々試行錯誤して、tailwindを使って作ってみた。
結構冗談抜きでこれだけで2時間くらいかかった。
そりゃあフロントエンド専門で食っていく人がいるわこれは。
ユーザーページ関連の調整
ユーザーページが欲しかったり、そもそも初期の状態だったりするので調整していく。
なんか、bladeのテンプレにProfileというページがあるので、見てみる。
これ、プロフィールというより、どちらかというとユーザー設定画面に近い。
というかこれ、URLが/profileだけなんだけどどうやってんだろうとか、英語だったりするので、日本語にしたりしていく。
ユーザー画面の構成
blazeの作ってくれたviewの構成を見てみるとこんな感じになっている。
editというところがこのプロフィールページで、それぞれの要素(アカウント削除、パスワード更新、ユーザー情報の更新)をpartials内のファイルで作っているという感じ。
作ったそれぞれのファイルをincludeで読み込んでる。
ユーザー更新の仕組み
例えば、ユーザー情報更新viewのpost先を見てみると「profile.update」のルートに繋がっていた。
そのルートから繋がる、コントローラを見てみる。
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return Redirect::route('profile.edit')->with('status', 'profile-updated');
}
$request->user()で現在認証しているユーザーを取得している。
fillメソッドは一括でデータをインスタンスに入れるやつ(めっちゃ忘れてた)。
validated()はバリデーションされたデータを取得するやつ。
updateの引数がただのRequestでなく、ProfileUpdateRequestで、ここでバリデーションされている。中身を見てみると
return [
'name' => ['string', 'max:255'],
'email' => ['email', 'max:255', Rule::unique(User::class)->ignore($this->user()->id)],
];
こんな感じ。
つまり、このリクエストのバリデーションに通過しないと
$request->user()->fill($request->validated())
でエラーになる。
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
ってなにやってんだと思ったんだけど、これはメールアドレスが認証されているかどうかを記録するもの。
isDirtyがその値が更新されるかを判別するやつで、更新されるとTrueを返す。
つまり、新しいメールアドレスの場合メールアドレスの認証がされないことになる=email_verified_atをnullにする。ということらしい。
しかし、私はまだメールサーバとかを建てていないので今のところこの属性を使う予定はない。
なるほどなぁ。
とりあえず仕組みは何となくわかったので、マイページ画面の作成でもしてみる。
マイページ画面の作成
Qiita、github、youtubeのマイページURLを見てみると
のように、「ドメイン/ユーザー名」でマイページを作成している。
これってURL被っちゃったりしないんだろうか。
例えばQiitaの場合、タイムラインのURLは
と、ドメイン/timelineとなっている。
つまり、timelineさんがいたらURLが被っちゃうよね?
youtubeの場合は@を付けることで回避しているのか?
他のサイトは恐らく、使いそうなloginとかdashboardとかでユーザー登録するのを禁止してるのかな。
うーん。でも保守性とか、シンプルさを考えると「ドメイン名/user/ユーザー名」とかの方が絶対いいと思うんだよね。
ちょっと今回はユーザー名は一意なのでドメイン名/user/ユーザー名のパターンでつくってみる。
欲しい機能は
- 投稿記事一覧
- いいねした記事一覧
とか?
とりあえずそんなもんで後から追加していけばよいのでは。
とりあえず、コントローラは必要だと思うので
“php artisan make:controller UserController”
で作成
次にルートの作成
“Route::get(“/user/{username}”, [UserController::class,”show”])->name(“user.show”);”
という感じでいいのではないだろうか。
次に、コントローラの記述
// ユーザー名からユーザーを取得
$user = User::where('name', $username)->firstOrFail();
// ユーザーの投稿を取得
$articles = $user->articles;
// ユーザーのいいねした記事を取得
$likes = $user->likes;
return view('user.show', compact('user', 'articles', 'likes'));
まず
“$user = User::where(‘name’, $username)->firstOrFail();”
について。
ここで、ユーザー探索しているんだけど、かかる時間は恐らくO(1)。
ChatGPT情報なので信頼性は微妙なんだけど、マイグレーション時に->uniqueでユニーク指定しておくと、pythonのsetみたいに扱えるっぽい。
$table->string(‘name’,32)->unique();
次にこちら。
$articles = $user->articles;
こいつは分かり難いけど、ユーザーが投稿した記事を全て取得している。
これはリレーションシップの設定をしているから。
ちょっとここら辺が曖昧なので、詳しくやりたい。
リレーションシップを理解したい
題材はこれ
$articles = $user->articles;
これはユーザーが投稿した記事を全て取得するというコード。
これ、「UserモデルとArticleモデルのどっちにどんなリレーションを設定すればいいの!?」
って毎回思ってたんだけど、ようやく理解できたかもしれない。
今回の場合は$userからそれに紐づく記事を取得するので、userがメインと言える。
だから、Userモデルにリレーションの設定をする。
次に、Userと記事の関係の具体例を考える。
私、tamakomaが作る記事はいくつ存在しても問題ない。
それに対し、ある記事が複数の投稿者を持つのはおかしい。
したがって、UserとArticleの関係は一対多になる。
日本語ドキュメントを見てみると、一対多はhasmanyらしいので、UserモデルにArticleとhasmanyの関係を記述してあげれば
$articles = $user->articles;
で記事が取得できるという感じ。
次の題材がこいつ
$likes = $user->likes;
これで、そのユーザーがいいねした記事を取得する……っていやいやいや、これはlike取得してもうてますやん。
って思ったんだけど、このlikesテーブルはカラムにuserだけでなくarticleも持っている。
だから、それを経由してarticleも取得できるということらしい。
マイページ画面の仮作成
こんな感じでできた。
やっぱちょっとプロフィール画像とか欲しいよね?
作ろう。
プロフィール画像機能の追加
これはユーザーじゃなくてプロフィール画面に追加したほうがいいかな。
やばい、画像投稿処理の方法忘れちゃった。
これってマイグレーションしてUserに新しく画像パスカラムを追加するのがいいのだろうか。
とりあえずやってみよう。
というか、色々調べたら既にBreezeにプロフィール画像機能を追加している方がいたので、これを参考にする。
途中でコンポーネントという概念が出てきたのでメモ
コンポーネントとは
簡潔に言えばViewの使いまわし。
@includeと何が違うの? と思うんだけど、そこら辺も理解したい。
一応該当の日本語ドキュメントはこちら。
前から少し気にはなってたんだけど、例えば以下のようなコードがある
<x-input-label for=”name” :value=”__(‘ユーザーネーム’)” />
このx-input-labelというのが、component。
x-から始まるのがコンポーネントらしく、ver.8からの結構新しめな機能みたい。
これは、input-labelというviewを使いまわしますよという宣言で、実際にcomponentsフォルダにinput-label.blade.phpというファイルがある。
ここに
<x-input-label for=”name” :value=”__(‘ユーザーネーム’)” />
で渡した値を入れて表示させるみたいな感じかなぁ。
また、匿名コンポーネントとクラスコンポーネントっていうのがあるんだけど、クラスコンポーネントが正直あんまりわからない。
コンポーネントを使って画像投稿ビューの構築
まずresources\views\profile\partials\update-profile-information-form.blade.phpのformを少し弄って、x-picture-inputを追加する。
<form method="post" action="{{ route('profile.update') }}" class="mt-6 space-y-6" enctype="multipart/form-data">
@csrf
@method('patch')
<div>
<x-picture-input />
<x-input-error class="mt-2" :messages="$errors->get('picture')" />
</div>
このpicture-inputは最初からあるコンポーネントではないので、自作する。
といっても、ほとんど引用。
“php artisan make:component picture-input”
で元ファイル作成。
<div class="flex mb-4" x-data="picturePreview()">
<div class="mr-3">
<img
id="preview"
src="{{ isset(Auth::user()->profile_icon_path) ? asset('storage/' . Auth::user()->profile_icon_path) : asset('storage/images/user_icon.png') }}"
alt=""
class="w-16 h-16 rounded-full object-cover border-none bg-gray-200">
</div>
<div class="flex items-center">
<button
x-on:click="document.getElementById('picture').click()"
type="button"
class="inline-flex items-center uppercase rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
プロフィール用の写真を選んでください
</button>
<input @change="showPreview(event)" type="file" name="picture" id="picture" class="hidden">
<script>
function picturePreview() {
return {
showPreview: (event) =>{
if(event.target.files.length > 0){
var src = URL.createObjectURL(event.target.files[0]);
document.getElementById('preview').src = src;
}
}
}
}
</script>
</div>
</div>
scirptは避けて通ってきたので説明できないんだけど、画像を新しくすると切り替えてくれるみたいな感じ。
<div class="mr-3">
<img
id="preview"
src="{{ isset(Auth::user()->profile_icon_path) ? asset('storage/' . Auth::user()->profile_icon_path) : asset('storage/images/user_icon.png') }}"
alt=""
class="w-16 h-16 rounded-full object-cover border-none bg-gray-200">
</div>
この部分は画像を表示する部分で、srcがパスを指定している。
このsrc=の部分は、もしUserDBにprofile_photo_pathがあればそれの画像を使い、無ければstorage/images/user_icon.pngを使うという意味。
条件 ? A : Bはexcelみたいなif文で、条件がTrueなら処理A、Falseで処理Bという意味。
x-on:click=”document.getElementById(‘picture’).click()”
<input @change=”showPreview(event)” type=”file” name=”picture” id=”picture” class=”hidden”>
この部分はjavascript? を使ってんのかな? わかんないけど、クリックすると画像選択するためのフォルダを表示する部分。
私の知らない技術を使っている。Alpine.js(アルパイン)というらしい。
javascriptもわからんのに、これは流石にわからん。
とりあえず、初期画像のuser_icon.pngを設定してみた。
この素敵な画像はこちらより。商用利用する場合は変えないとね。
画像投稿用のマイグレーションファイルを作る
参考にしているQiita記事の方ではfreshしてたんだけど、migrate:freshするとデータが消えるので新しくマイグレーションファイルを作ってprofile_icon_pathを追加する。
“php artisan make:migration add_profile_icon_to_users_table –table=users”
でマイグレーションファイルの追加
中身は以下の通り
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->string('profile_icon_path')->nullable();
});
}
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('profile_icon_path');
});
}
nullableにしてnull許可しとかないとエラー吐くのと、down()も記述しておくのがマナーらしいので記述。
“php artisan migrate”
でマイグレート。
一応、mysqlを確認。
超見にくいけど、存在したのでおk。
アイコン画像をアップロードする処理の追加
まだ表示部分しか作っていないので、アップロードする処理も作る。
具体的には、ProfileControllerに画像追加機能を追加する。
中身を見てみると、ProfileUpdateRequestでバリデーションしてそうだとわかるので、そこに画像ファイルのバリデーションも追加する。
バリデーションは以下の通りにしてみた。
public function rules(): array
{
return [
'name' => ['string', 'max:255'],
'email' => ['email', 'max:255', Rule::unique(User::class)->ignore($this->user()->id)],
'picture' => ['file', 'mimes:gif,png,jpg,webp', 'max:4096'],
];
}
webpって最近よく見るけど、何なんだろうねあれ。
ちょっと調べて見たら、2010年にGoogleが作成した画像圧縮方式らしく、圧縮率高い・透過できる・低劣化の3拍子みたい。
最近色んなブラウザが対応し始めたからみんな使ってんのかな。
なんかよさげなのでとりあえず、webpも許可しとく。
次に、ProfileControllerのupdateメソッドに画像投稿処理の追加。
$request->user()->fill($request->safe()->only(['name', 'email']));
...
// 画像投稿処理
$path = null;
if ($request->hasFile('picture')) {
$path = $request->file('picture')->store('uploads/user/images/icon', 'public');
$request->user()->profile_icon_path = $path;
}
こちらもパスをちょっと自分の環境に合わせたくらい。
これ、最初はファイル名どうなるの? と思ったんだけど、Laravelが勝手に一意なもの? をつけてくれるらしい。
便利だね。
アイコン投稿機能を使ってみる
さっそく、私のアイコンに変えてみる。
いいね!
やっぱり、アイコンはアイデンティティだし、あった方がいい。
DTMサイトならアイコン音楽みたいなのも欲しいなぁ。
ここまでのテスト機能を記述する
テスト作ろう。
というか、本来は
理想のテスト→テストFalse→それに合わせて機能作成→テストPass
という流れがいいんだけど、忘れてた。
とりあえず、簡易的に
- マイページ関連のテスト
- 画像投稿のテスト
があれば良いかなと思う。
どうやって作るんだっけねテストって。
こういう時に自分の過去のブログ見ると振り返れるので、何か制作している人はブログ書くのおすすめ。
画像投稿できるかのテスト
簡易的だけど、以下のようにした。
public function test_アイコンの変更が出来る()
{
Storage::fake("public");
$user = $this->login();
$filename = "fakeUserIcon.png";
$image = UploadedFile::fake()->image($filename);
$response = $this
->patch('/profile', [
'name' => 'Test User',
'email' => 'test@example.com',
'picture' => $image,
]);
$response->assertStatus(302);
// ストレージにあるかの確認
Storage::disk('public')->assertExists($user->profile_icon_path);
}
注意してほしいのは、->login()の中身とか、DBのRefreshDatabaseとかは親クラスに書いてるので、これをコピペしても動かない。
また、fake()で画像ファイルを作るときに以下みたいなエラーが出た。
どうやらimagecreatetruecolorという関数の定義をしているPHP-GDが入っていなかったのでエラーになったっぽい。
“sudo dnf install php-gd”
でインストールして
“sudo systemctl restart php-fpm”
で再起動。
恐らくちゃんと動いた。
ちょっと怖いのでコントローラの画像投稿機能を止めてもう一回テストしてみる。
あ~、動いちゃった。
なんでだ。
色々試行錯誤した感じ、path=nullでもパスが成立しちゃってエラーにならないっぽい。
なので、pathがnullか否かのテストにする。
public function test_アイコンの変更が出来る()
{
Storage::fake("public");
$user = $this->login();
$image = UploadedFile::fake()->image("fakeUserIcon.png");
$response = $this
->patch('/profile', [
'name' => 'Test User',
'email' => 'test@example.com',
'picture' => $image,
]);
$response->assertStatus(302);
$user->refresh();
// nullだったら保存できていない
$this->assertNotNull($user->profile_icon_path);
}
こっちはしっかりエラーになったのでとりあえずおk。
マイページが表示されるかのテスト
このマイページのテストはどこに作ろうか悩んだんだけど、
“php artisan make:test MypageTest”
で一旦普通にテストを作ることにする。
public function test_マイページを表示できる()
{
$user = $this->login();
$this->get(route("user.show",["username" => $user->name]))
->assertOk();
}
一旦こんな感じで簡易的なものを作った。
アイコン画像をナビバーに表示する
githubでいう、正にこういう風にしたい。
resources\views\layouts\navigation.blade.phpを弄る。
<x-slot name="trigger">
<img src="{{ isset(Auth::user()->profile_icon_path) ? asset('storage/' . Auth::user()->profile_icon_path) : asset('storage/images/user_icon.png') }}" alt="User Icon" class="w-10 h-10 rounded-full cursor-pointer" x-on:click="$refs.button.click()" />
</x-slot>
みたいにした。
x-on:clickというのはAlpine.jsというフロントエンド軽量フレームワーク。さっきも少し使ってたね。
今回の場合は、画像をクリックするとbuttonをクリックしたときと同じ状況を生み出せるというコード。
結果、以下のようになった。
悪くないねぇ。
もう少し弄ってQiitaに寄せる。
とりあえず、Qiitaに寄せた。
記事の隣に作成者の顔も欲しいよね。ここもQiitaみたいにしよう。
ここら辺もよく使うパーツなので、componentにする。
“php artisan make:component article-outline”
でコンポーネントの作成
中身はこんな感じ。
<div class="bg-white rounded-md px-3 py-3 my-2 flex items-start">
<img src="{{ isset($article->user->profile_icon_path) ? asset('storage/' . $article->user->profile_icon_path) : asset('storage/images/user_icon.png') }}" alt="User Icon" class="w-10 h-10 rounded-full cursor-pointer mr-3">
<div>
<div class="flex flex-col">
<div>@{{ $article->user->name }}</div>
<div class="text-gray-500 text-xs">
{{ $article->created_at ? $article->created_at->format('Y年m月d日') : 'null' }}
</div>
</div>
<h2 class="mt-2">
<a href="{{ route('article.show', $article) }}">
{{ $article->title }}
</a>
</h2>
@foreach($article->tags as $tag)
<span>{{ $tag->name }}</span>
@endforeach
@include('article.like_button', ['article' => $article])
</div>
</div>
何故か日付がnullの時があったので、例外処理的なのを入れている。
見た目はこんな感じ。いいね!
アクセス制限を設ける
現状の何が怖いかというと、DoS攻撃とかそういった攻撃の対策を何もしていないのが怖い。
いくらでもアカウント作れちゃうし、いくらでも記事の投稿が出来ちゃう。それに対して何も制限を設けてないんだよね。
ここら辺を出来るだけ解決していく。
メール認証機能の追加
最初はメールサーバー建てたり結構難しいのかと思ってたんだけど、そんなことないかもしれない。
こちらの記事を参考に。
どうやら、use Illuminate\Contracts\Auth\MustVerifyEmail;を有効化して、gmailを使えばできるみたい
やってみるか。
因みに、Gmailのデメリットは1日500通までの制限と、Googleを通すことと、自由度の低さとかっぽい。
gmailの設定も終えて、.envも設定し終わった。
新しくアカウントを作ってみよう。
お! 英語で読む気でないけど、出来てるっぽい。
めっちゃ警告受けてて、迷惑メールにあったけどメールも来てた!
日本語じゃないのがちょっと不満なので、そこら辺の改善を図る。
config/app.phpは日本語設定にしているので、それ以外の設定が必要なのかな。
色々調べたら、こちらの方法が今はお勧めらしいので、これでやってみる。
もう一回メール送ってみよう。
おーーー、日本語になってるねぇ。素晴らしいねぇ。
次に、認証してないアカウントは弾くようにする。
やり方は
Route::get('/dashboard', function () {
return view('dashboard');
})->middleware(['verified'])->name('dashboard');
みたいに弾きたいルートにmiddleware([‘verified’])を追加すればよいみたい。
私の場合は以下みたいにした。
Route::middleware(['verified'])->group(function() {
Route::get('article/create', [ArticleController::class, 'create'])->name('article.create');
Route::post('article', [ArticleController::class, 'store'])->name('article.store');
Route::get('article/{article}/edit', [ArticleController::class, 'edit'])->name('article.edit');
Route::put('article/{article}', [ArticleController::class, 'update'])->name('article.update');
Route::delete('article/{article}', [ArticleController::class, 'destroy'])->name('article.destroy');
});
Route::get('article', [ArticleController::class, 'index'])->name('article.index');
Route::get('article/{article}', [ArticleController::class, 'show'])->name('article.show');
Route::middleware(['verified'])->group(function () {
Route::post("/article/{id}/like", [LikeController::class, "store"])->name("article.like");
Route::delete("/article/{id}/unlike", [LikeController::class, "destroy"])->name("article.unlike");
});
Route::middleware(['verified'])->group(function() {
Route::get('audio/create', [AudioController::class, 'create'])->name('audio.create');
Route::post('audio', [AudioController::class, 'store'])->name('audio.store');
Route::delete('audio/{audio}', [AudioController::class, 'destroy'])->name('audio.destroy');
});
順序大事なので注意。
また、php artisan testやってみたら
8件failedだった。
どうやらゲスト時に404じゃなくログイン画面に遷移するようになったっぽい。
ここら辺も変えておく。
因みに、MySQLでid1番目を認証したことにするなら
UPDATE users SET email_verified_at = NOW() WHERE id = 1;
どちらにしろ、本格的に動かすならメールサーバーを建てなきゃいけないっぽいけど、とりあえず一旦Gmailさんに頑張って頂く。
レート制限をする
Q.レート制限とはなんぞや?
A.なんか、アクセス回数の制限を設けられるシステムのこと。
個人的には日本語ドキュメントのルーティングにある説明がまだ理解できた。
とりあえずやってみる。
app\Providers\RouteServiceProvider.phpを編集する。
すでにapiという名前の奴があるけど、自分でも作ってみよう。
protected function configureRateLimiting()
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('uploads', function (Request $request) {
return Limit::perMinute(1)->by($request->user()?->id ?: $request->ip());
});
}
自分で作ったのが
RateLimiter::for('uploads', function (Request $request) {
return Limit::perMinute(1)->by($request->user()?->id ?: $request->ip());
で、1分間に1回だけのリクエストを受け付ける。
そんで、判定の仕方はできればidで、idがなければipでみたいなことだよね?
これを、ルートで
Route::middleware(['verified','throttle:uploads'])->group(function() {
Route::post('article', [ArticleController::class, 'store'])->name('article.store');
Route::put('article/{article}', [ArticleController::class, 'update'])->name('article.update');
Route::delete('article/{article}', [ArticleController::class, 'destroy'])->name('article.destroy');
});
見たいに使ってあげればいいはず。
おk。
1分間に2回編集しようとしたら429が出たのでよさげ。
とりあえず、必要そうな処理にはレート制限をかける。
おわりに
とりあえず、できたものをクラウド環境にデプロイ……と言えば聞こえはいいけど実際はrsyncで丸々送ってるだけなんよね。
そんで、色々設定して……
うん、問題なく動くね。
次は音声ファイルのアップロードとか、サーバー監視とかそういうところに力を入れていきたい。
とりあえず、おやすみなさい。