ファイルをアップロード・ダウンロードできるWebアプリを公開しました

 / #react #firebase

以降、UL=アップロード、DL=ダウンロードと省略します。

はじめに

趣味関係で開発・メンテナンスしている JS 製のツールを GoogleDive で配布していたのですが、代替サービスを探していました。探す中で作れそうだ・作ってみたいと思い、2022年8月にサイトを公開しました。

※自ツール配布のためだけのサイトであり、また悪意のあるファイルを他者に UL されると困るので、サイト URL と GitHub リポジトリは非公開です。

どんなサービス

.zipファイルを説明文とともに UL し、また DL できるサービス。UL にはユーザー登録を必要とし、DL は非登録ユーザーでも可能。

Image from Gyazo

.zipファイルの種別やサイズ、最終更新、ダウンロード数、説明文を閲覧できる。

Image from Gyazo

利用規約に同意すると、DL 可能となる。

Image from Gyazo

利用状況

基本的に月上旬に1回、不具合などあればマイナーアップデート版と言った風にしています。※メンテナンス・開発を引き継ぐ前から、日時ベースのバージョン管理となっていました。

Image from Gyazo

Image from Gyazo

開発

利用した技術・サービス

サイトデザイン等は省力化のためにTailwind CSS Componentsを参照しました。

Form components

react-hook-formを使い、TextInput.tsx, PasswordInput.tsx, Textarea.tsx, Checkbox.tsx などを作成し、signup や login 認証やファイル UL 時のフォームで利用しました。また、ファイル UL の為のドロップゾーンはreact-dropzoneを利用しましたが、コードが長いので割愛します。

PasswordInput.tsx
import type { ComponentProps, FC } from "react";
import { useState, useCallback } from "react"
import { useFormContext } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/outline";
export type InputProps = Omit<ComponentProps<"input">, "type"> & {
id: string;
label: string;
validation?: RegisterOptions;
helperText?: string;
};
export const PasswordInput: FC<InputProps> = (props) => {
const { label, placeholder = "", helperText = "", id, disabled, readOnly, validation, ...rest } = props;
const { register, formState: { errors } } = useFormContext();
const [showPassword, setShowPassword] = useState(false);
const togglePassword = useCallback(() => {
setShowPassword((prev) => !prev);
}, []);
let stateClass;
if (readOnly || disabled) {
stateClass = "bg-gray-100 focus:ring-0 cursor-not-allowed border-gray-300 focus:border-gray-300";
} else if (errors[id]) {
stateClass = "focus:ring-red-500 border-red-500 focus:border-red-500";
} else {
stateClass = "focus:ring-primary-500 border-gray-300 focus:border-primary-500";
}
const className = twMerge("block w-full rounded-md shadow-sm", stateClass);
const isError = !!Object.entries(errors).length && !!errors[id]
return (
<div>
<label htmlFor={id} className="block text-sm font-normal text-gray-700">
{label}
</label>
<div className="relative mt-1">
<input
{...register(id, validation)} {...rest}
type={showPassword ? "text" : "password"}
name={id} id={id}
readOnly={readOnly} disabled={disabled}
className={className} placeholder={placeholder} aria-describedby={id}
/>
<button
onClick={(e) => {
e.preventDefault();
togglePassword();
}}
type="button"
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"
>
{showPassword ? (
<EyeSlashIcon className="h-6 w-6 cursor-pointer text-gray-500 hover:text-gray-600" />
) : (
<EyeIcon className="h-6 w-6 cursor-pointer text-gray-500 hover:text-gray-600" />
)}
</button>
</div>
<div className="mt-1">
{helperText && <p className="text-xs text-gray-500">{helperText}</p>}
{isError && <span className="text-sm text-red-500">{JSON.stringify(errors[id]?.message, null, 2)}</span>}
</div>
</div>
);
};

認証機能

ユーザー登録を私のみに制限するために、メールアドレス・パスワードによる Firebase 認証を選択し、.envで設定されたメールアドレスのみ使えるようにしました。

zod

バリデーションのためにreact-hook-formzodResolverを利用しました。

import * from z from "zod";
const emailSchema = z
.string()
.email({ message: 'メールアドレスの形式が正しくありません' });
const passwordSchema = z.string()
export const SignUpWithEmailAndPasswordSchema = z.object({
email: emailSchema,
password: passwordSchema
})

Signup

Signup.tsx
import { FC, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useCreateUserWithEmailAndPassword } from "react-firebase-hooks/auth";
import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { auth } from "@/lib/firebase";
import { SignUpWithEmailAndPasswordSchema } from "@/lib/zod";
import { Input, PasswordInput, Button } from "@/components/Form";
const validEmail = import.meta.env.VITE_VALID_EMAIL_ADRESS;
const Signup: FC = () => {
const navigate = useNavigate();
const [createUserWithEmailAndPassword, user, loading, error] = useCreateUserWithEmailAndPassword(auth);
const methods = useForm<z.infer<typeof SignUpWithEmailAndPasswordSchema>>({
resolver: zodResolver(SignUpWithEmailAndPasswordSchema),
});
const { handleSubmit, setError } = methods;
const onSubmit = handleSubmit((data) => {
const { email, password } = data;
if (email !== validEmail) {
setError("email", { type: "custom", message: "メールアドレスまたはパスワードが間違っています" });
setError("password", { type: "custom", message: "メールアドレスまたはパスワードが間違っています" });
return;
}
createUserWithEmailAndPassword(email, password);
});
useEffect(() => {
if (user) navigate("/admin");
}, [user]);
return (
<FormProvider {...methods}>
<form onSubmit={onSubmit} className="mx-auto max-w-lg rounded-lg border">
<div className="flex flex-col gap-4 p-4 md:p-8">
<Input id="email" label="Eメールアドレス" placeholder="example@example.com" validation={{ required: "required" }} />
<PasswordInput id="password" label="パスワード" validation={{ required: "required" }} />
<Button type="submit">サインアップ</Button>
</div>
</form>
</FormProvider>
);
};
export default Signup;

Firestore database

ファイル UL の際に追加情報として入力するファイルの説明文やサイズ、contentType などを保存する先として、Firestore を利用しました。本来であれば userID などを追加すべきかと思いましたが、登録可能ユーザーを私のみに制限しているので楽をしました。

{
"files": {
"autoID": {
"name": "hoge.zip",
"description": "description",
"downloaded": 133,
"contentType": "application/x-zip-compressed",
"path": "path-to-firebase-storage",
"size": 579896,
"createdAt": "serverTime",
"updatedAt": "serverTime"
}
}
}
<!-- firestore.rules -->
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isAuthenticated() {
return request.auth != null;
}
match /files/{fileID} {
allow get, list, update;
allow create, delete: if isAuthenticated()
}
}
}

Storage

UL された.zipファイルを保存するために利用しました。

<!-- storage.rules -->
rules_version = '2';
service firebase.storage {
function isAuthenticated() {
return request.auth != null;
}
match /b/{bucket}/o {
match /files/{fileID} {
allow get;
allow create, update, delete: if isAuthenticated()
}
}
}

CORS Error

Firebase Storage を利用する際にあたって、CORS の設定をしました。

ブラウザで直接データをダウンロードするには、Cloud Storage バケットに対してクロスオリジン アクセス(CORS)を構成する必要があります。

ウェブで Cloud Storage を使用してファイルをダウンロードする

[
{
"origin": [
"https://example.com",
"http://localhost:3000"
],
"responseHeader": ["Content-Type"],
"method": ["GET"],
"maxAgeSeconds": 3600
}
]
gsutil cors set path-to-cors-json-file gs://<bucket_name>...
gsutil cors get gs://<bucket_name>

おわりに

当初はドメイン代程度の赤字でも良いと思っていました。現在では毎日の粗食1杯程度の広告収入由来の利益は出ており、運用コストを抑えることのできる Firebase に感謝したいです。