alinea
Version:
Headless git-based CMS
275 lines (273 loc) • 8.16 kB
JavaScript
import {
package_default
} from "../chunks/chunk-NWJMNYVI.js";
import "../chunks/chunk-NZLE2WMY.js";
// src/cloud/CloudRemote.ts
import { Response } from "@alinea/iso";
import { AuthAction } from "alinea/backend/Auth";
import { OAuth2 } from "alinea/backend/api/OAuth2";
import { Config } from "alinea/core/Config";
import {
formatDraftKey,
parseDraftKey
} from "alinea/core/Draft";
import { HttpError } from "alinea/core/HttpError";
import { ShaMismatchError } from "alinea/core/source/ShaMismatchError";
import { ReadonlyTree } from "alinea/core/source/Tree";
import { base64 } from "alinea/core/util/Encoding";
import { Workspace } from "alinea/core/Workspace";
import { AuthResultType } from "./AuthResult.js";
import { cloudConfig } from "./CloudConfig.js";
var CloudRemote = class extends OAuth2 {
#context;
#config;
constructor(context, config) {
const clientId = context.apiKey.split("_")[1];
super(context, config, {
clientId,
clientSecret: context.apiKey,
jwksUri: cloudConfig.jwks,
tokenEndpoint: cloudConfig.token,
authorizationEndpoint: cloudConfig.auth,
revocationEndpoint: cloudConfig.revocation
});
this.#context = context;
this.#config = config;
}
async getTreeIfDifferent(sha) {
const ctx = this.#context;
return parseOutcome(
fetch(
cloudConfig.tree,
json({
method: "POST",
headers: bearer(ctx),
body: JSON.stringify({
contentDir: Config.contentDir(this.#config),
sha
})
})
)
).then((tree) => {
return tree ? new ReadonlyTree(tree) : void 0;
});
}
async *getBlobs(shas) {
const ctx = this.#context;
const response = await fetch(cloudConfig.blobs, {
method: "POST",
body: JSON.stringify({ shas }),
headers: {
...bearer(ctx),
"content-type": "application/json",
accept: "multipart/form-data"
}
}).then(failOnHttpError);
const form = await response.formData();
for (const [key, value] of form.entries()) {
if (value instanceof Blob) {
const sha = key.slice(0, 40);
const blob = new Uint8Array(await value.arrayBuffer());
yield [sha, blob];
}
}
}
async write(request) {
const ctx = this.#context;
return parseOutcome(
fetch(
cloudConfig.write,
json({
method: "POST",
headers: bearer(ctx),
body: JSON.stringify({
contentDir: Config.contentDir(this.#config),
...request
})
})
)
).catch((error) => {
if (error instanceof HttpError && error.code === 409) {
const actual = "actualSha" in error && error.actualSha;
const expected = "expectedSha" in error && error.expectedSha;
if (actual && expected) throw new ShaMismatchError(actual, expected);
}
throw error;
});
}
async authenticate(request) {
const ctx = this.#context;
const config = this.#config;
const url = new URL(request.url);
const action = url.searchParams.get("auth");
switch (action) {
case AuthAction.Status: {
const token = ctx.apiKey?.split("_")[1];
if (!token)
return Response.json({
type: AuthResultType.MissingApiKey,
setupUrl: cloudConfig.setup
});
return super.authenticate(request);
}
// The cloud server will request a handshake confirmation on this route
case AuthAction.Handshake: {
const handShakeId = url.searchParams.get("handshake_id");
if (!handShakeId)
throw new HttpError(
400,
"Provide a valid handshake id to initiate handshake"
);
const body = {
handshake_id: handShakeId,
status: {
version: package_default.version,
roles: [
{
key: "editor",
label: "Editor",
description: "Can view and edit all pages"
}
],
enableOAuth2: true,
sourceDirectories: Object.values(config.workspaces).flatMap((workspace) => {
const { source, mediaDir } = Workspace.data(workspace);
return [source, mediaDir];
}).filter(Boolean)
}
};
const res = await fetch(cloudConfig.handshake, {
method: "POST",
body: JSON.stringify(body),
headers: {
"content-type": "application/json",
accept: "application/json",
...bearer(ctx)
}
}).catch((e) => {
throw new HttpError(500, `Could not reach handshake api: ${e}`);
});
if (res.status !== 200)
throw new HttpError(
res.status,
`Handshake failed: ${await res.text()}`
);
return new Response("alinea cloud handshake");
}
default:
return super.authenticate(request);
}
}
prepareUpload(file) {
const ctx = this.#context;
return parseOutcome(
fetch(
cloudConfig.upload,
json({
method: "POST",
headers: bearer(ctx),
body: JSON.stringify({ filename: file })
})
)
).then(({ upload, ...rest }) => {
return {
...rest,
method: upload.method,
url: upload.url
};
});
}
async getDraft(draftKey) {
const ctx = this.#context;
if (!validApiKey(ctx.apiKey)) return;
const { entryId, locale } = parseDraftKey(draftKey);
const data = await parseOutcome(
fetch(`${cloudConfig.drafts}/${draftKey}`, json({ headers: bearer(ctx) }))
);
return data?.update ? {
entryId,
locale,
fileHash: data.fileHash,
draft: base64.parse(data.update)
} : void 0;
}
async storeDraft(draft) {
const ctx = this.#context;
const key = formatDraftKey({ id: draft.entryId, locale: draft.locale });
return parseOutcome(
fetch(
`${cloudConfig.drafts}/${key}`,
json({
method: "PUT",
headers: bearer(ctx),
body: JSON.stringify({
fileHash: draft.fileHash,
update: base64.stringify(draft.draft)
})
})
)
);
}
revisions(file) {
const ctx = this.#context;
return parseOutcome(
fetch(
`${cloudConfig.history}?${new URLSearchParams({ file })}`,
json({ headers: bearer(ctx) })
)
);
}
revisionData(file, revisionId) {
const ctx = this.#context;
return parseOutcome(
fetch(
`${cloudConfig.history}?${new URLSearchParams({ file, ref: revisionId })}`,
json({ headers: bearer(ctx) })
)
);
}
};
function validApiKey(apiKey) {
return Boolean(apiKey?.startsWith("alineapk"));
}
function bearer(ctx) {
return { authorization: `Bearer ${"token" in ctx ? ctx.token : ctx.apiKey}` };
}
function json(init = {}) {
const headers = new Headers(init.headers);
if (init.body) headers.set("content-type", "application/json");
headers.set("accept", "application/json");
return { ...init, headers };
}
async function failOnHttpError(response) {
if (!response.ok) throw new HttpError(response.status, await response.text());
return response;
}
async function parseOutcome(expected) {
const response = await expected;
const contentType = response.headers.get("content-type");
const isJson = contentType?.includes("application/json");
if (!response.ok && !isJson) {
const message = await response.text();
throw new HttpError(response.status, message);
}
const output = await response.json();
if (output.success) {
return output.data;
}
if (output.error) {
if (typeof output.error === "object") {
const error = new HttpError(response.status, "Unexpected error");
Object.assign(error, output.error);
throw error;
}
throw new HttpError(response.status, output.error);
}
throw new HttpError(
response.status,
`Unexpected response: ${JSON.stringify(output)}`
);
}
export {
CloudRemote
};