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

/

以降、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

signup や login 認証やファイル UL 時のフォームに必要なコンポーネントを react-hook-form を使って作成しました。また、ファイル UL の為のドロップゾーンは react-dropzone を利用しましたが、コードが長いので割愛します。

PasswordInput.tsx
undefined
1
import type { ComponentProps, FC } from "react";
2
import { useState, useCallback } from "react"
3
import { useFormContext } from "react-hook-form";
4
import { twMerge } from "tailwind-merge";
5
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/outline";
6
7
export type InputProps = Omit<ComponentProps<"input">, "type"> & {
8
id: string;
9
label: string;
10
validation?: RegisterOptions;
11
helperText?: string;
12
};
13
14
export 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
<input
41
{...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
<button
49
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-formzodResolver を利用しました。

undefined
1
import * from z from "zod";
2
3
const emailSchema = z
4
.string()
5
.email({ message: 'メールアドレスの形式が正しくありません' });
6
7
const passwordSchema = z.string()
8
9
export const SignUpWithEmailAndPasswordSchema = z.object({
10
email: emailSchema,
11
password: passwordSchema
12
})

Signup

Signup.tsx
undefined
1
import { FC, useEffect } from "react";
2
import { useNavigate } from "react-router-dom";
3
import { useCreateUserWithEmailAndPassword } from "react-firebase-hooks/auth";
4
import { FormProvider, useForm } from "react-hook-form";
5
import { z } from "zod";
6
import { zodResolver } from "@hookform/resolvers/zod";
7
8
import { auth } from "@/lib/firebase";
9
import { SignUpWithEmailAndPasswordSchema } from "@/lib/zod";
10
import { Input, PasswordInput, Button } from "@/components/Form";
11
12
const validEmail = import.meta.env.VITE_VALID_EMAIL_ADRESS;
13
14
const 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
50
export default Signup;

Firestore database

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

undefined
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
}
undefined
1
<!-- firestore.rules -->
2
rules_version = '2';
3
service 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 ファイルを保存するために利用しました。

undefined
1
<!-- storage.rules -->
2
rules_version = '2';
3
service 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 を使用してファイルをダウンロードする

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

おわりに

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