Reactで趣味ツールを配布するためのサイトを作りました(1年前に)
はじめに
自作ツールの配布を行なうためのダウンローダサイトを React と Firebase を用いて作りました。元々利用していた GoogleDrive では個人的に不都合な点があり、他の手段を探していましたが、他ダウンローダサイトを見ていくうちに作れそうだと思い作りました。
※趣味領域に自分の個人情報が紐づけられたり、悪意のあるファイルをサイトに仕込まれるのを避けるため、GitHub レポジトリは Private 下にあります。またサイトが特定される様な情報は今投稿においては公開しません。
また以降はアップロードを UL、ダウンロードを DL と書きます。
サイトについて
機能要件
- アカウント登録・ログインができる
- ※悪意のあるファイルが UL されるのを防ぐため、自分しか登録・ログインできない様に
- ログイン時のみ、
.zip
ファイルをアップロードできる - 登録・ログイン無しでファイルをダウンロードできる
使用したもの
- React, Typescript
- Firebase
- Hosting, Firestore, Storage
まとめ
以降はコードが多いので最初にまとめ。
firestore, storage を使った公開サイトは初めてでしたが、そこそこ使われている様で嬉しかったです。元々自分が楽する様に作ったツールなので良かったです。
コードを見返すと、ロジックとビューがごちゃ混ぜになっていて汚い箇所があるので修正していきたいです。
作成
firebase
プロジェクトはまず firebase init で作りました。.zip
の UL 先に FirebaseStorage、そのファイルの内容・DL 数のデータ情報・論理削除フラグなどのデータ保存先に Firestore を利用しました。
1firebase init
1rules_version = '2';2service 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}
1rules_version = '2';2service 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 エラーが発生するので、ドキュメントを参照して対応しました。
1[2 {3 "origin": ["http://origin1.example.com"],4 "responseHeader": ["Content-Type"],5 "method": ["GET"],6 "maxAgeSeconds": 36007 }8]
1gsutil cors set path-to-cors-json-file gs://<bucket_name>...2gsutil cors get gs://<bucket_name>
react app
firebase setting
firebase v9 を使用しました。
1import { initializeApp } from "firebase/app";2import { getAuth, browserSessionPersistence, GoogleAuthProvider } from "firebase/auth";3import { getFirestore } from "firebase/firestore";4import { getStorage } from "firebase/storage";5
6const firebaseConfig = {7 apiKey: import.meta.env.VITE_FIREBASE_APP_KEY,8 authDomain: ., projectId: ., storageBucket: ., appId: .,9};10
11const app = initializeApp(firebaseConfig);12const auth = getAuth(app);13const db = getFirestore(app);14const storage = getStorage(app);15const provider = new GoogleAuthProvider();16
17const persisit = import.meta.env.VITE_FIREBASE_AUTH_PERSISIT;18
19if (persisit) {20 // ログインを継続しない21 getAuth().setPersistence(browserSessionPersistence);22}23
24export { auth, provider, db, storage };
file input form
フォームへの .zip
ファイルや各種データの入力等に react-hook-form
と zod
を、ファイルインプット領域には react-dropzone
を利用しました。
1import type { FC } from "react";2import { FormProvider, useForm } from "react-hook-form";3import { zodResolver } from "@hookform/resolvers/zod";4
5import { CreateFormSchema } from "@/lib/zod";6
7const 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 <DropzoneInput25 id="file"26 label="file"27 accept={{28 "application/zip": [".zip"],29 }}30 />31 // ...32 </FormProvider>33 )34}
login form
自分だけが登録できるようにするためパスワード認証を利用し、.env
に登録された Email 以外は弾くようにしました。signup 認証は殆ど同じなので割愛します。
1import { useSignInWithEmailAndPassword } from "react-firebase-hooks/auth";2import { FormProvider, useForm } from "react-hook-form";3import { zodResolver } from "@hookform/resolvers/zod";4import { auth } from "@/lib/firebase";5import { SignInWithEmailAndPasswordSchema } from "@/lib/zod";6
7const validEmail = import.meta.env.VITE_VALID_EMAIL_ADRESS;8
9const 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}
required auth
ログイン・非ログインでアクセスできるまたは表示するページを出しわけるためのコンポーネントを作り利用しました。
1import { useLocation, Navigate, Outlet } from "react-router-dom";2import { useAuthState } from "react-firebase-hooks/auth";3import { auth } from "@/lib/firebase";4
5const 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 しました。
1import { BrowserRouter, Routes, Route } from "react-router-dom";2const RequiredAuth = lazy(() => import("./components/RequiedAuth"))3const Admin = lazy(() => import("./pages/admin"));4const NewFile = lazy(() => import("./pages/admin/new"));5const EditFile = lazy(() => import("./pages/admin/editFile"));6const DeleteFile = lazy(() => import("./pages/admin/deleteFile"));7
8const 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 弱の様です。値としては小さいですが、ニッチな領域でのツールの為のサイトなのでこんなものかと思います。