Skip to content

Commit a9a3be7

Browse files
authored
Merge pull request #81 from ut-code/protect-api-with-password
API をパスワード保護
2 parents ae0aa0d + 9070683 commit a9a3be7

14 files changed

Lines changed: 327 additions & 79 deletions

File tree

packages/server/.env.sample

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
WEB_ORIGIN=http://localhost:8080
2+
DATABASE_URL=postgresql://user:password@host:5432/db
3+
API_PASSWORD=password

packages/server/src/main.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1-
import express from "express";
1+
import express, { RequestHandler } from "express";
22
import cors from "cors";
33
import { PrismaClient } from "@prisma/client";
44

5+
const { WEB_ORIGIN, API_PASSWORD } = process.env;
6+
57
const app = express();
68
const client = new PrismaClient();
79

8-
app.use(cors({ origin: process.env["WEB_ORIGIN"] }));
10+
app.use(cors({ origin: WEB_ORIGIN }));
911
app.use(express.json());
1012

13+
const requirePassword: RequestHandler = (request, response, next) => {
14+
if (request.headers.authorization === API_PASSWORD) next();
15+
else response.sendStatus(401).send();
16+
};
17+
1118
// 通信テスト
1219

1320
app.get("/", (_, res) => {
@@ -28,7 +35,7 @@ type UserResponse = {
2835
rank: number | undefined;
2936
};
3037

31-
app.post("/user", async (request, response) => {
38+
app.post("/user", requirePassword, async (request, response) => {
3239
const requestBody: PostUserRequest = request.body;
3340
try {
3441
await client.user.create({
@@ -95,7 +102,7 @@ type PutUserRequest = {
95102
name: string;
96103
};
97104

98-
app.put("/user/:userId([0-9]+)", async (request, response) => {
105+
app.put("/user/:userId([0-9]+)", requirePassword, async (request, response) => {
99106
const requestParams: PutUserParams = {
100107
userId: Number(request.params["userId"]),
101108
};
@@ -148,16 +155,22 @@ type PutProgramRequest = {
148155
program: string;
149156
};
150157

151-
app.put("/program", async (request, response) => {
158+
app.put("/program", requirePassword, async (request, response) => {
152159
const requestBody: PutProgramRequest = request.body;
153160
const participantNumber = await client.userBattleIdentity.count();
154-
await client.userBattleIdentity.create({
155-
data: {
161+
await client.userBattleIdentity.upsert({
162+
create: {
156163
userId: requestBody.userId,
157164
program: requestBody.program,
158165
league: Math.floor((participantNumber + 5) / 4), // 作成された順にリーグ番号が割り振られる
159166
rank: requestBody.userId, // ひとまずidと同じ番号を挿入
160167
},
168+
update: {
169+
program: requestBody.program,
170+
},
171+
where: {
172+
userId: requestBody.userId,
173+
},
161174
});
162175
response.send();
163176
});
@@ -169,7 +182,7 @@ type PostSwapRankRequest = {
169182
userId2: number;
170183
};
171184

172-
app.post("/swap-rank", async (request, response) => {
185+
app.post("/swap-rank", requirePassword, async (request, response) => {
173186
const requestBody: PostSwapRankRequest = request.body;
174187
const user1 = await client.userBattleIdentity.findUnique({
175188
where: { userId: requestBody.userId1 },
@@ -207,4 +220,14 @@ app.post("/swap-rank", async (request, response) => {
207220
response.send();
208221
});
209222

223+
type PostCheckPasswordRequest = {
224+
password: string;
225+
};
226+
227+
app.post("/check-password", (request, response) => {
228+
const requestBody: PostCheckPasswordRequest = request.body;
229+
if (requestBody.password === API_PASSWORD) response.sendStatus(200);
230+
else response.sendStatus(401);
231+
});
232+
210233
app.listen(8081);

packages/web/src/App.tsx

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { useEffect, useRef, useState } from "react";
2-
import Blockly from "blockly";
3-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
4-
// @ts-ignore
5-
import Ja from "blockly/msg/ja";
2+
import "./common/blockly";
3+
import type { WorkspaceSvg } from "blockly";
64
import "./style.css";
75
import { Box } from "@mui/material";
86
import Injection from "./component/Injection";
@@ -12,16 +10,8 @@ import Welcome from "./component/Welcome";
1210
import ButtonAppBar from "./component/ButtonAppBar";
1311
import { getUsers } from "./fetchAPI";
1412
import type { User } from "./component/Emulator";
15-
16-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
17-
// @ts-ignore
18-
Blockly.setLocale(Ja);
19-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
20-
// @ts-ignore
21-
Blockly.HSV_SATURATION = 0.6;
22-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
23-
// @ts-ignore
24-
Blockly.HSV_VALUE = 1;
13+
import { useApiPasswordContext } from "./common/api-password";
14+
import ApiPasswordDialog from "./component/ApiPasswordDialog";
2515

2616
export default function App() {
2717
const [currentUser, setCurrentUser] = useState({
@@ -31,7 +21,9 @@ export default function App() {
3121
rank: 0,
3222
});
3323
const [users, setUsers] = useState<User[]>([]);
34-
const workspaceRef = useRef<Blockly.WorkspaceSvg>();
24+
const { password } = useApiPasswordContext();
25+
const [isApiPasswordDialogOpen, setIsApiPasswordDialogOpen] = useState(false);
26+
const workspaceRef = useRef<WorkspaceSvg>();
3527

3628
useEffect(() => {
3729
async function fetchUsers() {
@@ -50,21 +42,36 @@ export default function App() {
5042
gridTemplateRows: "48px auto",
5143
}}
5244
>
53-
<ButtonAppBar />
45+
<ButtonAppBar
46+
openApiPasswordDialog={() => {
47+
setIsApiPasswordDialogOpen(true);
48+
}}
49+
/>
5450
<Injection workspaceRef={workspaceRef} />
5551
</Box>
56-
<Welcome users={users} setCurrentUser={setCurrentUser} />
57-
<Arena
58-
currentUser={currentUser}
59-
setCurrentUser={setCurrentUser}
60-
workspaceRef={workspaceRef}
61-
users={users}
62-
/>
52+
{password && (
53+
<>
54+
<Welcome users={users} setCurrentUser={setCurrentUser} />
55+
<Arena
56+
currentUser={currentUser}
57+
setCurrentUser={setCurrentUser}
58+
workspaceRef={workspaceRef}
59+
users={users}
60+
/>
61+
</>
62+
)}
6363
<TestPlay
6464
currentUser={currentUser}
6565
setCurrentUser={setCurrentUser}
6666
workspaceRef={workspaceRef}
6767
/>
68+
{!password && isApiPasswordDialogOpen && (
69+
<ApiPasswordDialog
70+
onClose={() => {
71+
setIsApiPasswordDialogOpen(false);
72+
}}
73+
/>
74+
)}
6875
</>
6976
);
7077
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {
2+
createContext,
3+
ReactElement,
4+
useContext,
5+
useMemo,
6+
useState,
7+
} from "react";
8+
9+
const context = createContext<{
10+
password: string | null;
11+
setPassword(password: string): void;
12+
} | null>(null);
13+
14+
const LOCAL_STORAGE_KEY = "utcode_code_vs_code";
15+
16+
export function ApiPasswordContextProvider({
17+
children,
18+
}: {
19+
children: ReactElement;
20+
}) {
21+
const [password, setPassword] = useState<string | null>(
22+
window.localStorage.getItem(LOCAL_STORAGE_KEY)
23+
);
24+
25+
const contextValue = useMemo(
26+
() => ({
27+
password,
28+
setPassword(newPassword: string) {
29+
window.localStorage.setItem(LOCAL_STORAGE_KEY, newPassword);
30+
setPassword(newPassword);
31+
},
32+
}),
33+
[password]
34+
);
35+
36+
return <context.Provider value={contextValue}>{children}</context.Provider>;
37+
}
38+
39+
export function useApiPasswordContext() {
40+
const value = useContext(context);
41+
42+
if (!value) throw new Error("Could not subscribe ApiPasswordContext");
43+
44+
return value;
45+
}

packages/web/src/common/blockly.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Blockly from "blockly";
2+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
3+
// @ts-ignore
4+
import Ja from "blockly/msg/ja";
5+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
6+
// @ts-ignore
7+
Blockly.setLocale(Ja);
8+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
9+
// @ts-ignore
10+
Blockly.HSV_SATURATION = 0.6;
11+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
12+
// @ts-ignore
13+
Blockly.HSV_VALUE = 1;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import {
2+
Button,
3+
Dialog,
4+
DialogActions,
5+
DialogContent,
6+
DialogContentText,
7+
DialogTitle,
8+
TextField,
9+
} from "@mui/material";
10+
import { useEffect, useState } from "react";
11+
import { usePromise } from "react-use";
12+
import { useApiPasswordContext } from "../common/api-password";
13+
import { checkPassword } from "../fetchAPI";
14+
15+
export type ApiPasswordDialogProps = {
16+
onClose(): void;
17+
};
18+
19+
export default function ApiPasswordDialog({ onClose }: ApiPasswordDialogProps) {
20+
const { setPassword } = useApiPasswordContext();
21+
const [inputPassword, setInputPassword] = useState("");
22+
const [isValid, setIsValid] = useState(false);
23+
const mounted = usePromise();
24+
25+
useEffect(() => {
26+
setIsValid(false);
27+
if (!inputPassword) return undefined;
28+
const timerId = setTimeout(async () => {
29+
if (await mounted(checkPassword(inputPassword))) setIsValid(true);
30+
}, 1000);
31+
return () => {
32+
clearTimeout(timerId);
33+
};
34+
}, [inputPassword, mounted]);
35+
36+
return (
37+
<Dialog open onClose={onClose} maxWidth="xs" fullWidth>
38+
<form
39+
onSubmit={() => {
40+
setPassword(inputPassword);
41+
}}
42+
>
43+
<DialogTitle>ロック解除</DialogTitle>
44+
<DialogContent>
45+
<DialogContentText>
46+
オンライン対戦やユーザー登録などの機能はオフライン参加の方に限定しています。会場のスタッフはパスワードを入力してロックを解除してください。
47+
</DialogContentText>
48+
<TextField
49+
sx={{ mt: 2 }}
50+
type="password"
51+
fullWidth
52+
value={inputPassword}
53+
onChange={(e) => {
54+
setInputPassword(e.target.value);
55+
}}
56+
autoFocus
57+
/>
58+
</DialogContent>
59+
<DialogActions>
60+
<Button onClick={onClose}>キャンセル</Button>
61+
<Button type="submit" variant="contained" disabled={!isValid}>
62+
解除
63+
</Button>
64+
</DialogActions>
65+
</form>
66+
</Dialog>
67+
);
68+
}

packages/web/src/component/Arena.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { FaFortAwesome } from "react-icons/fa";
2222
import Draggable from "react-draggable";
2323
import type { User } from "./game";
2424
import { getUser, changeUserName, uploadProgram } from "../fetchAPI";
25+
import { useApiPasswordContext } from "../common/api-password";
2526

2627
interface ChangeNameDialogProps {
2728
currentUser: User;
@@ -45,6 +46,8 @@ function ChangeNameDialog(props: ChangeNameDialogProps) {
4546
name,
4647
setName,
4748
} = props;
49+
const { password } = useApiPasswordContext();
50+
if (!password) throw new Error("Missing password");
4851

4952
const handleClose = async (newName: string) => {
5053
if (newName !== "" && newName.match(/\S/g)) {
@@ -55,7 +58,7 @@ function ChangeNameDialog(props: ChangeNameDialogProps) {
5558
program: currentUser.program,
5659
rank: currentUser.rank,
5760
};
58-
await changeUserName(currentUser.id, newName);
61+
await changeUserName(currentUser.id, newName, password);
5962
setCurrentUser(newCurrentUser);
6063
setOpen(false);
6164
} catch {
@@ -121,6 +124,8 @@ export default function Arena(props: ArenaProps) {
121124
const [open, setOpen] = useState(false);
122125
const [errorMessage, setErrorMessage] = useState(" ");
123126
const [name, setName] = useState("");
127+
const { password } = useApiPasswordContext();
128+
if (!password) throw new Error("Missing password");
124129

125130
const handleChange = async (_: unknown, expanded: boolean) => {
126131
if (expanded) {
@@ -131,10 +136,13 @@ export default function Arena(props: ArenaProps) {
131136
};
132137

133138
const handleUpload = () => {
134-
uploadProgram({
135-
userId: currentUser.id,
136-
program: Blockly.JavaScript.workspaceToCode(workspaceRef.current),
137-
});
139+
uploadProgram(
140+
{
141+
userId: currentUser.id,
142+
program: Blockly.JavaScript.workspaceToCode(workspaceRef.current),
143+
},
144+
password
145+
);
138146
};
139147

140148
const handleClickOpen = () => {

0 commit comments

Comments
 (0)