Reactで趣味ツールを配布するためのサイトを作りました(1年前に)

/
#react#firebase

はじめに

自作ツールの配布を行なうためのダウンローダサイトを React と Firebase を用いて作りました。元々利用していた GoogleDrive では個人的に不都合な点があり、他の手段を探していましたが、他ダウンローダサイトを見ていくうちに作れそうだと思い作りました。

※趣味領域に自分の個人情報が紐づけられたり、悪意のあるファイルをサイトに仕込まれるのを避けるため、GitHub レポジトリは Private 下にあります。またサイトが特定される様な情報は今投稿においては公開しません。

また以降はアップロードを UL、ダウンロードを DL と書きます。

Image from Gyazo

サイトについて

機能要件

  • アカウント登録・ログインができる
    • ※悪意のあるファイルが UL されるのを防ぐため、自分しか登録・ログインできない様に
  • ログイン時のみ、.zip ファイルをアップロードできる
  • 登録・ログイン無しでファイルをダウンロードできる

使用したもの

まとめ

以降はコードが多いので最初にまとめ。

firestore, storage を使った公開サイトは初めてでしたが、そこそこ使われている様で嬉しかったです。元々自分が楽する様に作ったツールなので良かったです。

コードを見返すと、ロジックとビューがごちゃ混ぜになっていて汚い箇所があるので修正していきたいです。

Image from Gyazo

Image from Gyazo

作成

firebase

プロジェクトはまず firebase init で作りました。.zip の UL 先に FirebaseStorage、そのファイルの内容・DL 数のデータ情報・論理削除フラグなどのデータ保存先に Firestore を利用しました。

undefined
1
firebase init
filrebase.rules
1
rules_version = '2';
2
service cloud.firestore {
3
match /databases/{database}/documents {
4
function isAuthenticated() {
5
return request.auth != null;
6
}
7
8
// function isUserAuthenticated(userID) {
9
// return request.auth.uid == userID;
10
// }
11
12
match /files/{fileID} {
13
allow get, list, update;
14
allow create, delete: if isAuthenticated()
15
}
16
}
17
}
storage.rules
1
rules_version = '2';
2
service firebase.storage {
3
match /b/{bucket}/o {
4
match /files/{fileID} {
5
allow get;
6
allow create, update, delete: if isAuthenticated()
7
}
8
}
9
}

通常であれば、match /files/{user's UUID}/{fileID} の様にするのが良かったのかもしれませんが、垢登録・UL を自分だけに制限するつもりでしたので、さぼりました。

また、storage へのアクセスで cors エラーが発生するので、ドキュメントを参照して対応しました。

cors.json
1
[
2
{
3
"origin": ["http://origin1.example.com"],
4
"responseHeader": ["Content-Type"],
5
"method": ["GET"],
6
"maxAgeSeconds": 3600
7
}
8
]
undefined
1
gsutil cors set path-to-cors-json-file gs://<bucket_name>...
2
gsutil cors get gs://<bucket_name>

react app

firebase setting

firebase v9 を使用しました。

filebase.ts
1
import { initializeApp } from "firebase/app";
2
import { getAuth, browserSessionPersistence, GoogleAuthProvider } from "firebase/auth";
3
import { getFirestore } from "firebase/firestore";
4
import { getStorage } from "firebase/storage";
5
6
const firebaseConfig = {
7
apiKey: import.meta.env.VITE_FIREBASE_APP_KEY,
8
authDomain: ., projectId: ., storageBucket: ., appId: .,
9
};
10
11
const app = initializeApp(firebaseConfig);
12
const auth = getAuth(app);
13
const db = getFirestore(app);
14
const storage = getStorage(app);
15
const provider = new GoogleAuthProvider();
16
17
const persisit = import.meta.env.VITE_FIREBASE_AUTH_PERSISIT;
18
19
if (persisit) {
20
// ログインを継続しない
21
getAuth().setPersistence(browserSessionPersistence);
22
}
23
24
export { auth, provider, db, storage };

file input form

フォームへの .zip ファイルや各種データの入力等に react-hook-formzod を、ファイルインプット領域には react-dropzone を利用しました。

newFile.ts
1
import type { FC } from "react";
2
import { FormProvider, useForm } from "react-hook-form";
3
import { zodResolver } from "@hookform/resolvers/zod";
4
5
import { CreateFormSchema } from "@/lib/zod";
6
7
const NewFile: FC = () => {
8
const methods = useForm<Pick<FormData, "name" | "description" | "file" | "deleted">>({
9
resolver: zodResolver(CreateFormSchema),
10
defaultValues: {
11
name: "",
12
file: [],
13
description: Description,
14
deleted: false,
15
},
16
});
17
18
const onSumit = (async (data) => {
19
// ...
20
})
21
22
return (
23
<FormProvider {...methods}>
24
<DropzoneInput
25
id="file"
26
label="file"
27
accept={{
28
"application/zip": [".zip"],
29
}}
30
/>
31
// ...
32
</FormProvider>
33
)
34
}

Image from Gyazo

login form

自分だけが登録できるようにするためパスワード認証を利用し、.env に登録された Email 以外は弾くようにしました。signup 認証は殆ど同じなので割愛します。

login.tsx
1
import { useSignInWithEmailAndPassword } from "react-firebase-hooks/auth";
2
import { FormProvider, useForm } from "react-hook-form";
3
import { zodResolver } from "@hookform/resolvers/zod";
4
import { auth } from "@/lib/firebase";
5
import { SignInWithEmailAndPasswordSchema } from "@/lib/zod";
6
7
const validEmail = import.meta.env.VITE_VALID_EMAIL_ADRESS;
8
9
const Login: FC = () => {
10
const [signInWithEmailAndPassword, user, loading, error] = useSignInWithEmailAndPassword(auth);
11
const methos = useForm<{ email: string; password: string }>({
12
resolver: zodResolver(SignInWithEmailAndPasswordSchema),
13
});
14
const { handleSubmit, setError } = methos;
15
16
const onSubmit = handleSubmit((data) => {
17
const { email, password } = data;
18
if (email !== validEmail) {
19
setError("email", { type: "custom", message: "メールアドレスまたはパスワードが間違っています" });
20
setError("password", { type: "custom", message: "メールアドレスまたはパスワードが間違っています" });
21
} else {
22
signInWithEmailAndPassword(email, password);
23
}
24
});
25
26
return (
27
<FormProvider {...methos}>
28
<form onSubmit={onSubmit} className="mx-auto max-w-lg rounded-lg border">
29
<div className="flex flex-col gap-4 p-4 md:p-8">
30
<Input id="email" label="Eメール" placeholder="[email protected]" validation={{ required: "required" }} />
31
<PasswordInput id="password" label="パスワード" validation={{ required: "required" }} />
32
<Button type="submit">ログイン</Button>
33
</div>
34
</form>
35
</FormProvider>)
36
}

Image from Gyazo

required auth

ログイン・非ログインでアクセスできるまたは表示するページを出しわけるためのコンポーネントを作り利用しました。

RequiredAuth.tsx
1
import { useLocation, Navigate, Outlet } from "react-router-dom";
2
import { useAuthState } from "react-firebase-hooks/auth";
3
import { auth } from "@/lib/firebase";
4
5
const RequiredAuth: FC = () => {
6
const [user, loading, error] = useAuthState(auth);
7
const location = useLocation();
8
9
if (loading) {
10
return <div>loading...</div>
11
}
12
13
if (!loading && error) {
14
return <div>error...</div>
15
}
16
17
if (!user) {
18
return <Navigate to="/login" state={{ from: location }} replace />;
19
}
20
21
return (
22
<>
23
<Seo noindex />
24
<Outlet />
25
</>
26
);
27
};

自分以外は admin ページにアクセスしないので、パフォーマンスのためにも lazy import しました。

App.tsx
1
import { BrowserRouter, Routes, Route } from "react-router-dom";
2
const RequiredAuth = lazy(() => import("./components/RequiedAuth"))
3
const Admin = lazy(() => import("./pages/admin"));
4
const NewFile = lazy(() => import("./pages/admin/new"));
5
const EditFile = lazy(() => import("./pages/admin/editFile"));
6
const DeleteFile = lazy(() => import("./pages/admin/deleteFile"));
7
8
const App: FC = () => (
9
<BrowserRouter>
10
<Routes>
11
<Route path="/" element={<Layout />}>
12
<Route index element={<Home />} />
13
<Route path="files" element={<FileLayout />}>
14
<Route index element={<Files />} />
15
<Route path=":fileId">
16
<Route index element={<FileDetail />} />
17
<Route path="download" element={<FileDownload />} />
18
</Route>
19
</Route>
20
<Route path="login" element={<Login />} />
21
<Route path="signup" element={<Signup />} />
22
<Route path="admin" element={<RequiredAuth />}>
23
<Route index element={<Admin />} />
24
<Route path="new" element={<NewFile />} />
25
<Route path="files/:fileId">
26
<Route path="edit" element={<EditFile />} />
27
<Route path="delete" element={<DeleteFile />} />
28
</Route>
29
</Route>
30
<Route path="*" element={<PageNotFound />} />
31
</Route>
32
</Routes>
33
</BrowserRouter>
34
);

さいごに

アナリティクスによると MAU2000 弱の様です。値としては小さいですが、ニッチな領域でのツールの為のサイトなのでこんなものかと思います。