Skip to content

Commit 3cc9f1c

Browse files
authored
Merge pull request #119 from takker99/upload-gcs
✨ Implement an wrapper of /api/gcs
2 parents e35436b + 7b51dea commit 3cc9f1c

File tree

5 files changed

+173
-2
lines changed

5 files changed

+173
-2
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
- uses: actions/checkout@v2
1010
- uses: denoland/setup-deno@v1
1111
with:
12-
deno-version: "1.28.1"
12+
deno-version: "1.32.3"
1313
- name: Check fmt
1414
run: deno fmt --check
1515
- name: Run lint

.github/workflows/udd.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ jobs:
1212
- uses: actions/checkout@v2
1313
- uses: denoland/setup-deno@v1
1414
with:
15-
deno-version: "1.28.1"
15+
deno-version: "1.32.3"
1616
- name: Update dependencies
1717
run: >
1818
deno run --allow-net --allow-read --allow-write=deps/

deps/hash.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { Md5 } from "https://deno.land/[email protected]/hash/md5.ts#=";

rest/mod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ export * from "./auth.ts";
1414
export * from "./util.ts";
1515
export * from "./error.ts";
1616
export * from "./getCodeBlocks.ts";
17+
export * from "./uploadToGCS.ts";

rest/uploadToGCS.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { cookie, getCSRFToken } from "./auth.ts";
2+
import { BaseOptions, ExtendedOptions, Result, setDefaults } from "./util.ts";
3+
import { makeError, UnexpectedResponseError } from "./error.ts";
4+
import type { ErrorLike, NotFoundError } from "../deps/scrapbox-rest.ts";
5+
import { Md5 } from "../deps/hash.ts";
6+
7+
/** uploadしたファイルのメタデータ */
8+
export interface GCSFile {
9+
/** uploadしたファイルのURL */
10+
embedUrl: string;
11+
12+
/** uploadしたファイルの名前 */
13+
originalName: string;
14+
}
15+
16+
/** 任意のファイルをscrapbox.ioにuploadする
17+
*
18+
* @param file uploadしたいファイル
19+
* @param projectId upload先projectのid
20+
* @return 成功したら、ファイルのクラウド上のURLなどが返ってくる
21+
*/
22+
export const uploadToGCS = async (
23+
file: File,
24+
projectId: string,
25+
options?: ExtendedOptions,
26+
): Promise<
27+
Result<GCSFile, GCSError | NotFoundError | FileCapacityError | ErrorLike>
28+
> => {
29+
const md5 = new Md5().update(await file.arrayBuffer()).toString();
30+
const res = await uploadRequest(file, projectId, md5, options);
31+
if (!res.ok) return res;
32+
if ("embedUrl" in res.value) return { ok: true, value: res.value };
33+
const res2 = await upload(res.value.signedUrl, file, options);
34+
if (!res2.ok) return res2;
35+
return verify(projectId, res.value.fileId, md5, options);
36+
};
37+
38+
/** 容量を使い切ったときに発生するerror */
39+
export interface FileCapacityError extends ErrorLike {
40+
name: "FileCapacityError";
41+
}
42+
43+
interface UploadRequest {
44+
/** upload先URL */
45+
signedUrl: string;
46+
47+
/** uploadしたファイルに紐付けられる予定のfile id */
48+
fileId: string;
49+
}
50+
51+
/** ファイルのuploadを要求する
52+
*
53+
* @param file uploadしたいファイル
54+
* @param projectId upload先projectのid
55+
* @param md5 uploadしたいファイルのMD5 hash (16進数)
56+
* @return すでにuploadされていればファイルのURLを、まだの場合はupload先URLを返す
57+
*/
58+
const uploadRequest = async (
59+
file: File,
60+
projectId: string,
61+
md5: string,
62+
init?: ExtendedOptions,
63+
): Promise<Result<GCSFile | UploadRequest, FileCapacityError | ErrorLike>> => {
64+
const { sid, hostName, fetch, csrf } = setDefaults(init ?? {});
65+
const body = {
66+
md5,
67+
size: file.size,
68+
contentType: file.type,
69+
name: file.name,
70+
};
71+
const token = csrf ?? await getCSRFToken();
72+
const req = new Request(
73+
`https://${hostName}/api/gcs/${projectId}/upload-request`,
74+
{
75+
method: "POST",
76+
body: JSON.stringify(body),
77+
headers: {
78+
"Content-Type": "application/json;charset=utf-8",
79+
"X-CSRF-TOKEN": token,
80+
...(sid ? { Cookie: cookie(sid) } : {}),
81+
},
82+
},
83+
);
84+
const res = await fetch(req);
85+
if (!res.ok) {
86+
return makeError(res);
87+
}
88+
return { ok: true, value: await res.json() };
89+
};
90+
91+
/** Google Cloud Storage XML APIのerror
92+
*
93+
* `message`には[この形式](https://cloud.google.com/storage/docs/xml-api/reference-status#http-status-and-error-codes)のXMLが入る
94+
*/
95+
export interface GCSError extends ErrorLike {
96+
name: "GCSError";
97+
}
98+
99+
/** ファイルをuploadする */
100+
const upload = async (
101+
signedUrl: string,
102+
file: File,
103+
init?: BaseOptions,
104+
): Promise<Result<undefined, GCSError>> => {
105+
const { sid, fetch } = setDefaults(init ?? {});
106+
const res = await fetch(
107+
signedUrl,
108+
{
109+
method: "PUT",
110+
body: file,
111+
headers: {
112+
"Content-Type": file.type,
113+
...(sid ? { Cookie: cookie(sid) } : {}),
114+
},
115+
},
116+
);
117+
if (!res.ok) {
118+
if (res.headers.get("Content-Type")?.includes?.("/xml")) {
119+
return {
120+
ok: false,
121+
value: {
122+
name: "GCSError",
123+
message: await res.text(),
124+
},
125+
};
126+
}
127+
throw new UnexpectedResponseError(res);
128+
}
129+
return { ok: true, value: undefined };
130+
};
131+
132+
/** uploadしたファイルの整合性を確認する */
133+
const verify = async (
134+
projectId: string,
135+
fileId: string,
136+
md5: string,
137+
init?: ExtendedOptions,
138+
): Promise<Result<GCSFile, NotFoundError>> => {
139+
const { sid, hostName, fetch, csrf } = setDefaults(init ?? {});
140+
const token = csrf ?? await getCSRFToken();
141+
const req = new Request(
142+
`https://${hostName}/api/gcs/${projectId}/verify`,
143+
{
144+
method: "POST",
145+
body: JSON.stringify({ md5, fileId }),
146+
headers: {
147+
"Content-Type": "application/json;charset=utf-8",
148+
"X-CSRF-TOKEN": token,
149+
...(sid ? { Cookie: cookie(sid) } : {}),
150+
},
151+
},
152+
);
153+
const res = await fetch(req);
154+
if (!res.ok) {
155+
try {
156+
if (res.status === 404) {
157+
return {
158+
ok: false,
159+
value: { name: "NotFoundError", message: (await res.json()).message },
160+
};
161+
}
162+
} catch (_) {
163+
throw new UnexpectedResponseError(res);
164+
}
165+
throw new UnexpectedResponseError(res);
166+
}
167+
const gcs = await res.json();
168+
return { ok: true, value: gcs };
169+
};

0 commit comments

Comments
 (0)