@naru-pub/sdk 0.0.0-stage → 1.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 +661 -0
- package/README.md +81 -2
- package/dist/functions.d.ts +206 -0
- package/dist/naru.d.ts +349 -0
- package/dist/naru.js +1066 -0
- package/package.json +44 -4
package/dist/naru.js
ADDED
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Naru Data SDK 1.1.0: browser SDK for data owned by one Naru site: 1.0.0's
|
|
3
|
+
* collections, with the site's own tables reached through its queries and
|
|
4
|
+
* mutations, live queries, and its members.
|
|
5
|
+
* @packageDocumentation
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A Naru operation that could not be completed. Check it with `instanceof`.
|
|
10
|
+
*
|
|
11
|
+
* `message` is a short Korean sentence a visitor can read, so a page may show
|
|
12
|
+
* it as is (`status.textContent = error.message`): a query's or mutation's
|
|
13
|
+
* own `ctx.fail` message, or Naru's wording for `code`. `cause` is a network
|
|
14
|
+
* failure's original error. What Naru answered is in the browser's network tab.
|
|
15
|
+
*/
|
|
16
|
+
export class NaruError extends Error {
|
|
17
|
+
constructor(message, code, cause) {
|
|
18
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
19
|
+
this.name = "NaruError";
|
|
20
|
+
this.code = code;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** How a page's visitors read each failure. */
|
|
25
|
+
const WORDS = {
|
|
26
|
+
CONFLICT: "다른 곳에서 먼저 바뀌었습니다. 새로고침한 뒤 다시 해 주세요.",
|
|
27
|
+
QUOTA_EXCEEDED: "사이트의 사용 한도에 도달했습니다.",
|
|
28
|
+
AUTH_REQUIRED: "로그인이 필요합니다. 다시 로그인해 주세요.",
|
|
29
|
+
ACCESS_DENIED: "권한이 없습니다.",
|
|
30
|
+
NOT_FOUND: "찾는 자료가 없습니다.",
|
|
31
|
+
RATE_LIMITED: "요청이 너무 많습니다. 잠시 뒤에 다시 해 주세요.",
|
|
32
|
+
INVALID_REQUEST: "요청을 처리할 수 없습니다.",
|
|
33
|
+
UNAVAILABLE: "나루에 연결하지 못했습니다. 잠시 뒤에 다시 해 주세요.",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** A NaruError worded for visitors (`words`, or the code's). */
|
|
37
|
+
function naruError(code, { words, cause } = {}) {
|
|
38
|
+
return new NaruError(words ?? WORDS[code], code, cause);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The NaruError for what the server answered: `error` with its HTTP `status`. */
|
|
42
|
+
function failure(status, error) {
|
|
43
|
+
const code = errorCode(status, error?.code);
|
|
44
|
+
// Site code's ctx.fail message is already written for visitors. A
|
|
45
|
+
// value a unique field already holds is no concurrent change, as other
|
|
46
|
+
// conflicts are: it is worded on its own.
|
|
47
|
+
const unique = error?.details?.unique;
|
|
48
|
+
const failed = naruError(code, {
|
|
49
|
+
words:
|
|
50
|
+
error?.site === true && typeof error.message === "string"
|
|
51
|
+
? error.message
|
|
52
|
+
: code === "CONFLICT" && typeof unique === "string"
|
|
53
|
+
? `이미 쓰이고 있는 값입니다(${unique}). 다른 값으로 해 주세요.`
|
|
54
|
+
: undefined,
|
|
55
|
+
});
|
|
56
|
+
// What site code passed to ctx.fail, for the page to branch on.
|
|
57
|
+
if (error?.details !== undefined)
|
|
58
|
+
Object.defineProperty(failed, "details", {
|
|
59
|
+
value: error.details,
|
|
60
|
+
enumerable: true,
|
|
61
|
+
});
|
|
62
|
+
return failed;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The server names the code. Only the codes this version knows are passed on,
|
|
66
|
+
// so a code added later reads as the nearest one this version has.
|
|
67
|
+
const CODES = new Set([
|
|
68
|
+
"CONFLICT",
|
|
69
|
+
"QUOTA_EXCEEDED",
|
|
70
|
+
"AUTH_REQUIRED",
|
|
71
|
+
"ACCESS_DENIED",
|
|
72
|
+
"NOT_FOUND",
|
|
73
|
+
"RATE_LIMITED",
|
|
74
|
+
"INVALID_REQUEST",
|
|
75
|
+
"UNAVAILABLE",
|
|
76
|
+
]);
|
|
77
|
+
const errorCode = (status, code) => {
|
|
78
|
+
if (CODES.has(code)) return code;
|
|
79
|
+
if (status === 401) return "AUTH_REQUIRED";
|
|
80
|
+
if (status === 403) return "ACCESS_DENIED";
|
|
81
|
+
if (status === 404) return "NOT_FOUND";
|
|
82
|
+
if (status === 429) return "RATE_LIMITED";
|
|
83
|
+
if (status >= 500) return "UNAVAILABLE";
|
|
84
|
+
return "INVALID_REQUEST";
|
|
85
|
+
};
|
|
86
|
+
const CONTROL_PLANE = "https://naru.pub";
|
|
87
|
+
// The data API, served from Seoul (sdk.naru.pub); sign-in stays on naru.pub.
|
|
88
|
+
const DATA_API = "https://sdk.naru.pub";
|
|
89
|
+
const SITE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
90
|
+
const ID = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
91
|
+
// Where requests go: the data API at sdk.naru.pub, sign-in at naru.pub. A
|
|
92
|
+
// page on alice.naru.pub belongs to the site alice, so only custom domains and
|
|
93
|
+
// local development say which site.
|
|
94
|
+
function target({ site } = {}) {
|
|
95
|
+
site ||= /^([a-z0-9-]+)\.naru\.pub$/.exec(
|
|
96
|
+
globalThis.location?.hostname ?? "",
|
|
97
|
+
)?.[1];
|
|
98
|
+
if (typeof site !== "string" || !SITE.test(site))
|
|
99
|
+
throw new TypeError(
|
|
100
|
+
"Pass { site: <Naru login name> } when the page is not on <site>.naru.pub.",
|
|
101
|
+
);
|
|
102
|
+
return {
|
|
103
|
+
site,
|
|
104
|
+
origin: CONTROL_PLANE,
|
|
105
|
+
root: `${DATA_API}/api/data/v1/${site}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A document ID becomes a path segment. Checking it here keeps "..", which a
|
|
110
|
+
// URL would resolve away, from ever addressing something else.
|
|
111
|
+
function segment(value) {
|
|
112
|
+
if (typeof value !== "string" || !ID.test(value))
|
|
113
|
+
throw new TypeError(
|
|
114
|
+
"Names and IDs are 1-64 ASCII letters, digits, underscores or hyphens.",
|
|
115
|
+
);
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Once this module writes a collection, its subsequent reads bypass caches.
|
|
120
|
+
// No cache lifetime is baked into deployed SDKs; the server may change it.
|
|
121
|
+
const written = new Set();
|
|
122
|
+
|
|
123
|
+
// Accept only JSON values, without silently dropping or converting input.
|
|
124
|
+
function jsonValue(value, ancestors = new Set(), path = "data") {
|
|
125
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
126
|
+
return;
|
|
127
|
+
if (typeof value === "number" && Number.isFinite(value)) return;
|
|
128
|
+
if (typeof value !== "object" || ancestors.has(value))
|
|
129
|
+
throw new TypeError(
|
|
130
|
+
`${path} must contain only JSON values (no cycles or non-finite numbers).`,
|
|
131
|
+
);
|
|
132
|
+
const array = Array.isArray(value);
|
|
133
|
+
const prototype = Object.getPrototypeOf(value);
|
|
134
|
+
if (
|
|
135
|
+
array
|
|
136
|
+
? prototype !== Array.prototype
|
|
137
|
+
: prototype !== Object.prototype && prototype !== null
|
|
138
|
+
)
|
|
139
|
+
throw new TypeError(
|
|
140
|
+
`${path} must be a plain JSON object; convert dates to strings.`,
|
|
141
|
+
);
|
|
142
|
+
ancestors.add(value);
|
|
143
|
+
const keys = Reflect.ownKeys(value).filter(
|
|
144
|
+
(key) => !(array && key === "length"),
|
|
145
|
+
);
|
|
146
|
+
if (array && keys.length !== value.length)
|
|
147
|
+
throw new TypeError(`${path} must be a dense JSON array.`);
|
|
148
|
+
for (const key of keys) {
|
|
149
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
150
|
+
if (
|
|
151
|
+
typeof key !== "string" ||
|
|
152
|
+
!descriptor.enumerable ||
|
|
153
|
+
!("value" in descriptor) ||
|
|
154
|
+
(array && !/^(0|[1-9][0-9]*)$/.test(key))
|
|
155
|
+
)
|
|
156
|
+
throw new TypeError(
|
|
157
|
+
`${path} must contain only ordinary JSON properties.`,
|
|
158
|
+
);
|
|
159
|
+
jsonValue(descriptor.value, ancestors, `${path}.${key}`);
|
|
160
|
+
}
|
|
161
|
+
ancestors.delete(value);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// How long a request may go unanswered before it fails as UNAVAILABLE. Naru
|
|
165
|
+
// answers or refuses within about ten seconds, so a request still waiting
|
|
166
|
+
// after this is one that will not arrive: a page can say so instead of
|
|
167
|
+
// waiting on the browser's own limit, which can be minutes.
|
|
168
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
169
|
+
|
|
170
|
+
async function request(
|
|
171
|
+
url,
|
|
172
|
+
{ method = "GET", body, token, signal, touches, renew },
|
|
173
|
+
) {
|
|
174
|
+
const fresh =
|
|
175
|
+
token || method !== "GET" || touches.some((path) => written.has(path));
|
|
176
|
+
// An explicit transport flag makes cache bypass enforceable by the server.
|
|
177
|
+
if (fresh && method === "GET") {
|
|
178
|
+
const target = new URL(url);
|
|
179
|
+
target.searchParams.set("fresh", "1");
|
|
180
|
+
url = target.href;
|
|
181
|
+
}
|
|
182
|
+
// Serialization errors are input errors, never network failures.
|
|
183
|
+
const encoded = body === undefined ? undefined : JSON.stringify(body);
|
|
184
|
+
let response;
|
|
185
|
+
try {
|
|
186
|
+
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
187
|
+
response = await fetch(url, {
|
|
188
|
+
method,
|
|
189
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
190
|
+
credentials: "omit",
|
|
191
|
+
redirect: "error",
|
|
192
|
+
cache: fresh ? "no-store" : "default",
|
|
193
|
+
headers: {
|
|
194
|
+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
195
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
196
|
+
},
|
|
197
|
+
body: encoded,
|
|
198
|
+
});
|
|
199
|
+
} catch (cause) {
|
|
200
|
+
// The caller's own abort; a timeout (TimeoutError) is Naru's silence.
|
|
201
|
+
if (cause?.name === "AbortError") throw cause;
|
|
202
|
+
throw naruError("UNAVAILABLE", { cause });
|
|
203
|
+
} finally {
|
|
204
|
+
// Also when the response was lost: the write may still have landed.
|
|
205
|
+
if (method !== "GET") for (const path of touches) written.add(path);
|
|
206
|
+
}
|
|
207
|
+
// A token's lifetime is an idle window the server pushes forward as the
|
|
208
|
+
// token is used, and this is the expiry it now has. It arrives as a duration
|
|
209
|
+
// so this browser's clock, however wrong, only measures it.
|
|
210
|
+
const renewed = response.headers.get("Naru-Owner-Expires-In");
|
|
211
|
+
if (renew && renewed !== null && Number.isFinite(Number(renewed)))
|
|
212
|
+
renew(Date.now() + Number(renewed) * 1000);
|
|
213
|
+
const result = await response.json().catch(() => null);
|
|
214
|
+
if (!response.ok) throw failure(response.status, result?.error);
|
|
215
|
+
// A proxy or challenge page can answer 200 with HTML. Handing that back as
|
|
216
|
+
// an empty result would make a missing document look like a present one.
|
|
217
|
+
if (result === null || typeof result !== "object")
|
|
218
|
+
throw naruError("UNAVAILABLE");
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function query({ filter, sort, size, after, includeTotal } = {}) {
|
|
223
|
+
const parameters = new URLSearchParams();
|
|
224
|
+
if (filter && Object.keys(filter).length)
|
|
225
|
+
parameters.set("filter", JSON.stringify(filter));
|
|
226
|
+
if (sort) parameters.set("sort", JSON.stringify(sort));
|
|
227
|
+
if (size !== undefined) parameters.set("size", String(size));
|
|
228
|
+
if (after) parameters.set("after", after);
|
|
229
|
+
if (includeTotal) parameters.set("includeTotal", "1");
|
|
230
|
+
const text = String(parameters);
|
|
231
|
+
return text ? `?${text}` : "";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Shared by single writes and batches, so both refuse the same mistakes here.
|
|
235
|
+
// A delete conditional on absence could only ever do nothing.
|
|
236
|
+
function checkCondition(value, { deleting = false } = {}) {
|
|
237
|
+
if (value === undefined) return value;
|
|
238
|
+
if (!value || typeof value !== "object")
|
|
239
|
+
throw new TypeError("condition must contain revision or absent.");
|
|
240
|
+
if (Object.keys(value).length !== 1)
|
|
241
|
+
throw new TypeError("condition must contain only revision or absent.");
|
|
242
|
+
if (Object.hasOwn(value, "revision")) {
|
|
243
|
+
const { revision } = value;
|
|
244
|
+
if (typeof revision !== "string" || !revision)
|
|
245
|
+
throw new TypeError("condition.revision must be returned by Naru.");
|
|
246
|
+
return value;
|
|
247
|
+
}
|
|
248
|
+
if (value.absent === true && !deleting) return value;
|
|
249
|
+
throw new TypeError(
|
|
250
|
+
deleting
|
|
251
|
+
? "A delete can only be conditional on a revision."
|
|
252
|
+
: "condition must contain revision or absent.",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function condition(value, options) {
|
|
257
|
+
checkCondition(value, options);
|
|
258
|
+
if (value === undefined) return "";
|
|
259
|
+
const { revision } = value;
|
|
260
|
+
return revision
|
|
261
|
+
? `?ifRevision=${encodeURIComponent(revision)}`
|
|
262
|
+
: "?ifAbsent=1";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function documents(root, name, send) {
|
|
266
|
+
const path = `${root}/${segment(name)}`;
|
|
267
|
+
const touches = [path];
|
|
268
|
+
const list = (options = {}) =>
|
|
269
|
+
send(`${path}${query(options)}`, { signal: options.signal, touches });
|
|
270
|
+
return Object.freeze({
|
|
271
|
+
async get(id, { signal } = {}) {
|
|
272
|
+
const result = await send(`${path}/${segment(id)}`, { signal, touches });
|
|
273
|
+
return result.document;
|
|
274
|
+
},
|
|
275
|
+
list,
|
|
276
|
+
// A total rides along with a one-document page; the list request is all
|
|
277
|
+
// the server needs to support.
|
|
278
|
+
async count({ filter, signal } = {}) {
|
|
279
|
+
return (await list({ filter, size: 1, includeTotal: true, signal }))
|
|
280
|
+
.totalCount;
|
|
281
|
+
},
|
|
282
|
+
// Each page as it arrives, following nextCursor. Like the pages it walks,
|
|
283
|
+
// this is not a snapshot: writes in between can shift later pages.
|
|
284
|
+
async *pages(options = {}) {
|
|
285
|
+
let after = options.after;
|
|
286
|
+
do {
|
|
287
|
+
const page = await list({ ...options, after });
|
|
288
|
+
yield page;
|
|
289
|
+
after = page.nextCursor;
|
|
290
|
+
} while (after);
|
|
291
|
+
},
|
|
292
|
+
add(data, { signal } = {}) {
|
|
293
|
+
jsonValue(data);
|
|
294
|
+
return send(path, {
|
|
295
|
+
method: "POST",
|
|
296
|
+
body: { data },
|
|
297
|
+
signal,
|
|
298
|
+
touches,
|
|
299
|
+
});
|
|
300
|
+
},
|
|
301
|
+
set(id, data, { signal, condition: expected } = {}) {
|
|
302
|
+
jsonValue(data);
|
|
303
|
+
return send(`${path}/${segment(id)}${condition(expected)}`, {
|
|
304
|
+
method: "PUT",
|
|
305
|
+
body: { data },
|
|
306
|
+
signal,
|
|
307
|
+
touches,
|
|
308
|
+
});
|
|
309
|
+
},
|
|
310
|
+
async delete(id, { signal, condition: expected } = {}) {
|
|
311
|
+
await send(
|
|
312
|
+
`${path}/${segment(id)}${condition(expected, { deleting: true })}`,
|
|
313
|
+
{ method: "DELETE", signal, touches },
|
|
314
|
+
);
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** A collection of the site this page belongs to. Nothing is requested yet. */
|
|
320
|
+
function publicDocuments(root, name) {
|
|
321
|
+
const collection = documents(root, name, request);
|
|
322
|
+
return Object.freeze({
|
|
323
|
+
get: collection.get,
|
|
324
|
+
list: collection.list,
|
|
325
|
+
count: collection.count,
|
|
326
|
+
pages: collection.pages,
|
|
327
|
+
add: collection.add,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Photos straight off a phone are several megabytes and thousands of pixels
|
|
332
|
+
// for an image a page shows at a fraction of that. Uploads go straight to
|
|
333
|
+
// object storage, so the browser is the only place to shrink them: before the
|
|
334
|
+
// upload is authorized, so quota counts what is stored, and by re-encoding,
|
|
335
|
+
// which drops EXIF so a photo's location never reaches the public URL.
|
|
336
|
+
const MAX_EDGE = 2048;
|
|
337
|
+
// Naru never stores HEIC, so a photo in it is converted or refused here. The
|
|
338
|
+
// other types Naru stores are the server's to say, and may grow.
|
|
339
|
+
const HEIC = /^image\/hei[cf]$/;
|
|
340
|
+
const HEIC_NAME = /\.hei[cf]$/i;
|
|
341
|
+
const SMALL_BYTES = 512 * 1024;
|
|
342
|
+
async function shrink(file) {
|
|
343
|
+
const heic = HEIC.test(file.type);
|
|
344
|
+
if (
|
|
345
|
+
!(heic || /^image\/(jpeg|png|webp)$/.test(file.type)) ||
|
|
346
|
+
typeof createImageBitmap !== "function"
|
|
347
|
+
)
|
|
348
|
+
return file;
|
|
349
|
+
let bitmap;
|
|
350
|
+
try {
|
|
351
|
+
// Canvas discards EXIF, so orientation is baked into the pixels here.
|
|
352
|
+
bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
|
|
353
|
+
} catch {
|
|
354
|
+
return file;
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height));
|
|
358
|
+
if (scale === 1 && !heic && file.size <= SMALL_BYTES) return file;
|
|
359
|
+
const width = Math.max(1, Math.round(bitmap.width * scale));
|
|
360
|
+
const height = Math.max(1, Math.round(bitmap.height * scale));
|
|
361
|
+
for (const type of ["image/webp", "image/jpeg"]) {
|
|
362
|
+
const canvas =
|
|
363
|
+
typeof OffscreenCanvas === "function"
|
|
364
|
+
? new OffscreenCanvas(width, height)
|
|
365
|
+
: Object.assign(document.createElement("canvas"), { width, height });
|
|
366
|
+
// Spelled out: which overload a union of canvases resolves to depends on
|
|
367
|
+
// what else a program compiles, and the application's does differ.
|
|
368
|
+
const context = canvas.getContext("2d");
|
|
369
|
+
if (!context) return file;
|
|
370
|
+
// JPEG has no transparency; paint white rather than let it go black.
|
|
371
|
+
if (type === "image/jpeg") {
|
|
372
|
+
context.fillStyle = "#fff";
|
|
373
|
+
context.fillRect(0, 0, width, height);
|
|
374
|
+
}
|
|
375
|
+
context.drawImage(bitmap, 0, 0, width, height);
|
|
376
|
+
const blob = await ("convertToBlob" in canvas
|
|
377
|
+
? canvas.convertToBlob({ type, quality: 0.82 })
|
|
378
|
+
: new Promise((resolve) => canvas.toBlob(resolve, type, 0.82)));
|
|
379
|
+
// A browser that cannot encode a type quietly returns PNG instead.
|
|
380
|
+
if (blob?.type !== type) continue;
|
|
381
|
+
// HEIC is not accepted as is, so its conversion is kept even if larger.
|
|
382
|
+
if (!heic && blob.size >= file.size) return file;
|
|
383
|
+
const base = (file.name || "upload").replace(/\.[^.]*$/, "");
|
|
384
|
+
return new File([blob], `${base}.${type.slice(6)}`, { type });
|
|
385
|
+
}
|
|
386
|
+
return file;
|
|
387
|
+
} catch {
|
|
388
|
+
return file;
|
|
389
|
+
} finally {
|
|
390
|
+
bitmap.close?.();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// The bytes go from the browser straight to object storage on a signed URL.
|
|
395
|
+
// fetch reports nothing of a body it sends, and streaming one to count it
|
|
396
|
+
// would drop the Content-Length a signed PUT needs, so a caller who asked for
|
|
397
|
+
// progress gets the same PUT through XMLHttpRequest. It answers like fetch:
|
|
398
|
+
// { ok, status }, a TypeError for a network failure, and the signal's reason
|
|
399
|
+
// for an abort.
|
|
400
|
+
function put(url, headers, file, signal, uploading) {
|
|
401
|
+
if (!uploading)
|
|
402
|
+
return fetch(url, {
|
|
403
|
+
method: "PUT",
|
|
404
|
+
headers,
|
|
405
|
+
body: file,
|
|
406
|
+
signal,
|
|
407
|
+
credentials: "omit",
|
|
408
|
+
redirect: "error",
|
|
409
|
+
});
|
|
410
|
+
return new Promise((resolve, reject) => {
|
|
411
|
+
const aborted = () =>
|
|
412
|
+
signal?.reason ??
|
|
413
|
+
new DOMException("The upload was aborted.", "AbortError");
|
|
414
|
+
if (signal?.aborted) return reject(aborted());
|
|
415
|
+
const request = new XMLHttpRequest();
|
|
416
|
+
const abort = () => request.abort();
|
|
417
|
+
const settle = (finish) => {
|
|
418
|
+
signal?.removeEventListener("abort", abort);
|
|
419
|
+
finish();
|
|
420
|
+
};
|
|
421
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
422
|
+
request.open("PUT", url);
|
|
423
|
+
for (const [name, value] of Object.entries(headers))
|
|
424
|
+
request.setRequestHeader(name, value);
|
|
425
|
+
request.upload.onprogress = (event) =>
|
|
426
|
+
uploading(Math.min(event.loaded, file.size));
|
|
427
|
+
// XMLHttpRequest follows redirects it cannot be told to refuse. Storage
|
|
428
|
+
// never redirects a signed upload, so one that moved is not trusted.
|
|
429
|
+
request.onload = () =>
|
|
430
|
+
settle(() =>
|
|
431
|
+
resolve({
|
|
432
|
+
ok:
|
|
433
|
+
request.status >= 200 &&
|
|
434
|
+
request.status < 300 &&
|
|
435
|
+
request.responseURL === new URL(url).href,
|
|
436
|
+
status: request.status,
|
|
437
|
+
}),
|
|
438
|
+
);
|
|
439
|
+
request.onerror = request.ontimeout = () =>
|
|
440
|
+
settle(() => reject(new TypeError("The upload connection failed.")));
|
|
441
|
+
request.onabort = () => settle(() => reject(aborted()));
|
|
442
|
+
request.send(file);
|
|
443
|
+
// After send: an abort from this first report must reach a sent request,
|
|
444
|
+
// which fires abort, rather than an unsent one, which would not.
|
|
445
|
+
uploading(0);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Uploading, as an owner or a member: the same requests, with the token the
|
|
450
|
+
// handle sends them with.
|
|
451
|
+
function media(root, send) {
|
|
452
|
+
return Object.freeze({
|
|
453
|
+
async upload(source, { signal, onProgress } = {}) {
|
|
454
|
+
if (onProgress !== undefined && typeof onProgress !== "function")
|
|
455
|
+
throw new TypeError("onProgress must be a function.");
|
|
456
|
+
// A caller's mistake in its progress handler is reported, never
|
|
457
|
+
// allowed to fail an upload partway through.
|
|
458
|
+
const report = (progress) => {
|
|
459
|
+
try {
|
|
460
|
+
onProgress?.(progress);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
if (typeof reportError === "function") reportError(error);
|
|
463
|
+
else
|
|
464
|
+
setTimeout(() => {
|
|
465
|
+
throw error;
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
report({ phase: "preparing" });
|
|
470
|
+
// Some browsers leave an iPhone photo's type empty.
|
|
471
|
+
const file = await shrink(
|
|
472
|
+
!source.type && HEIC_NAME.test(source.name ?? "")
|
|
473
|
+
? new File([source], source.name, { type: "image/heic" })
|
|
474
|
+
: source,
|
|
475
|
+
);
|
|
476
|
+
if (HEIC.test(file.type))
|
|
477
|
+
throw new TypeError(
|
|
478
|
+
"This browser cannot convert HEIC photos. Upload from Safari, or convert the photo to JPEG first.",
|
|
479
|
+
);
|
|
480
|
+
const authorization = await send(`${root}/_files`, {
|
|
481
|
+
method: "POST",
|
|
482
|
+
body: {
|
|
483
|
+
name: file.name || "upload",
|
|
484
|
+
contentType: file.type,
|
|
485
|
+
size: file.size,
|
|
486
|
+
},
|
|
487
|
+
signal,
|
|
488
|
+
touches: [],
|
|
489
|
+
});
|
|
490
|
+
let upload;
|
|
491
|
+
try {
|
|
492
|
+
upload = await put(
|
|
493
|
+
authorization.uploadUrl,
|
|
494
|
+
authorization.headers,
|
|
495
|
+
file,
|
|
496
|
+
signal,
|
|
497
|
+
onProgress &&
|
|
498
|
+
((loaded) =>
|
|
499
|
+
report({ phase: "uploading", loaded, total: file.size })),
|
|
500
|
+
);
|
|
501
|
+
} catch (cause) {
|
|
502
|
+
if (cause?.name === "AbortError") throw cause;
|
|
503
|
+
throw naruError("UNAVAILABLE", {
|
|
504
|
+
words: "파일을 올리지 못했습니다. 다시 해 주세요.",
|
|
505
|
+
cause,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
// An upload that never finishes is removed by the server within an
|
|
509
|
+
// hour. Storage refusing the bytes (an expired authorization, say) is
|
|
510
|
+
// fixed by uploading again, which authorizes afresh.
|
|
511
|
+
if (!upload.ok)
|
|
512
|
+
throw naruError("UNAVAILABLE", {
|
|
513
|
+
words: "파일을 올리지 못했습니다. 다시 해 주세요.",
|
|
514
|
+
});
|
|
515
|
+
report({ phase: "finishing" });
|
|
516
|
+
const finished = await send(
|
|
517
|
+
`${root}/_files/${segment(authorization.id)}`,
|
|
518
|
+
{ method: "PUT", body: {}, signal, touches: [] },
|
|
519
|
+
);
|
|
520
|
+
// What was stored, which shrinking may have renamed and re-encoded.
|
|
521
|
+
const { url, name, contentType, size } = finished.file;
|
|
522
|
+
return { id: authorization.id, url, name, contentType, size };
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const ownerPlace = ({ origin, site }) => ({
|
|
528
|
+
store: () => localStorage,
|
|
529
|
+
key: `naru:owner:${origin}:${site}:${location.origin}${location.pathname}`,
|
|
530
|
+
});
|
|
531
|
+
const memberPlace = ({ origin, site }) => ({
|
|
532
|
+
store: () => localStorage,
|
|
533
|
+
key: `naru:member:${origin}:${site}`,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
function saved({ store, key }) {
|
|
537
|
+
try {
|
|
538
|
+
return JSON.parse(store().getItem(key) ?? "null");
|
|
539
|
+
} catch {
|
|
540
|
+
return null; /* unreadable or blocked, so no session */
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// An older client must not erase or outlive a newer sign-in made in the same
|
|
545
|
+
// place, so both of these only touch a stored session that is still this one.
|
|
546
|
+
function forget(place, token) {
|
|
547
|
+
try {
|
|
548
|
+
if (saved(place)?.accessToken === token)
|
|
549
|
+
place.store().removeItem(place.key);
|
|
550
|
+
} catch {
|
|
551
|
+
/* storage blocked: nothing was kept */
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function remember(place, token, expiresAt) {
|
|
556
|
+
const session = saved(place);
|
|
557
|
+
try {
|
|
558
|
+
if (session?.accessToken === token)
|
|
559
|
+
place
|
|
560
|
+
.store()
|
|
561
|
+
.setItem(place.key, JSON.stringify({ ...session, expiresAt }));
|
|
562
|
+
} catch {
|
|
563
|
+
/* storage blocked: the handle still holds it */
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// What an owner's and a member's handle share: a token the server renews as
|
|
568
|
+
// it is used, so a tab left open while it is used is not signed out midway,
|
|
569
|
+
// and which the handle refuses to send once signed out or expired.
|
|
570
|
+
function credential(place, token, expiresAt) {
|
|
571
|
+
const renew = (at) => {
|
|
572
|
+
if (!(at > expiresAt)) return;
|
|
573
|
+
expiresAt = at;
|
|
574
|
+
remember(place, token, at);
|
|
575
|
+
};
|
|
576
|
+
let signedOut = false;
|
|
577
|
+
const send = async (url, init) => {
|
|
578
|
+
if (signedOut || Date.now() >= expiresAt) {
|
|
579
|
+
forget(place, token);
|
|
580
|
+
throw naruError("AUTH_REQUIRED");
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
return await request(url, { ...init, token, renew });
|
|
584
|
+
} catch (error) {
|
|
585
|
+
if (error.code === "AUTH_REQUIRED") forget(place, token);
|
|
586
|
+
throw error;
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
/** Forgets the session here first, then asks Naru to revoke it. */
|
|
590
|
+
const signOut = async (revoke) => {
|
|
591
|
+
signedOut = true;
|
|
592
|
+
forget(place, token);
|
|
593
|
+
await request(revoke, { method: "POST", token, touches: [] });
|
|
594
|
+
};
|
|
595
|
+
return { send, signOut };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// A mistake in an application's callback is reported, never allowed to break
|
|
599
|
+
// the SDK work that called it.
|
|
600
|
+
function guarded(fn, ...args) {
|
|
601
|
+
try {
|
|
602
|
+
fn?.(...args);
|
|
603
|
+
} catch (error) {
|
|
604
|
+
if (typeof reportError === "function") reportError(error);
|
|
605
|
+
else
|
|
606
|
+
setTimeout(() => {
|
|
607
|
+
throw error;
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function live(root, hooks, ticket) {
|
|
613
|
+
let socket;
|
|
614
|
+
let closed = false;
|
|
615
|
+
let attempt = 0;
|
|
616
|
+
let timer;
|
|
617
|
+
let keepalive;
|
|
618
|
+
const stop = () => {
|
|
619
|
+
closed = true;
|
|
620
|
+
clearTimeout(timer);
|
|
621
|
+
clearInterval(keepalive);
|
|
622
|
+
};
|
|
623
|
+
const retry = () => {
|
|
624
|
+
if (closed) return;
|
|
625
|
+
const pause = Math.min(30_000, 1000 * 2 ** attempt++);
|
|
626
|
+
timer = setTimeout(open, pause / 2 + (Math.random() * pause) / 2);
|
|
627
|
+
};
|
|
628
|
+
async function open() {
|
|
629
|
+
if (closed) return;
|
|
630
|
+
const url = new URL(`${root}/_live`);
|
|
631
|
+
url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
|
|
632
|
+
if (ticket)
|
|
633
|
+
try {
|
|
634
|
+
url.searchParams.set("ticket", await ticket());
|
|
635
|
+
} catch (error) {
|
|
636
|
+
const code = error.code;
|
|
637
|
+
if (code === "AUTH_REQUIRED" || code === "ACCESS_DENIED") stop();
|
|
638
|
+
else retry();
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (closed) return;
|
|
642
|
+
const current = new WebSocket(url);
|
|
643
|
+
socket = current;
|
|
644
|
+
current.onopen = () => {
|
|
645
|
+
attempt = 0;
|
|
646
|
+
// Answered by the server without waking the site's object.
|
|
647
|
+
keepalive = setInterval(() => current.send("ping"), 50_000);
|
|
648
|
+
hooks.open((message) => current.send(JSON.stringify(message)));
|
|
649
|
+
};
|
|
650
|
+
current.onmessage = (event) => {
|
|
651
|
+
if (event.data === "pong") return;
|
|
652
|
+
let message;
|
|
653
|
+
try {
|
|
654
|
+
message = JSON.parse(String(event.data));
|
|
655
|
+
} catch {
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
hooks.message(message);
|
|
659
|
+
};
|
|
660
|
+
current.onclose = (event) => {
|
|
661
|
+
clearInterval(keepalive);
|
|
662
|
+
hooks.closed();
|
|
663
|
+
if (socket === current) socket = undefined;
|
|
664
|
+
if (closed) return;
|
|
665
|
+
if (event.code === 4003) stop();
|
|
666
|
+
else retry();
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
void open();
|
|
670
|
+
return Object.freeze({
|
|
671
|
+
close() {
|
|
672
|
+
if (closed) return;
|
|
673
|
+
stop();
|
|
674
|
+
socket?.close(1000);
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Live queries: one connection a handle shares among them, opened with the
|
|
680
|
+
// first and closed with the last. On every (re)connect each is asked again,
|
|
681
|
+
// so a result missed while the connection was down arrives then.
|
|
682
|
+
function subscriptions(root, ticket) {
|
|
683
|
+
const active = new Map();
|
|
684
|
+
let connection;
|
|
685
|
+
let send;
|
|
686
|
+
let next = 0;
|
|
687
|
+
const ask = (id) => {
|
|
688
|
+
const { query, args } = active.get(id);
|
|
689
|
+
send?.({ type: "subscribe", id, query, args });
|
|
690
|
+
};
|
|
691
|
+
return function subscribe(name, given, onResult, options = {}) {
|
|
692
|
+
const parts = typeof name === "string" ? name.split(":") : [];
|
|
693
|
+
if (parts.length !== 2)
|
|
694
|
+
throw new TypeError('Live queries name "<module/path>:<query>".');
|
|
695
|
+
parts[0].split("/").forEach(segment);
|
|
696
|
+
segment(parts[1]);
|
|
697
|
+
// As query()'s default: no args is null, not a refusal.
|
|
698
|
+
const args = given === undefined ? null : given;
|
|
699
|
+
jsonValue(args, undefined, "args");
|
|
700
|
+
if (typeof onResult !== "function")
|
|
701
|
+
throw new TypeError("subscribe() needs an onResult function.");
|
|
702
|
+
const id = String(++next);
|
|
703
|
+
active.set(id, {
|
|
704
|
+
query: name,
|
|
705
|
+
args,
|
|
706
|
+
onResult: onResult,
|
|
707
|
+
onError: options.onError,
|
|
708
|
+
});
|
|
709
|
+
connection ??= live(
|
|
710
|
+
root,
|
|
711
|
+
{
|
|
712
|
+
open(sender) {
|
|
713
|
+
send = sender;
|
|
714
|
+
for (const id of active.keys()) ask(id);
|
|
715
|
+
},
|
|
716
|
+
message(message) {
|
|
717
|
+
const { type, id, result, error } = message ?? {};
|
|
718
|
+
const subscription = id === undefined ? undefined : active.get(id);
|
|
719
|
+
if (type === "result" && subscription)
|
|
720
|
+
guarded(subscription.onResult, result);
|
|
721
|
+
else if (type === "error" && subscription)
|
|
722
|
+
guarded(subscription.onError, failure(error?.status ?? 400, error));
|
|
723
|
+
},
|
|
724
|
+
closed() {
|
|
725
|
+
send = undefined;
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
ticket,
|
|
729
|
+
);
|
|
730
|
+
ask(id);
|
|
731
|
+
return Object.freeze({
|
|
732
|
+
close() {
|
|
733
|
+
if (!active.delete(id)) return;
|
|
734
|
+
send?.({ type: "unsubscribe", id });
|
|
735
|
+
if (active.size) return;
|
|
736
|
+
connection?.close();
|
|
737
|
+
connection = undefined;
|
|
738
|
+
send = undefined;
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Running a query or mutation, as a visitor, a member or the owner: the same
|
|
745
|
+
// request with the handle's token, if it has one. Queries and mutations write
|
|
746
|
+
// the site's tables, not collections, so no collection's cache is touched.
|
|
747
|
+
function siteCode(root, send, path) {
|
|
748
|
+
return async function call(name, args = null, { signal } = {}) {
|
|
749
|
+
const parts = typeof name === "string" ? name.split(":") : [];
|
|
750
|
+
if (parts.length !== 2)
|
|
751
|
+
throw new TypeError('Calls name "<module/path>:<export>".');
|
|
752
|
+
parts[0].split("/").forEach(segment);
|
|
753
|
+
segment(parts[1]);
|
|
754
|
+
jsonValue(args, undefined, "args");
|
|
755
|
+
const answer = await send(`${root}/${path}/${parts[0]}:${parts[1]}`, {
|
|
756
|
+
method: "POST",
|
|
757
|
+
body: { args },
|
|
758
|
+
signal,
|
|
759
|
+
touches: [],
|
|
760
|
+
});
|
|
761
|
+
return answer.result;
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function admin(context, token, expiresAt) {
|
|
766
|
+
const { origin, root } = context;
|
|
767
|
+
const { send, signOut } = credential(ownerPlace(context), token, expiresAt);
|
|
768
|
+
return Object.freeze({
|
|
769
|
+
collection: (name) => documents(root, name, send),
|
|
770
|
+
async batch(writes, { signal } = {}) {
|
|
771
|
+
const operations = writes.map(({ collection, set, delete: remove }) => {
|
|
772
|
+
const write = set ?? remove;
|
|
773
|
+
if (!write || Boolean(set) === Boolean(remove))
|
|
774
|
+
throw new TypeError("Each batch write must set or delete.");
|
|
775
|
+
if (set) jsonValue(set.data);
|
|
776
|
+
segment(collection);
|
|
777
|
+
segment(write.id);
|
|
778
|
+
checkCondition(write.condition, { deleting: !set });
|
|
779
|
+
return {
|
|
780
|
+
type: set ? "set" : "delete",
|
|
781
|
+
collection,
|
|
782
|
+
id: write.id,
|
|
783
|
+
...(set ? { data: set.data } : {}),
|
|
784
|
+
...(write.condition ? { condition: write.condition } : {}),
|
|
785
|
+
};
|
|
786
|
+
});
|
|
787
|
+
const touches = [
|
|
788
|
+
...new Set(operations.map((item) => `${root}/${item.collection}`)),
|
|
789
|
+
];
|
|
790
|
+
const { results } = await send(`${root}/_batch`, {
|
|
791
|
+
method: "POST",
|
|
792
|
+
body: { operations },
|
|
793
|
+
signal,
|
|
794
|
+
touches,
|
|
795
|
+
});
|
|
796
|
+
// In the order written: what each set stored, and null for a delete.
|
|
797
|
+
return results.map((result) =>
|
|
798
|
+
result
|
|
799
|
+
? {
|
|
800
|
+
id: result.id,
|
|
801
|
+
revision: result.revision,
|
|
802
|
+
createdAt: result.createdAt,
|
|
803
|
+
updatedAt: result.updatedAt,
|
|
804
|
+
}
|
|
805
|
+
: null,
|
|
806
|
+
);
|
|
807
|
+
},
|
|
808
|
+
query: siteCode(root, send, "_query"),
|
|
809
|
+
mutate: siteCode(root, send, "_mutate"),
|
|
810
|
+
subscribe: subscriptions(root, async () => {
|
|
811
|
+
const { ticket } = await send(`${root}/_live`, {
|
|
812
|
+
method: "POST",
|
|
813
|
+
body: {},
|
|
814
|
+
touches: [],
|
|
815
|
+
});
|
|
816
|
+
return ticket;
|
|
817
|
+
}),
|
|
818
|
+
media: media(root, send),
|
|
819
|
+
signOut: () => signOut(`${origin}/api/data-auth/v1/revoke`),
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function member(context, token, expiresAt, id) {
|
|
824
|
+
const { origin, root } = context;
|
|
825
|
+
const { send, signOut } = credential(memberPlace(context), token, expiresAt);
|
|
826
|
+
const liveTicket = async () => {
|
|
827
|
+
const { ticket } = await send(`${root}/_live`, {
|
|
828
|
+
method: "POST",
|
|
829
|
+
body: {},
|
|
830
|
+
touches: [],
|
|
831
|
+
});
|
|
832
|
+
return ticket;
|
|
833
|
+
};
|
|
834
|
+
return Object.freeze({
|
|
835
|
+
id,
|
|
836
|
+
query: siteCode(root, send, "_query"),
|
|
837
|
+
mutate: siteCode(root, send, "_mutate"),
|
|
838
|
+
subscribe: subscriptions(root, liveTicket),
|
|
839
|
+
media: media(root, send),
|
|
840
|
+
signOut: () => signOut(`${origin}/api/data-auth/v1/member-revoke`),
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const base64url = (bytes) =>
|
|
845
|
+
btoa(String.fromCharCode(...new Uint8Array(bytes)))
|
|
846
|
+
.replace(/\+/g, "-")
|
|
847
|
+
.replace(/\//g, "_")
|
|
848
|
+
.replace(/=+$/, "");
|
|
849
|
+
const random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
850
|
+
// The page this tab is at. Naru sends the sign-in back to it, so the session
|
|
851
|
+
// below is found where it was left; the rest of the address is restored then.
|
|
852
|
+
const callback = () => location.origin + location.pathname;
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Leaves for Naru's approval page at `path` with `query`, keeping what it
|
|
856
|
+
* takes to finish when Naru sends the tab back.
|
|
857
|
+
*/
|
|
858
|
+
async function leave(context, place, path, query) {
|
|
859
|
+
const verifier = random();
|
|
860
|
+
const state = random();
|
|
861
|
+
const challenge = base64url(
|
|
862
|
+
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)),
|
|
863
|
+
);
|
|
864
|
+
// The PKCE transaction has to survive the round trip through Naru.
|
|
865
|
+
sessionStorage.setItem(
|
|
866
|
+
`${place.key}:pending`,
|
|
867
|
+
JSON.stringify({
|
|
868
|
+
verifier,
|
|
869
|
+
state,
|
|
870
|
+
startedAt: Date.now(),
|
|
871
|
+
address: location.href,
|
|
872
|
+
}),
|
|
873
|
+
);
|
|
874
|
+
const approval = new URL(path, context.origin);
|
|
875
|
+
approval.search = String(
|
|
876
|
+
new URLSearchParams({
|
|
877
|
+
site: context.site,
|
|
878
|
+
redirectUri: callback(),
|
|
879
|
+
challenge,
|
|
880
|
+
state,
|
|
881
|
+
...query,
|
|
882
|
+
}),
|
|
883
|
+
);
|
|
884
|
+
location.assign(approval.href);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Leaves for Naru, where the owner approves access to `collections`, and comes
|
|
889
|
+
* back to this page. Register this page's URL as an administrator callback in
|
|
890
|
+
* the control panel first; Naru says so on its own page if it is not.
|
|
891
|
+
*/
|
|
892
|
+
async function signIn(context, collections) {
|
|
893
|
+
// Checked here so a bad name fails on this page, not on Naru's consent page.
|
|
894
|
+
collections.forEach(segment);
|
|
895
|
+
await leave(context, ownerPlace(context), "/database/authorize", {
|
|
896
|
+
collections: collections.join(","),
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* The token Naru's redirect back to this page carries, or null when this is
|
|
902
|
+
* not that redirect: then the page's own `?code=`, `?error=` and `?state=`
|
|
903
|
+
* are left alone. Only the sign-in this tab started in `place`, with the same
|
|
904
|
+
* state, is finished, so an owner's and a member's sign-in never take each
|
|
905
|
+
* other's code.
|
|
906
|
+
*/
|
|
907
|
+
async function returned(context, place, exchange) {
|
|
908
|
+
const url = new URL(location.href);
|
|
909
|
+
const code = url.searchParams.get("code");
|
|
910
|
+
const denied = url.searchParams.get("error");
|
|
911
|
+
const state = url.searchParams.get("state");
|
|
912
|
+
let pending = null;
|
|
913
|
+
try {
|
|
914
|
+
pending = JSON.parse(
|
|
915
|
+
sessionStorage.getItem(`${place.key}:pending`) ?? "null",
|
|
916
|
+
);
|
|
917
|
+
} catch {
|
|
918
|
+
/* unreadable, so no sign-in to finish */
|
|
919
|
+
}
|
|
920
|
+
if (!pending || pending.state !== state || (code === null && denied === null))
|
|
921
|
+
return null;
|
|
922
|
+
for (const name of ["code", "state", "error"]) url.searchParams.delete(name);
|
|
923
|
+
// Back to the address the sign-in left from, when that is this page.
|
|
924
|
+
const left = pending.address ? new URL(pending.address) : null;
|
|
925
|
+
history.replaceState(
|
|
926
|
+
history.state,
|
|
927
|
+
"",
|
|
928
|
+
left && left.origin + left.pathname === url.origin + url.pathname
|
|
929
|
+
? left.href
|
|
930
|
+
: url.href,
|
|
931
|
+
);
|
|
932
|
+
sessionStorage.removeItem(`${place.key}:pending`);
|
|
933
|
+
if (!(Date.now() - pending.startedAt < 10 * 60 * 1000))
|
|
934
|
+
throw naruError("AUTH_REQUIRED", {
|
|
935
|
+
words: "로그인 시간이 지났습니다. 다시 로그인해 주세요.",
|
|
936
|
+
});
|
|
937
|
+
if (denied !== null)
|
|
938
|
+
throw naruError("ACCESS_DENIED", {
|
|
939
|
+
words: "로그인하지 않았습니다.",
|
|
940
|
+
});
|
|
941
|
+
const startedAt = Date.now();
|
|
942
|
+
const token = await request(`${context.origin}${exchange}`, {
|
|
943
|
+
method: "POST",
|
|
944
|
+
body: { code, verifier: pending.verifier, redirectUri: callback() },
|
|
945
|
+
touches: [],
|
|
946
|
+
});
|
|
947
|
+
// The lifetime is measured on this browser's clock from before the request,
|
|
948
|
+
// so a clock set wrong can neither end the session early nor extend it.
|
|
949
|
+
return {
|
|
950
|
+
...token,
|
|
951
|
+
expiresAt: Number.isFinite(token.expiresIn)
|
|
952
|
+
? startedAt + token.expiresIn * 1000
|
|
953
|
+
: token.expiresAt,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* The signed-in admin client for this page, or null. Finishes a sign-in when
|
|
959
|
+
* Naru has just redirected back, and otherwise restores the page's session,
|
|
960
|
+
* which every tab of it shares.
|
|
961
|
+
* Call it before rendering: it removes the one-time code from the address bar.
|
|
962
|
+
*
|
|
963
|
+
* A sign-in that came back without completing (denied, stale, or a failed
|
|
964
|
+
* exchange) is an ordinary signed-out visit, never a page failure. The owner
|
|
965
|
+
* who denied it knows; anything else is fixed by signing in again.
|
|
966
|
+
*/
|
|
967
|
+
async function adminSession(context) {
|
|
968
|
+
const place = ownerPlace(context);
|
|
969
|
+
try {
|
|
970
|
+
const token = await returned(context, place, "/api/data-auth/v1/token");
|
|
971
|
+
if (token) {
|
|
972
|
+
try {
|
|
973
|
+
place.store().setItem(
|
|
974
|
+
place.key,
|
|
975
|
+
JSON.stringify({
|
|
976
|
+
accessToken: token.accessToken,
|
|
977
|
+
expiresAt: token.expiresAt,
|
|
978
|
+
}),
|
|
979
|
+
);
|
|
980
|
+
} catch {
|
|
981
|
+
/* storage blocked: signed in on this page only */
|
|
982
|
+
}
|
|
983
|
+
return admin(context, token.accessToken, token.expiresAt);
|
|
984
|
+
}
|
|
985
|
+
} catch (error) {
|
|
986
|
+
if (error instanceof NaruError) return null;
|
|
987
|
+
throw error;
|
|
988
|
+
}
|
|
989
|
+
const session = saved(place);
|
|
990
|
+
if (session && session.expiresAt > Date.now())
|
|
991
|
+
return admin(context, session.accessToken, session.expiresAt);
|
|
992
|
+
try {
|
|
993
|
+
place.store().removeItem(place.key);
|
|
994
|
+
} catch {
|
|
995
|
+
/* storage blocked: nothing was kept */
|
|
996
|
+
}
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* The member signed in to this site, or null, like `adminSession`: from
|
|
1002
|
+
* Naru's redirect back, or from the site's storage.
|
|
1003
|
+
*/
|
|
1004
|
+
async function memberSession(context) {
|
|
1005
|
+
const place = memberPlace(context);
|
|
1006
|
+
try {
|
|
1007
|
+
const token = await returned(
|
|
1008
|
+
context,
|
|
1009
|
+
place,
|
|
1010
|
+
"/api/data-auth/v1/member-token",
|
|
1011
|
+
);
|
|
1012
|
+
if (token) {
|
|
1013
|
+
const id = token.memberId;
|
|
1014
|
+
try {
|
|
1015
|
+
place.store().setItem(
|
|
1016
|
+
place.key,
|
|
1017
|
+
JSON.stringify({
|
|
1018
|
+
accessToken: token.accessToken,
|
|
1019
|
+
expiresAt: token.expiresAt,
|
|
1020
|
+
memberId: id,
|
|
1021
|
+
}),
|
|
1022
|
+
);
|
|
1023
|
+
} catch {
|
|
1024
|
+
/* storage blocked: signed in on this page only */
|
|
1025
|
+
}
|
|
1026
|
+
return member(context, token.accessToken, token.expiresAt, id);
|
|
1027
|
+
}
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
if (error instanceof NaruError) return null;
|
|
1030
|
+
throw error;
|
|
1031
|
+
}
|
|
1032
|
+
const session = saved(place);
|
|
1033
|
+
if (session?.memberId && session.expiresAt > Date.now())
|
|
1034
|
+
return member(
|
|
1035
|
+
context,
|
|
1036
|
+
session.accessToken,
|
|
1037
|
+
session.expiresAt,
|
|
1038
|
+
session.memberId,
|
|
1039
|
+
);
|
|
1040
|
+
try {
|
|
1041
|
+
place.store().removeItem(place.key);
|
|
1042
|
+
} catch {
|
|
1043
|
+
/* storage blocked: nothing was kept */
|
|
1044
|
+
}
|
|
1045
|
+
return null;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** Creates a client bound to one site. */
|
|
1049
|
+
export function createNaru(options) {
|
|
1050
|
+
const context = target(options);
|
|
1051
|
+
return Object.freeze({
|
|
1052
|
+
collection: (name) => publicDocuments(context.root, name),
|
|
1053
|
+
subscribe: subscriptions(context.root),
|
|
1054
|
+
query: siteCode(context.root, request, "_query"),
|
|
1055
|
+
mutate: siteCode(context.root, request, "_mutate"),
|
|
1056
|
+
auth: Object.freeze({
|
|
1057
|
+
session: () => adminSession(context),
|
|
1058
|
+
signIn: ({ collections = [] } = {}) => signIn(context, collections),
|
|
1059
|
+
}),
|
|
1060
|
+
members: Object.freeze({
|
|
1061
|
+
session: () => memberSession(context),
|
|
1062
|
+
signIn: () =>
|
|
1063
|
+
leave(context, memberPlace(context), "/members/sign-in", {}),
|
|
1064
|
+
}),
|
|
1065
|
+
});
|
|
1066
|
+
}
|