@leisuresaas/react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +14 -0
- package/package.json +41 -0
- package/src/ads-constants.ts +55 -0
- package/src/ads-context.tsx +62 -0
- package/src/ads-hooks.tsx +143 -0
- package/src/ads-http.ts +134 -0
- package/src/ads-ui.tsx +215 -0
- package/src/app-config.ts +82 -0
- package/src/auth-provider.tsx +90 -0
- package/src/auth.ts +175 -0
- package/src/drive-types.ts +49 -0
- package/src/drive.ts +258 -0
- package/src/errors.ts +11 -0
- package/src/gateway-url.ts +6 -0
- package/src/hosted-ui.ts +75 -0
- package/src/index.ts +117 -0
- package/src/oauth.ts +257 -0
- package/src/platform-api-base.ts +63 -0
- package/src/publishable-key.ts +14 -0
- package/src/userinfo.ts +30 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { LeisureSaasHttpError } from "./errors";
|
|
2
|
+
import { applyPublishableKeyHeaders } from "./publishable-key";
|
|
3
|
+
|
|
4
|
+
export type PublicAppConfigRequestContext = {
|
|
5
|
+
gatewayUrl: string;
|
|
6
|
+
publishableKey: string;
|
|
7
|
+
origin: string;
|
|
8
|
+
locale?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type AppConfigResponse = {
|
|
12
|
+
platform: string;
|
|
13
|
+
policy: {
|
|
14
|
+
min_supported_version: string;
|
|
15
|
+
latest_version: string;
|
|
16
|
+
store_url: string;
|
|
17
|
+
release_notes?: string;
|
|
18
|
+
update_message?: string;
|
|
19
|
+
};
|
|
20
|
+
links: {
|
|
21
|
+
support_url: string;
|
|
22
|
+
terms_url: string;
|
|
23
|
+
privacy_url: string;
|
|
24
|
+
};
|
|
25
|
+
supported_locales: string[];
|
|
26
|
+
default_locale: string;
|
|
27
|
+
client?: { app_version?: string; app_build?: string };
|
|
28
|
+
update: { required: boolean; recommended: boolean; reason: string };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function trimSlash(url: string): string {
|
|
32
|
+
return url.replace(/\/$/, "");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Public app config. Web callers use links and locales; store update fields are for apps. */
|
|
36
|
+
export async function getPublicAppConfig(
|
|
37
|
+
ctx: PublicAppConfigRequestContext,
|
|
38
|
+
input: { appVersion?: string; locale?: string; fresh?: boolean } = {},
|
|
39
|
+
): Promise<AppConfigResponse> {
|
|
40
|
+
const headers: Record<string, string> = {
|
|
41
|
+
Accept: "application/json",
|
|
42
|
+
"X-Client-Platform": "web",
|
|
43
|
+
"X-App-Version": (input.appVersion ?? "0.0.0").trim(),
|
|
44
|
+
};
|
|
45
|
+
applyPublishableKeyHeaders(headers, ctx.publishableKey);
|
|
46
|
+
const locale = input.locale?.trim() || ctx.locale?.trim();
|
|
47
|
+
if (locale) {
|
|
48
|
+
headers["Accept-Language"] = locale;
|
|
49
|
+
}
|
|
50
|
+
if (ctx.origin.trim()) {
|
|
51
|
+
headers.Origin = ctx.origin.trim();
|
|
52
|
+
}
|
|
53
|
+
if (input.fresh) {
|
|
54
|
+
headers["Cache-Control"] = "no-cache";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let url = `${trimSlash(ctx.gatewayUrl)}/v1/public/app-config/`;
|
|
58
|
+
if (input.fresh) {
|
|
59
|
+
url += "?fresh=1";
|
|
60
|
+
}
|
|
61
|
+
const res = await fetch(url, { method: "GET", headers });
|
|
62
|
+
const text = await res.text();
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
throw new LeisureSaasHttpError(res.status, text.trim());
|
|
65
|
+
}
|
|
66
|
+
const body = JSON.parse(text) as AppConfigResponse;
|
|
67
|
+
const links = body.links ?? { support_url: "", terms_url: "", privacy_url: "" };
|
|
68
|
+
const supported = Array.isArray(body.supported_locales)
|
|
69
|
+
? body.supported_locales.map((x) => String(x ?? "").trim()).filter(Boolean)
|
|
70
|
+
: [];
|
|
71
|
+
const defaultLocale = String(body.default_locale ?? "").trim() || "en";
|
|
72
|
+
return {
|
|
73
|
+
...body,
|
|
74
|
+
links: {
|
|
75
|
+
support_url: String(links.support_url ?? "").trim(),
|
|
76
|
+
terms_url: String(links.terms_url ?? "").trim(),
|
|
77
|
+
privacy_url: String(links.privacy_url ?? "").trim(),
|
|
78
|
+
},
|
|
79
|
+
supported_locales: supported.length > 0 ? supported : ["en"],
|
|
80
|
+
default_locale: defaultLocale,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
loginProductAccount,
|
|
7
|
+
logoutProductAccount,
|
|
8
|
+
refreshProductSession,
|
|
9
|
+
registerProductAccount,
|
|
10
|
+
type ProductAuthTokens,
|
|
11
|
+
type PublicAuthContext,
|
|
12
|
+
} from "./auth";
|
|
13
|
+
import { accessNeedsRefresh } from "./oauth";
|
|
14
|
+
|
|
15
|
+
const STORAGE_KEY = "leisuresaas_product_session";
|
|
16
|
+
|
|
17
|
+
type ProductAuthState = {
|
|
18
|
+
tokens: ProductAuthTokens | null;
|
|
19
|
+
ready: boolean;
|
|
20
|
+
login: (input: { email: string; password: string }) => Promise<void>;
|
|
21
|
+
register: (input: { email: string; password: string; name?: string }) => Promise<{ verificationRequired: boolean }>;
|
|
22
|
+
logout: () => Promise<void>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const ProductAuthContext = createContext<ProductAuthState | null>(null);
|
|
26
|
+
|
|
27
|
+
function readStored(): ProductAuthTokens | null {
|
|
28
|
+
if (typeof sessionStorage === "undefined") return null;
|
|
29
|
+
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
30
|
+
if (!raw) return null;
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(raw) as ProductAuthTokens;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function ProductAuthProvider({ context, children }: { context: PublicAuthContext; children: ReactNode }) {
|
|
39
|
+
const [tokens, setTokens] = useState<ProductAuthTokens | null>(null);
|
|
40
|
+
const [ready, setReady] = useState(false);
|
|
41
|
+
|
|
42
|
+
const persist = useCallback((next: ProductAuthTokens | null) => {
|
|
43
|
+
setTokens(next);
|
|
44
|
+
if (typeof sessionStorage === "undefined") return;
|
|
45
|
+
if (!next) sessionStorage.removeItem(STORAGE_KEY);
|
|
46
|
+
else sessionStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const stored = readStored();
|
|
51
|
+
if (!stored?.accessToken) {
|
|
52
|
+
setReady(true);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!accessNeedsRefresh(stored.accessToken) || !stored.refreshToken) {
|
|
56
|
+
setTokens(stored);
|
|
57
|
+
setReady(true);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
void refreshProductSession(context, stored.refreshToken)
|
|
61
|
+
.then((next) => persist(next))
|
|
62
|
+
.catch(() => persist(null))
|
|
63
|
+
.finally(() => setReady(true));
|
|
64
|
+
}, [context, persist]);
|
|
65
|
+
|
|
66
|
+
const value = useMemo<ProductAuthState>(() => ({
|
|
67
|
+
tokens,
|
|
68
|
+
ready,
|
|
69
|
+
login: async (input) => {
|
|
70
|
+
persist(await loginProductAccount(context, input));
|
|
71
|
+
},
|
|
72
|
+
register: async (input) => registerProductAccount(context, input),
|
|
73
|
+
logout: async () => {
|
|
74
|
+
if (tokens?.accessToken) {
|
|
75
|
+
await logoutProductAccount(context, tokens.accessToken, tokens.refreshToken).catch(() => undefined);
|
|
76
|
+
}
|
|
77
|
+
persist(null);
|
|
78
|
+
},
|
|
79
|
+
}), [tokens, ready, context, persist]);
|
|
80
|
+
|
|
81
|
+
return <ProductAuthContext.Provider value={value}>{children}</ProductAuthContext.Provider>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function useProductAuth(): ProductAuthState {
|
|
85
|
+
const ctx = useContext(ProductAuthContext);
|
|
86
|
+
if (!ctx) {
|
|
87
|
+
throw new Error("useProductAuth must be used within ProductAuthProvider");
|
|
88
|
+
}
|
|
89
|
+
return ctx;
|
|
90
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { LeisureSaasHttpError } from "./errors";
|
|
2
|
+
import { applyPublishableKeyHeaders } from "./publishable-key";
|
|
3
|
+
|
|
4
|
+
/** Browser product-account calls. Platform is always web; pass the site origin. */
|
|
5
|
+
export type PublicAuthContext = {
|
|
6
|
+
gatewayUrl: string;
|
|
7
|
+
publishableKey: string;
|
|
8
|
+
origin: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ProductAuthTokens = {
|
|
12
|
+
accessToken: string;
|
|
13
|
+
refreshToken: string;
|
|
14
|
+
accessExpiresAtUnix: number;
|
|
15
|
+
refreshExpiresAtUnix: number;
|
|
16
|
+
userId: string;
|
|
17
|
+
tenantId: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type TokenJSON = {
|
|
21
|
+
access_token?: string;
|
|
22
|
+
refresh_token?: string;
|
|
23
|
+
access_expires_at_unix?: number;
|
|
24
|
+
refresh_expires_at_unix?: number;
|
|
25
|
+
user_id?: string;
|
|
26
|
+
tenant_id?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function trimSlash(url: string): string {
|
|
30
|
+
return url.replace(/\/$/, "");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function authHeaders(ctx: PublicAuthContext, accessToken?: string): Record<string, string> {
|
|
34
|
+
const headers: Record<string, string> = {
|
|
35
|
+
Accept: "application/json",
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
"X-Client-Platform": "web",
|
|
38
|
+
};
|
|
39
|
+
applyPublishableKeyHeaders(headers, ctx.publishableKey);
|
|
40
|
+
if (ctx.origin.trim()) {
|
|
41
|
+
headers.Origin = ctx.origin.trim();
|
|
42
|
+
}
|
|
43
|
+
if (accessToken?.trim()) {
|
|
44
|
+
headers.Authorization = `Bearer ${accessToken.trim()}`;
|
|
45
|
+
}
|
|
46
|
+
return headers;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function publicAuthRequest<T>(
|
|
50
|
+
ctx: PublicAuthContext,
|
|
51
|
+
path: string,
|
|
52
|
+
body?: unknown,
|
|
53
|
+
accessToken?: string,
|
|
54
|
+
): Promise<T> {
|
|
55
|
+
const url = `${trimSlash(ctx.gatewayUrl)}/v1/public/auth${path}`;
|
|
56
|
+
const res = await fetch(url, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: authHeaders(ctx, accessToken),
|
|
59
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
60
|
+
});
|
|
61
|
+
const text = await res.text();
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
throw new LeisureSaasHttpError(res.status, text.trim());
|
|
64
|
+
}
|
|
65
|
+
if (!text) {
|
|
66
|
+
return {} as T;
|
|
67
|
+
}
|
|
68
|
+
return JSON.parse(text) as T;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function tokensFromJSON(data: TokenJSON): ProductAuthTokens {
|
|
72
|
+
const accessToken = data.access_token?.trim() ?? "";
|
|
73
|
+
if (!accessToken) {
|
|
74
|
+
throw new LeisureSaasHttpError(502, "missing access_token");
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
accessToken,
|
|
78
|
+
refreshToken: data.refresh_token?.trim() ?? "",
|
|
79
|
+
accessExpiresAtUnix: data.access_expires_at_unix ?? 0,
|
|
80
|
+
refreshExpiresAtUnix: data.refresh_expires_at_unix ?? 0,
|
|
81
|
+
userId: data.user_id?.trim() ?? "",
|
|
82
|
+
tenantId: data.tenant_id?.trim() ?? "",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function registerProductAccount(
|
|
87
|
+
ctx: PublicAuthContext,
|
|
88
|
+
input: { email: string; password: string; name?: string; locale?: string },
|
|
89
|
+
): Promise<{ verificationRequired: boolean }> {
|
|
90
|
+
const resp = await publicAuthRequest<{ verification_required?: boolean }>(ctx, "/register", {
|
|
91
|
+
email: input.email, password: input.password, name: input.name ?? "", locale: input.locale ?? "",
|
|
92
|
+
});
|
|
93
|
+
return { verificationRequired: resp.verification_required !== false };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function loginProductAccount(
|
|
97
|
+
ctx: PublicAuthContext,
|
|
98
|
+
input: { email: string; password: string; locale?: string },
|
|
99
|
+
): Promise<ProductAuthTokens> {
|
|
100
|
+
const resp = await publicAuthRequest<TokenJSON>(ctx, "/login", {
|
|
101
|
+
email: input.email, password: input.password, locale: input.locale ?? "",
|
|
102
|
+
});
|
|
103
|
+
return tokensFromJSON(resp);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function refreshProductSession(ctx: PublicAuthContext, refreshToken: string): Promise<ProductAuthTokens> {
|
|
107
|
+
const resp = await publicAuthRequest<TokenJSON>(ctx, "/refresh", { refresh_token: refreshToken });
|
|
108
|
+
return tokensFromJSON(resp);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function requestProductPasswordReset(
|
|
112
|
+
ctx: PublicAuthContext,
|
|
113
|
+
input: { email: string; locale?: string },
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
await publicAuthRequest(ctx, "/password-reset", { email: input.email, locale: input.locale ?? "" });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function requestProductEmailVerification(
|
|
119
|
+
ctx: PublicAuthContext,
|
|
120
|
+
input: { email: string; locale?: string },
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
await publicAuthRequest(ctx, "/email-verification", { email: input.email, locale: input.locale ?? "" });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function changeProductPassword(
|
|
126
|
+
ctx: PublicAuthContext,
|
|
127
|
+
accessToken: string,
|
|
128
|
+
input: { currentPassword: string; newPassword: string },
|
|
129
|
+
): Promise<void> {
|
|
130
|
+
await publicAuthRequest(ctx, "/password", {
|
|
131
|
+
current_password: input.currentPassword, new_password: input.newPassword,
|
|
132
|
+
}, accessToken);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function logoutProductAccount(
|
|
136
|
+
ctx: PublicAuthContext,
|
|
137
|
+
accessToken: string,
|
|
138
|
+
refreshToken?: string,
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
await publicAuthRequest(ctx, "/logout", { refresh_token: refreshToken ?? "" }, accessToken);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function deleteProductAccount(ctx: PublicAuthContext, accessToken: string): Promise<void> {
|
|
144
|
+
await publicAuthRequest(ctx, "/account/delete", {}, accessToken);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function startPlatformAuthorize(
|
|
148
|
+
ctx: PublicAuthContext,
|
|
149
|
+
input: { redirectUri: string; codeChallenge: string; state?: string },
|
|
150
|
+
): Promise<string> {
|
|
151
|
+
const resp = await publicAuthRequest<{ authorization_url?: string }>(ctx, "/authorize", {
|
|
152
|
+
redirect_uri: input.redirectUri,
|
|
153
|
+
code_challenge: input.codeChallenge,
|
|
154
|
+
state: input.state ?? "",
|
|
155
|
+
});
|
|
156
|
+
const url = resp.authorization_url?.trim() ?? "";
|
|
157
|
+
if (!url) {
|
|
158
|
+
throw new LeisureSaasHttpError(502, "missing authorization_url");
|
|
159
|
+
}
|
|
160
|
+
return url;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function bindPlatformAccount(
|
|
164
|
+
ctx: PublicAuthContext,
|
|
165
|
+
accessToken: string,
|
|
166
|
+
input: { code: string; codeVerifier: string; redirectUri: string },
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
await publicAuthRequest(ctx, "/bind", {
|
|
169
|
+
code: input.code, code_verifier: input.codeVerifier, redirect_uri: input.redirectUri,
|
|
170
|
+
}, accessToken);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function unbindPlatformAccount(ctx: PublicAuthContext, accessToken: string): Promise<void> {
|
|
174
|
+
await publicAuthRequest(ctx, "/unbind", {}, accessToken);
|
|
175
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type FsNode = {
|
|
2
|
+
id: string;
|
|
3
|
+
product_id: string;
|
|
4
|
+
tenant_id: string;
|
|
5
|
+
owner_subject: string;
|
|
6
|
+
parent_id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
kind: "folder" | "file" | string;
|
|
9
|
+
object_id: string;
|
|
10
|
+
content_type: string;
|
|
11
|
+
size: number;
|
|
12
|
+
status: "uploading" | "active" | string;
|
|
13
|
+
created_at_unix: number;
|
|
14
|
+
updated_at_unix: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type FsUploadSessionResult = {
|
|
18
|
+
node: FsNode;
|
|
19
|
+
upload_url: string;
|
|
20
|
+
upload_headers?: Record<string, string>;
|
|
21
|
+
expires_unix: number;
|
|
22
|
+
expires_at: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type FsContentURLResult = {
|
|
26
|
+
signed_url: string;
|
|
27
|
+
expires_unix: number;
|
|
28
|
+
expires_at: string;
|
|
29
|
+
intent: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type FsContentURLIntent = "preview" | "download" | "";
|
|
33
|
+
|
|
34
|
+
export type UploadProgress = {
|
|
35
|
+
loaded: number;
|
|
36
|
+
total: number;
|
|
37
|
+
ratio: number;
|
|
38
|
+
phase?: "put" | "storing" | "finishing" | "done";
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type FsStorageUsage = {
|
|
42
|
+
product_id: string;
|
|
43
|
+
tenant_id: string;
|
|
44
|
+
metric_key: string;
|
|
45
|
+
bytes_used: number;
|
|
46
|
+
objects_used: number;
|
|
47
|
+
bytes_limit: number;
|
|
48
|
+
bytes_remaining: number | null;
|
|
49
|
+
};
|
package/src/drive.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { LeisureSaasHttpError } from "./errors";
|
|
2
|
+
import { applyPublishableKeyHeaders } from "./publishable-key";
|
|
3
|
+
import type {
|
|
4
|
+
FsContentURLIntent,
|
|
5
|
+
FsContentURLResult,
|
|
6
|
+
FsNode,
|
|
7
|
+
FsStorageUsage,
|
|
8
|
+
FsUploadSessionResult,
|
|
9
|
+
UploadProgress,
|
|
10
|
+
} from "./drive-types";
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
FsContentURLIntent,
|
|
14
|
+
FsContentURLResult,
|
|
15
|
+
FsNode,
|
|
16
|
+
FsStorageUsage,
|
|
17
|
+
FsUploadSessionResult,
|
|
18
|
+
UploadProgress,
|
|
19
|
+
} from "./drive-types";
|
|
20
|
+
|
|
21
|
+
export const DRIVE_UPLOAD_MAX_AUTO_PUT_BYTES = 20 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
export type DriveRequestContext = {
|
|
24
|
+
gatewayUrl: string;
|
|
25
|
+
publishableKey: string;
|
|
26
|
+
accessToken: string;
|
|
27
|
+
origin: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type UploadProgressHandler = (progress: UploadProgress) => void;
|
|
31
|
+
|
|
32
|
+
function trimSlash(url: string): string {
|
|
33
|
+
return url.replace(/\/$/, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function driveHeaders(ctx: DriveRequestContext): Record<string, string> {
|
|
37
|
+
const token = ctx.accessToken.trim();
|
|
38
|
+
if (!token) {
|
|
39
|
+
throw new LeisureSaasHttpError(401, "accessToken is required for Drive User Plane");
|
|
40
|
+
}
|
|
41
|
+
const headers: Record<string, string> = {
|
|
42
|
+
Accept: "application/json",
|
|
43
|
+
Authorization: `Bearer ${token}`,
|
|
44
|
+
"X-Client-Platform": "web",
|
|
45
|
+
};
|
|
46
|
+
applyPublishableKeyHeaders(headers, ctx.publishableKey);
|
|
47
|
+
if (ctx.origin.trim()) {
|
|
48
|
+
headers.Origin = ctx.origin.trim();
|
|
49
|
+
}
|
|
50
|
+
return headers;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function driveRequest<T>(
|
|
54
|
+
ctx: DriveRequestContext,
|
|
55
|
+
method: "GET" | "POST" | "PATCH" | "DELETE",
|
|
56
|
+
path: string,
|
|
57
|
+
body?: unknown,
|
|
58
|
+
): Promise<T> {
|
|
59
|
+
const base = `${trimSlash(ctx.gatewayUrl)}/v1/user/storage`;
|
|
60
|
+
const url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
|
61
|
+
const headers = driveHeaders(ctx);
|
|
62
|
+
let payload: string | undefined;
|
|
63
|
+
if (body !== undefined) {
|
|
64
|
+
headers["Content-Type"] = "application/json";
|
|
65
|
+
payload = JSON.stringify(body);
|
|
66
|
+
}
|
|
67
|
+
const res = await fetch(url, { method, headers, body: payload });
|
|
68
|
+
const text = await res.text();
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
throw new LeisureSaasHttpError(res.status, text.trim());
|
|
71
|
+
}
|
|
72
|
+
if (res.status === 204 || !text) {
|
|
73
|
+
return {} as T;
|
|
74
|
+
}
|
|
75
|
+
return JSON.parse(text) as T;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function listFsNodes(ctx: DriveRequestContext, parentId = ""): Promise<FsNode[]> {
|
|
79
|
+
const q = parentId.trim() ? `?parent_id=${encodeURIComponent(parentId.trim())}` : "";
|
|
80
|
+
const resp = await driveRequest<{ nodes?: FsNode[] }>(ctx, "GET", `/fs/nodes${q}`);
|
|
81
|
+
return resp.nodes ?? [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function getFsStorageUsage(ctx: DriveRequestContext): Promise<FsStorageUsage> {
|
|
85
|
+
return driveRequest<FsStorageUsage>(ctx, "GET", "/usage");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function getFsNode(ctx: DriveRequestContext, id: string): Promise<FsNode> {
|
|
89
|
+
const nodeId = id.trim();
|
|
90
|
+
if (!nodeId) throw new LeisureSaasHttpError(400, "id is required");
|
|
91
|
+
return driveRequest<FsNode>(ctx, "GET", `/fs/nodes/${encodeURIComponent(nodeId)}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function createFsFolder(
|
|
95
|
+
ctx: DriveRequestContext,
|
|
96
|
+
input: { name: string; parentId?: string },
|
|
97
|
+
): Promise<FsNode> {
|
|
98
|
+
const body: Record<string, string> = { name: input.name };
|
|
99
|
+
if (input.parentId?.trim()) body.parent_id = input.parentId.trim();
|
|
100
|
+
return driveRequest<FsNode>(ctx, "POST", "/fs/folders", body);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function renameFsNode(ctx: DriveRequestContext, id: string, name: string): Promise<FsNode> {
|
|
104
|
+
const nodeId = id.trim();
|
|
105
|
+
if (!nodeId) throw new LeisureSaasHttpError(400, "id is required");
|
|
106
|
+
return driveRequest<FsNode>(ctx, "PATCH", `/fs/nodes/${encodeURIComponent(nodeId)}`, { name });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function moveFsNode(ctx: DriveRequestContext, id: string, parentId = ""): Promise<FsNode> {
|
|
110
|
+
const nodeId = id.trim();
|
|
111
|
+
if (!nodeId) throw new LeisureSaasHttpError(400, "id is required");
|
|
112
|
+
const body: Record<string, string> = {};
|
|
113
|
+
if (parentId.trim()) body.parent_id = parentId.trim();
|
|
114
|
+
return driveRequest<FsNode>(ctx, "POST", `/fs/nodes/${encodeURIComponent(nodeId)}/move`, body);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function deleteFsNode(ctx: DriveRequestContext, id: string, recursive = false): Promise<void> {
|
|
118
|
+
const nodeId = id.trim();
|
|
119
|
+
if (!nodeId) throw new LeisureSaasHttpError(400, "id is required");
|
|
120
|
+
let path = `/fs/nodes/${encodeURIComponent(nodeId)}`;
|
|
121
|
+
if (recursive) path += "?recursive=true";
|
|
122
|
+
await driveRequest(ctx, "DELETE", path);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function createFsUploadSession(
|
|
126
|
+
ctx: DriveRequestContext,
|
|
127
|
+
input: { name: string; contentType: string; size: number; parentId?: string },
|
|
128
|
+
): Promise<FsUploadSessionResult> {
|
|
129
|
+
const body: Record<string, unknown> = {
|
|
130
|
+
name: input.name,
|
|
131
|
+
content_type: input.contentType,
|
|
132
|
+
size: input.size,
|
|
133
|
+
};
|
|
134
|
+
if (input.parentId?.trim()) body.parent_id = input.parentId.trim();
|
|
135
|
+
return driveRequest<FsUploadSessionResult>(ctx, "POST", "/fs/files/upload-session", body);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function finishFsUploadSession(ctx: DriveRequestContext, nodeId: string): Promise<FsNode> {
|
|
139
|
+
const id = nodeId.trim();
|
|
140
|
+
if (!id) throw new LeisureSaasHttpError(400, "node id is required");
|
|
141
|
+
return driveRequest<FsNode>(ctx, "POST", `/fs/files/upload-session/${encodeURIComponent(id)}/finish`, {});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function createFsContentURL(
|
|
145
|
+
ctx: DriveRequestContext,
|
|
146
|
+
id: string,
|
|
147
|
+
input?: { intent?: FsContentURLIntent; ttlSeconds?: number },
|
|
148
|
+
): Promise<FsContentURLResult> {
|
|
149
|
+
const nodeId = id.trim();
|
|
150
|
+
if (!nodeId) throw new LeisureSaasHttpError(400, "id is required");
|
|
151
|
+
const body: Record<string, unknown> = {};
|
|
152
|
+
if (input?.intent) body.intent = input.intent;
|
|
153
|
+
if (input?.ttlSeconds && input.ttlSeconds > 0) body.ttl_seconds = input.ttlSeconds;
|
|
154
|
+
return driveRequest<FsContentURLResult>(ctx, "POST", `/fs/nodes/${encodeURIComponent(nodeId)}/content-url`, body);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function asUploadBody(body: ArrayBuffer | Blob | Uint8Array): BodyInit {
|
|
158
|
+
if (body instanceof Uint8Array) return body as unknown as BodyInit;
|
|
159
|
+
if (typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer) {
|
|
160
|
+
return new Uint8Array(body) as unknown as BodyInit;
|
|
161
|
+
}
|
|
162
|
+
return body as BodyInit;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function putFsUploadURL(
|
|
166
|
+
uploadURL: string,
|
|
167
|
+
uploadHeaders: Record<string, string> | undefined,
|
|
168
|
+
contentType: string,
|
|
169
|
+
body: ArrayBuffer | Blob | Uint8Array,
|
|
170
|
+
opts?: { size?: number; onProgress?: UploadProgressHandler },
|
|
171
|
+
): Promise<void> {
|
|
172
|
+
const headers: Record<string, string> = { ...(uploadHeaders ?? {}) };
|
|
173
|
+
if (!headers["Content-Type"] && !headers["content-type"] && contentType) {
|
|
174
|
+
headers["Content-Type"] = contentType;
|
|
175
|
+
}
|
|
176
|
+
let total = opts?.size ?? 0;
|
|
177
|
+
if (total <= 0) {
|
|
178
|
+
if (typeof Blob !== "undefined" && body instanceof Blob) total = body.size;
|
|
179
|
+
else if (body instanceof Uint8Array) total = body.byteLength;
|
|
180
|
+
else if (typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer) total = body.byteLength;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const report = (loaded: number, phase: UploadProgress["phase"]) => {
|
|
184
|
+
if (!opts?.onProgress) return;
|
|
185
|
+
const t = total > 0 ? total : 0;
|
|
186
|
+
const ratio = t > 0 ? Math.min(1, Math.max(0, loaded / t)) : 0;
|
|
187
|
+
opts.onProgress({ loaded, total: t, ratio, phase });
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (opts?.onProgress && typeof XMLHttpRequest !== "undefined") {
|
|
191
|
+
await new Promise<void>((resolve, reject) => {
|
|
192
|
+
const xhr = new XMLHttpRequest();
|
|
193
|
+
xhr.open("PUT", uploadURL);
|
|
194
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
195
|
+
if (v) xhr.setRequestHeader(k, v);
|
|
196
|
+
}
|
|
197
|
+
xhr.upload.onprogress = (ev) => report(ev.loaded, "put");
|
|
198
|
+
xhr.upload.onload = () => report(total > 0 ? total : 1, "storing");
|
|
199
|
+
xhr.onload = () => {
|
|
200
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
201
|
+
report(total > 0 ? total : 1, "storing");
|
|
202
|
+
resolve();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
reject(new LeisureSaasHttpError(xhr.status, (xhr.responseText || "upload PUT failed").trim()));
|
|
206
|
+
};
|
|
207
|
+
xhr.onerror = () => reject(new LeisureSaasHttpError(0, "upload PUT network error"));
|
|
208
|
+
xhr.onabort = () => reject(new LeisureSaasHttpError(0, "upload PUT aborted"));
|
|
209
|
+
report(0, "put");
|
|
210
|
+
xhr.send(asUploadBody(body) as XMLHttpRequestBodyInit);
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
report(0, "put");
|
|
216
|
+
const res = await fetch(uploadURL, { method: "PUT", headers, body: asUploadBody(body), redirect: "manual" });
|
|
217
|
+
if (res.type === "opaqueredirect" || (res.status >= 300 && res.status < 400)) {
|
|
218
|
+
throw new LeisureSaasHttpError(res.status || 0, "upload PUT redirected");
|
|
219
|
+
}
|
|
220
|
+
if (!res.ok) {
|
|
221
|
+
const text = await res.text().catch(() => "");
|
|
222
|
+
throw new LeisureSaasHttpError(res.status, text.trim() || "upload PUT failed");
|
|
223
|
+
}
|
|
224
|
+
report(total > 0 ? total : 1, "storing");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function uploadFsFile(
|
|
228
|
+
ctx: DriveRequestContext,
|
|
229
|
+
input: {
|
|
230
|
+
name: string;
|
|
231
|
+
contentType: string;
|
|
232
|
+
size: number;
|
|
233
|
+
body: ArrayBuffer | Blob | Uint8Array;
|
|
234
|
+
parentId?: string;
|
|
235
|
+
onProgress?: UploadProgressHandler;
|
|
236
|
+
},
|
|
237
|
+
): Promise<FsNode> {
|
|
238
|
+
if (input.size <= 0) {
|
|
239
|
+
throw new LeisureSaasHttpError(400, "size must be positive");
|
|
240
|
+
}
|
|
241
|
+
if (input.size > DRIVE_UPLOAD_MAX_AUTO_PUT_BYTES) {
|
|
242
|
+
throw new LeisureSaasHttpError(400, `body exceeds max auto-PUT size (${DRIVE_UPLOAD_MAX_AUTO_PUT_BYTES} bytes)`);
|
|
243
|
+
}
|
|
244
|
+
const sess = await createFsUploadSession(ctx, {
|
|
245
|
+
name: input.name,
|
|
246
|
+
contentType: input.contentType,
|
|
247
|
+
size: input.size,
|
|
248
|
+
parentId: input.parentId,
|
|
249
|
+
});
|
|
250
|
+
await putFsUploadURL(sess.upload_url, sess.upload_headers, input.contentType, input.body, {
|
|
251
|
+
size: input.size,
|
|
252
|
+
onProgress: input.onProgress,
|
|
253
|
+
});
|
|
254
|
+
input.onProgress?.({ loaded: input.size, total: input.size, ratio: 1, phase: "finishing" });
|
|
255
|
+
const node = await finishFsUploadSession(ctx, sess.node.id);
|
|
256
|
+
input.onProgress?.({ loaded: input.size, total: input.size, ratio: 1, phase: "done" });
|
|
257
|
+
return node;
|
|
258
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export class LeisureSaasHttpError extends Error {
|
|
2
|
+
readonly statusCode: number;
|
|
3
|
+
readonly body: string;
|
|
4
|
+
|
|
5
|
+
constructor(statusCode: number, body: string) {
|
|
6
|
+
super(body || `HTTP ${statusCode}`);
|
|
7
|
+
this.name = "LeisureSaasHttpError";
|
|
8
|
+
this.statusCode = statusCode;
|
|
9
|
+
this.body = body;
|
|
10
|
+
}
|
|
11
|
+
}
|