2024-07-30 23:33:33 +08:00
|
|
|
import type { SpacePrimitives } from "./space_primitives.ts";
|
|
|
|
import type { FileMeta } from "../../plug-api/types.ts";
|
2024-09-25 23:06:43 +08:00
|
|
|
import {
|
|
|
|
flushCachesAndUnregisterServiceWorker,
|
|
|
|
unregisterServiceWorkers,
|
|
|
|
} from "../sw_util.ts";
|
2024-08-20 15:38:56 +08:00
|
|
|
import { encodePageURI } from "@silverbulletmd/silverbullet/lib/page_ref";
|
2022-03-20 16:56:28 +08:00
|
|
|
|
2024-08-04 14:24:38 +08:00
|
|
|
const defaultFetchTimeout = 30000; // 30 seconds
|
2024-07-22 23:36:02 +08:00
|
|
|
|
2022-04-07 21:21:30 +08:00
|
|
|
export class HttpSpacePrimitives implements SpacePrimitives {
|
2023-01-13 22:41:29 +08:00
|
|
|
constructor(
|
2023-05-24 02:53:53 +08:00
|
|
|
readonly url: string,
|
|
|
|
readonly expectedSpacePath?: string,
|
2023-12-14 00:52:56 +08:00
|
|
|
private bearerToken?: string,
|
2023-01-13 22:41:29 +08:00
|
|
|
) {
|
2022-04-30 00:54:27 +08:00
|
|
|
}
|
|
|
|
|
2023-05-24 02:53:53 +08:00
|
|
|
public async authenticatedFetch(
|
2022-04-30 00:54:27 +08:00
|
|
|
url: string,
|
2023-05-24 02:53:53 +08:00
|
|
|
options: RequestInit,
|
2024-08-04 14:24:38 +08:00
|
|
|
fetchTimeout: number = defaultFetchTimeout,
|
2022-04-30 00:54:27 +08:00
|
|
|
): Promise<Response> {
|
2023-05-24 02:53:53 +08:00
|
|
|
if (!options.headers) {
|
|
|
|
options.headers = {};
|
|
|
|
}
|
2023-07-28 21:34:12 +08:00
|
|
|
options.headers = {
|
|
|
|
...options.headers,
|
|
|
|
"X-Sync-Mode": "true",
|
|
|
|
};
|
2023-12-14 00:52:56 +08:00
|
|
|
if (this.bearerToken) {
|
|
|
|
options.headers = {
|
|
|
|
...options.headers,
|
|
|
|
"Authorization": `Bearer ${this.bearerToken}`,
|
|
|
|
};
|
|
|
|
}
|
2023-01-13 22:41:29 +08:00
|
|
|
|
2023-07-27 17:41:44 +08:00
|
|
|
try {
|
2024-07-22 23:36:02 +08:00
|
|
|
options.signal = AbortSignal.timeout(fetchTimeout);
|
2024-09-10 00:36:54 +08:00
|
|
|
options.redirect = "manual";
|
2023-07-27 17:41:44 +08:00
|
|
|
const result = await fetch(url, options);
|
|
|
|
if (result.status === 503) {
|
|
|
|
throw new Error("Offline");
|
|
|
|
}
|
2024-09-10 00:36:54 +08:00
|
|
|
const redirectHeader = result.headers.get("location");
|
|
|
|
|
2024-09-25 23:06:43 +08:00
|
|
|
if (result.type === "opaqueredirect" && !redirectHeader) {
|
|
|
|
// This is a scenario where the server sent a redirect, but this redirect is not visible to the client, likely due to CORS
|
|
|
|
// The best we can do is to reload the page and hope that the server will redirect us to the correct location
|
|
|
|
alert(
|
|
|
|
"You are not authenticated, reloading to reauthenticate",
|
|
|
|
);
|
|
|
|
console.log("Unregistering service workers", redirectHeader);
|
|
|
|
await unregisterServiceWorkers();
|
|
|
|
location.reload();
|
|
|
|
// Let's throw to avoid any further processing
|
|
|
|
throw Error("Not authenticated");
|
|
|
|
}
|
|
|
|
|
2024-09-10 00:36:54 +08:00
|
|
|
// console.log("Got response", result.status, result.statusText, result.url);
|
|
|
|
|
2024-08-18 18:45:33 +08:00
|
|
|
// Attempting to handle various authentication proxies
|
2024-09-10 00:36:54 +08:00
|
|
|
if (result.status >= 300 && result.status < 400) {
|
|
|
|
if (redirectHeader) {
|
|
|
|
// Got a redirect
|
2024-09-25 23:06:43 +08:00
|
|
|
alert(
|
|
|
|
"Received an authentication redirect, redirecting to URL: " +
|
|
|
|
redirectHeader,
|
|
|
|
);
|
2024-09-10 00:36:54 +08:00
|
|
|
location.href = redirectHeader;
|
|
|
|
throw new Error("Redirected");
|
|
|
|
} else {
|
|
|
|
console.error("Got a redirect status but no location header", result);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// Check for unauthorized status
|
|
|
|
if (result.status === 401 || result.status === 403) {
|
|
|
|
// If it came with a redirect header, we'll redirect to that URL
|
|
|
|
if (redirectHeader) {
|
2024-03-24 03:02:16 +08:00
|
|
|
console.log(
|
|
|
|
"Received unauthorized status and got a redirect via the API so will redirect to URL",
|
|
|
|
result.url,
|
|
|
|
);
|
2024-09-10 00:36:54 +08:00
|
|
|
alert("You are not authenticated, redirecting to: " + redirectHeader);
|
|
|
|
location.href = redirectHeader;
|
2024-03-24 03:02:16 +08:00
|
|
|
throw new Error("Not authenticated");
|
|
|
|
} else {
|
2024-09-10 00:36:54 +08:00
|
|
|
// If not, let's reload
|
|
|
|
alert(
|
|
|
|
"You are not authenticated, going to reload and hope that that kicks off authentication",
|
|
|
|
);
|
|
|
|
location.reload();
|
|
|
|
throw new Error("Not authenticated, got 401");
|
2024-03-24 03:02:16 +08:00
|
|
|
}
|
2023-07-27 17:41:44 +08:00
|
|
|
}
|
|
|
|
return result;
|
|
|
|
} catch (e: any) {
|
2023-07-27 23:02:53 +08:00
|
|
|
// Errors when there is no internet connection:
|
|
|
|
//
|
|
|
|
// * Firefox: NetworkError when attempting to fetch resource (with SW and without)
|
|
|
|
// * Safari (service worker enabled): FetchEvent.respondWith received an error: TypeError: Load failed
|
|
|
|
// * Safari (no service worker): Load failed
|
|
|
|
// * Chrome: Failed to fetch
|
|
|
|
//
|
2023-07-27 17:41:44 +08:00
|
|
|
// Common substrings: "fetch" "load failed"
|
|
|
|
const errorMessage = e.message.toLowerCase();
|
|
|
|
if (
|
|
|
|
errorMessage.includes("fetch") || errorMessage.includes("load failed")
|
|
|
|
) {
|
2024-01-21 05:53:51 +08:00
|
|
|
console.error(
|
|
|
|
"Got error fetching, throwing offline",
|
|
|
|
url,
|
2024-01-24 18:58:33 +08:00
|
|
|
e,
|
2024-01-21 05:53:51 +08:00
|
|
|
);
|
2023-07-27 17:41:44 +08:00
|
|
|
throw new Error("Offline");
|
|
|
|
}
|
|
|
|
throw e;
|
2022-04-30 00:54:27 +08:00
|
|
|
}
|
2022-03-20 16:56:28 +08:00
|
|
|
}
|
|
|
|
|
2023-01-13 22:41:29 +08:00
|
|
|
async fetchFileList(): Promise<FileMeta[]> {
|
2023-07-06 22:47:50 +08:00
|
|
|
const resp = await this.authenticatedFetch(`${this.url}/index.json`, {
|
2022-04-06 21:39:20 +08:00
|
|
|
method: "GET",
|
|
|
|
});
|
2022-03-31 20:28:07 +08:00
|
|
|
|
2023-05-24 02:53:53 +08:00
|
|
|
if (
|
2023-07-06 22:47:50 +08:00
|
|
|
resp.status === 200 &&
|
2023-05-24 02:53:53 +08:00
|
|
|
this.expectedSpacePath &&
|
2023-07-30 17:30:01 +08:00
|
|
|
resp.headers.get("X-Space-Path") &&
|
2023-05-24 02:53:53 +08:00
|
|
|
resp.headers.get("X-Space-Path") !== this.expectedSpacePath
|
|
|
|
) {
|
2023-07-06 22:47:50 +08:00
|
|
|
console.log("Expected space path", this.expectedSpacePath);
|
|
|
|
console.log("Got space path", resp.headers.get("X-Space-Path"));
|
2023-05-24 02:53:53 +08:00
|
|
|
await flushCachesAndUnregisterServiceWorker();
|
|
|
|
alert("Space folder path different on server, reloading the page");
|
|
|
|
location.reload();
|
|
|
|
}
|
|
|
|
|
|
|
|
return resp.json();
|
2022-03-20 16:56:28 +08:00
|
|
|
}
|
|
|
|
|
2022-09-12 20:50:37 +08:00
|
|
|
async readFile(
|
|
|
|
name: string,
|
2023-05-24 02:53:53 +08:00
|
|
|
): Promise<{ data: Uint8Array; meta: FileMeta }> {
|
2023-01-13 22:41:29 +08:00
|
|
|
const res = await this.authenticatedFetch(
|
2024-08-20 15:38:56 +08:00
|
|
|
`${this.url}/${encodePageURI(name)}`,
|
2023-01-13 22:41:29 +08:00
|
|
|
{
|
|
|
|
method: "GET",
|
2024-01-09 00:08:26 +08:00
|
|
|
headers: {
|
|
|
|
// This header won't trigger CORS preflight requests but can be interpreted on the server
|
|
|
|
Accept: "application/octet-stream",
|
|
|
|
},
|
2023-01-13 22:41:29 +08:00
|
|
|
},
|
|
|
|
);
|
2023-07-06 22:47:50 +08:00
|
|
|
if (res.status === 404) {
|
2023-05-24 02:53:53 +08:00
|
|
|
throw new Error(`Not found`);
|
2022-09-12 20:50:37 +08:00
|
|
|
}
|
2022-03-31 20:28:07 +08:00
|
|
|
return {
|
2023-05-24 02:53:53 +08:00
|
|
|
data: new Uint8Array(await res.arrayBuffer()),
|
2022-09-12 20:50:37 +08:00
|
|
|
meta: this.responseToMeta(name, res),
|
2022-03-31 20:28:07 +08:00
|
|
|
};
|
2022-03-20 16:56:28 +08:00
|
|
|
}
|
|
|
|
|
2022-09-12 20:50:37 +08:00
|
|
|
async writeFile(
|
2022-03-31 20:28:07 +08:00
|
|
|
name: string,
|
2023-05-24 02:53:53 +08:00
|
|
|
data: Uint8Array,
|
|
|
|
_selfUpdate?: boolean,
|
2023-07-02 17:25:32 +08:00
|
|
|
meta?: FileMeta,
|
2022-09-12 20:50:37 +08:00
|
|
|
): Promise<FileMeta> {
|
2023-01-13 22:41:29 +08:00
|
|
|
const headers: Record<string, string> = {
|
|
|
|
"Content-Type": "application/octet-stream",
|
|
|
|
};
|
2023-07-02 17:25:32 +08:00
|
|
|
if (meta) {
|
2023-11-03 16:38:04 +08:00
|
|
|
headers["X-Created"] = "" + meta.created;
|
2023-07-02 17:25:32 +08:00
|
|
|
headers["X-Last-Modified"] = "" + meta.lastModified;
|
|
|
|
headers["X-Perm"] = "" + meta.perm;
|
2023-01-13 22:41:29 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
const res = await this.authenticatedFetch(
|
2024-08-20 15:38:56 +08:00
|
|
|
`${this.url}/${encodePageURI(name)}`,
|
2023-01-13 22:41:29 +08:00
|
|
|
{
|
|
|
|
method: "PUT",
|
|
|
|
headers,
|
2023-05-24 02:53:53 +08:00
|
|
|
body: data,
|
2022-09-12 20:50:37 +08:00
|
|
|
},
|
2023-01-13 22:41:29 +08:00
|
|
|
);
|
2022-09-12 20:50:37 +08:00
|
|
|
const newMeta = this.responseToMeta(name, res);
|
2022-04-06 21:39:20 +08:00
|
|
|
return newMeta;
|
2022-03-31 20:28:07 +08:00
|
|
|
}
|
2022-03-20 16:56:28 +08:00
|
|
|
|
2022-09-12 20:50:37 +08:00
|
|
|
async deleteFile(name: string): Promise<void> {
|
2023-01-13 22:41:29 +08:00
|
|
|
const req = await this.authenticatedFetch(
|
2024-08-20 15:38:56 +08:00
|
|
|
`${this.url}/${encodePageURI(name)}`,
|
2023-01-13 22:41:29 +08:00
|
|
|
{
|
|
|
|
method: "DELETE",
|
|
|
|
},
|
|
|
|
);
|
2023-07-06 22:47:50 +08:00
|
|
|
if (req.status !== 200) {
|
2022-09-12 20:50:37 +08:00
|
|
|
throw Error(`Failed to delete file: ${req.statusText}`);
|
2022-03-31 20:28:07 +08:00
|
|
|
}
|
2022-03-20 16:56:28 +08:00
|
|
|
}
|
|
|
|
|
2022-09-12 20:50:37 +08:00
|
|
|
async getFileMeta(name: string): Promise<FileMeta> {
|
2023-01-13 22:41:29 +08:00
|
|
|
const res = await this.authenticatedFetch(
|
2024-08-20 15:38:56 +08:00
|
|
|
`${this.url}/${encodePageURI(name)}`,
|
2023-07-10 19:06:57 +08:00
|
|
|
// This used to use HEAD, but it seems that Safari on iOS is blocking cookies/credentials to be sent along with HEAD requests
|
2023-08-08 21:00:18 +08:00
|
|
|
// so we'll use GET instead with a magic header which the server may or may not use to omit the body.
|
2023-01-13 22:41:29 +08:00
|
|
|
{
|
2023-07-10 19:06:57 +08:00
|
|
|
method: "GET",
|
2023-08-08 21:00:18 +08:00
|
|
|
headers: {
|
|
|
|
"X-Get-Meta": "true",
|
|
|
|
},
|
2023-01-13 22:41:29 +08:00
|
|
|
},
|
|
|
|
);
|
2023-07-06 22:47:50 +08:00
|
|
|
if (res.status === 404) {
|
2023-05-24 02:53:53 +08:00
|
|
|
throw new Error(`Not found`);
|
2022-09-12 20:50:37 +08:00
|
|
|
}
|
2023-10-06 00:24:12 +08:00
|
|
|
if (!res.ok) {
|
|
|
|
throw new Error(`Failed to get file meta: ${res.statusText}`);
|
|
|
|
}
|
2022-09-12 20:50:37 +08:00
|
|
|
return this.responseToMeta(name, res);
|
|
|
|
}
|
|
|
|
|
|
|
|
private responseToMeta(name: string, res: Response): FileMeta {
|
|
|
|
return {
|
|
|
|
name,
|
2023-08-08 21:00:18 +08:00
|
|
|
// The server may set a custom X-Content-Length header in case a GET request was sent with X-Get-Meta, in which case the body may be omitted
|
|
|
|
size: res.headers.has("X-Content-Length")
|
|
|
|
? +res.headers.get("X-Content-Length")!
|
|
|
|
: +res.headers.get("Content-Length")!,
|
2022-09-12 20:50:37 +08:00
|
|
|
contentType: res.headers.get("Content-type")!,
|
2023-11-03 16:38:04 +08:00
|
|
|
created: +(res.headers.get("X-Created") || "0"),
|
2022-10-10 22:20:29 +08:00
|
|
|
lastModified: +(res.headers.get("X-Last-Modified") || "0"),
|
2023-08-16 02:15:27 +08:00
|
|
|
perm: (res.headers.get("X-Permission") as "rw" | "ro") || "ro",
|
2022-09-12 20:50:37 +08:00
|
|
|
};
|
|
|
|
}
|
2023-07-27 18:37:39 +08:00
|
|
|
|
|
|
|
// Used to check if the server is reachable and the user is authenticated
|
|
|
|
// If not: throws an error or invokes a redirect
|
|
|
|
async ping() {
|
2024-08-04 14:24:38 +08:00
|
|
|
await this.authenticatedFetch(`${this.url}/.ping`, {
|
2023-07-28 21:34:12 +08:00
|
|
|
method: "GET",
|
|
|
|
headers: {
|
|
|
|
Accept: "application/json",
|
|
|
|
},
|
2024-08-04 14:24:38 +08:00
|
|
|
}, 5000);
|
2023-07-27 18:37:39 +08:00
|
|
|
}
|
2022-03-20 16:56:28 +08:00
|
|
|
}
|