ファイルをアップロード・ダウンロードできるWebアプリを公開しました
以降、UL=アップロード、DL=ダウンロードと省略します。
はじめに
趣味関係で開発・メンテナンスしている JS 製のツールを GoogleDive で配布していたのですが、代替サービスを探していました。探す中で作れそうだ・作ってみたいと思い、2022 年 8 月にサイトを公開しました。
※自ツール配布のためだけのサイトであり、また悪意のあるファイルを他者に UL されると困るので、サイト URL と GitHub リポジトリは非公開です。
どんなサービス
.zip
ファイルを説明文とともに UL し、また DL できるサービス。UL にはユーザー登録を必要とし、DL は非登録ユーザーでも可能。
![Image from Gyazo](https://i.gyazo.com/ca3fe099cecbd3ae3190071178432e52.png)
.zip
ファイルの種別やサイズ、最終更新、ダウンロード数、説明文を閲覧できる。
![Image from Gyazo](https://i.gyazo.com/a196d0a42cc76e0bcdabbbb4658cde5a.png)
利用規約に同意すると、DL 可能となる。
![Image from Gyazo](https://i.gyazo.com/506ba3da5f5e4b8cd6ca54f49423205b.png)
利用状況
基本的に月上旬に 1 回、不具合などあればマイナーアップデート版と言った風にしています。※メンテナンス・開発を引き継ぐ前から、日時ベースのバージョン管理となっていました。
![Image from Gyazo](https://i.gyazo.com/ac6ea63d60e6b9ddb707f95bb9b1b9d8.png)
![Image from Gyazo](https://i.gyazo.com/f1e7e6c245d3f2cd161ec7157a855d41.png)
開発
利用した技術・サービス
サイトデザイン等は省力化のために Tailwind CSS Components を参照しました。
- React, TypeScript, TailwindCSS
- Firebase
- Hosting, Firestore Database, Storage
Form components
signup や login 認証やファイル UL 時のフォームに必要なコンポーネントを react-hook-form
を使って作成しました。また、ファイル UL の為のドロップゾーンは react-dropzone
を利用しましたが、コードが長いので割愛します。
PasswordInput.tsx
1import type { ComponentProps, FC } from "react";2import { useState, useCallback } from "react"3import { useFormContext } from "react-hook-form";4import { twMerge } from "tailwind-merge";5import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/outline";6
7export type InputProps = Omit<ComponentProps<"input">, "type"> & {8 id: string;9 label: string;10 validation?: RegisterOptions;11 helperText?: string;12};13
14export const PasswordInput: FC<InputProps> = (props) => {15 const { label, placeholder = "", helperText = "", id, disabled, readOnly, validation, ...rest } = props;16
17 const { register, formState: { errors } } = useFormContext();18 const [showPassword, setShowPassword] = useState(false);19 const togglePassword = useCallback(() => {20 setShowPassword((prev) => !prev);21 }, []);22
23 let stateClass;24 if (readOnly || disabled) {25 stateClass = "bg-gray-100 focus:ring-0 cursor-not-allowed border-gray-300 focus:border-gray-300";26 } else if (errors[id]) {27 stateClass = "focus:ring-red-500 border-red-500 focus:border-red-500";28 } else {29 stateClass = "focus:ring-primary-500 border-gray-300 focus:border-primary-500";30 }31 const className = twMerge("block w-full rounded-md shadow-sm", stateClass);32 const isError = !!Object.entries(errors).length && !!errors[id]33
34 return (35 <div>36 <label htmlFor={id} className="block text-sm font-normal text-gray-700">37 {label}38 </label>39 <div className="relative mt-1">40 <input41 {...register(id, validation)} {...rest}42 type={showPassword ? "text" : "password"}43 name={id} id={id}44 readOnly={readOnly} disabled={disabled}45 className={className} placeholder={placeholder} aria-describedby={id}46 />47
48 <button49 onClick={(e) => {50 e.preventDefault();51 togglePassword();52 }}53 type="button"54 className="focus:ring-primary-500 absolute inset-y-0 right-0 mr-3 flex items-center rounded-lg p-1 focus:outline-none focus:ring"55 >56 {showPassword ? (57 <EyeSlashIcon className="h-6 w-6 cursor-pointer text-gray-500 hover:text-gray-600" />58 ) : (59 <EyeIcon className="h-6 w-6 cursor-pointer text-gray-500 hover:text-gray-600" />60 )}61 </button>62 </div>63 <div className="mt-1">64 {helperText && <p className="text-xs text-gray-500">{helperText}</p>}65 {isError && <span className="text-sm text-red-500">{JSON.stringify(errors[id]?.message, null, 2)}</span>}66 </div>67 </div>68 );69};
認証機能
ユーザー登録を私のみに制限するために、メールアドレス・パスワードによる Firebase 認証を選択し、.env
で設定されたメールアドレスのみ使えるようにしました。
zod
バリデーションのために react-hook-form
の zodResolver
を利用しました。
1import * from z from "zod";2
3const emailSchema = z4 .string()5 .email({ message: 'メールアドレスの形式が正しくありません' });6
7const passwordSchema = z.string()8
9export const SignUpWithEmailAndPasswordSchema = z.object({10 email: emailSchema,11 password: passwordSchema12})
Signup
Signup.tsx
1import { FC, useEffect } from "react";2import { useNavigate } from "react-router-dom";3import { useCreateUserWithEmailAndPassword } from "react-firebase-hooks/auth";4import { FormProvider, useForm } from "react-hook-form";5import { z } from "zod";6import { zodResolver } from "@hookform/resolvers/zod";7
8import { auth } from "@/lib/firebase";9import { SignUpWithEmailAndPasswordSchema } from "@/lib/zod";10import { Input, PasswordInput, Button } from "@/components/Form";11
12const validEmail = import.meta.env.VITE_VALID_EMAIL_ADRESS;13
14const Signup: FC = () => {15 const navigate = useNavigate();16 const [createUserWithEmailAndPassword, user, loading, error] = useCreateUserWithEmailAndPassword(auth);17 const methods = useForm<z.infer<typeof SignUpWithEmailAndPasswordSchema>>({18 resolver: zodResolver(SignUpWithEmailAndPasswordSchema),19 });20 const { handleSubmit, setError } = methods;21
22 const onSubmit = handleSubmit((data) => {23 const { email, password } = data;24 if (email !== validEmail) {25 setError("email", { type: "custom", message: "メールアドレスまたはパスワードが間違っています" });26 setError("password", { type: "custom", message: "メールアドレスまたはパスワードが間違っています" });27 return;28 }29
30 createUserWithEmailAndPassword(email, password);31 });32
33 useEffect(() => {34 if (user) navigate("/admin");35 }, [user]);36
37 return (38 <FormProvider {...methods}>39 <form onSubmit={onSubmit} className="mx-auto max-w-lg rounded-lg border">40 <div className="flex flex-col gap-4 p-4 md:p-8">41 <Input id="email" label="Eメールアドレス" placeholder="[email protected]" validation={{ required: "required" }} />42 <PasswordInput id="password" label="パスワード" validation={{ required: "required" }} />43 <Button type="submit">サインアップ</Button>44 </div>45 </form>46 </FormProvider>47 );48};49
50export default Signup;
Firestore database
ファイル UL の際に追加情報として入力するファイルの説明文やサイズ、contentType などを保存する先として、Firestore を利用しました。本来であれば userID などを追加すべきかと思いましたが、登録可能ユーザーを私のみに制限しているので楽をしました。
1{2 "files": {3 "autoID": {4 "name": "hoge.zip",5 "description": "description",6 "downloaded": 133,7 "contentType": "application/x-zip-compressed",8 "path": "path-to-firebase-storage",9 "size": 579896,10 "createdAt": "serverTime",11 "updatedAt": "serverTime"12 }13 }14}
1<!-- firestore.rules -->2rules_version = '2';3service cloud.firestore {4 match /databases/{database}/documents {5 function isAuthenticated() {6 return request.auth != null;7 }8
9 match /files/{fileID} {10 allow get, list, update;11 allow create, delete: if isAuthenticated()12 }13 }14}
Storage
UL された .zip
ファイルを保存するために利用しました。
1<!-- storage.rules -->2rules_version = '2';3service firebase.storage {4 function isAuthenticated() {5 return request.auth != null;6 }7
8 match /b/{bucket}/o {9 match /files/{fileID} {10 allow get;11 allow create, update, delete: if isAuthenticated()12 }13 }14}
CORS Error
Firebase Storage を利用する際にあたって、CORS の設定をしました。
ブラウザで直接データをダウンロードするには、Cloud Storage バケットに対してクロスオリジン アクセス(CORS)を構成する必要があります。
ウェブで Cloud Storage を使用してファイルをダウンロードする
- ウェブで Cloud Storage を使用してファイルをダウンロードする | Cloud Storage for Firebase
- クロスオリジン リソース シェアリング(CORS)の構成
- gsutil をインストールする | Cloud Storage | Google Cloud
1[2 {3 "origin": [4 "https://example.com",5 "http://localhost:3000"6 ],7 "responseHeader": ["Content-Type"],8 "method": ["GET"],9 "maxAgeSeconds": 360010 }11]
1gsutil cors set path-to-cors-json-file gs://<bucket_name>...2gsutil cors get gs://<bucket_name>
おわりに
当初はドメイン代程度の赤字でも良いと思っていました。現在では毎日の粗食 1 杯程度の広告収入由来の利益は出ており、運用コストを抑えることのできる Firebase に感謝したいです。