Skip to content

Commit

Permalink
add Posit Cloud PublishProvider
Browse files Browse the repository at this point in the history
  • Loading branch information
mslynch committed Jun 27, 2023
1 parent 729555c commit 5127c05
Show file tree
Hide file tree
Showing 9 changed files with 645 additions and 12 deletions.
4 changes: 4 additions & 0 deletions news/changelog-1.4.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@
- ([#5630](https://github.com/quarto-dev/quarto-cli/issues/5630)): Properly form sharing URL for books
- ([#5454](https://github.com/quarto-dev/quarto-cli/issues/5454)): Fix errors previewing with formats such as `asciidoc` are added to book projects.

## Publishing

- ([#5436](https://github.com/quarto-dev/quarto-cli/issues/5436)): Add support for publishing to Posit Cloud.

## Other Fixes and Improvements

- ([#5785](https://github.com/quarto-dev/quarto-cli/issues/5785)): Don't process juptyer notebook markdown into metadata when embedding notebooks into documents.
Expand Down
6 changes: 3 additions & 3 deletions src/core/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,13 @@ export function progressBar(total: number, prefixMessage?: string): {
};
}

export async function withSpinner(
export async function withSpinner<T>(
options: SpinnerOptions,
op: () => Promise<void>,
op: () => Promise<T>,
) {
const cancel = spinner(options.message);
try {
await op();
return await op();
} finally {
cancel(options.doneMessage);
}
Expand Down
20 changes: 15 additions & 5 deletions src/core/hash.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
/*
* hash.ts
*
* Copyright (C) 2020-2022 Posit Software, PBC
*
*/
* hash.ts
*
* Copyright (C) 2020-2022 Posit Software, PBC
*/

import { crypto } from "https://deno.land/[email protected]/crypto/mod.ts";
import blueimpMd5 from "blueimpMd5";

export function md5Hash(content: string) {
return blueimpMd5(content);
}

export function md5HashBytes(content: Uint8Array) {
const buffer = crypto.subtle.digestSync(
"MD5",
content,
);
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

// Simple insecure hash for a string
export function insecureHash(content: string) {
let hash = 0;
Expand Down
13 changes: 10 additions & 3 deletions src/publish/rsconnect/bundle.ts → src/publish/common/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,14 @@ import { Tar } from "archive/tar.ts";

import { PublishFiles } from "../provider-types.ts";
import { TempContext } from "../../core/temp-types.ts";
import { md5Hash } from "../../core/hash.ts";
import { md5HashBytes } from "../../core/hash.ts";

/** Creates a compressed bundle file in the format required by Posit Connect and Cloud.
* @param type Whether this is a site or document.
* @param files Information on what should be included in the bundle.
* @param tempContext Temporary directory where file operations will be performed.
* @returns The absolute path of the bundle file.
*/
export async function createBundle(
type: "site" | "document",
files: PublishFiles,
Expand All @@ -24,8 +30,9 @@ export async function createBundle(
for (const file of files.files) {
const filePath = join(files.baseDir, file);
if (Deno.statSync(filePath).isFile) {
const f = Deno.readTextFileSync(filePath);
manifestFiles[file] = { checksum: md5Hash(f) as string };
const fBytes = Deno.readFileSync(filePath);
const checksum = md5HashBytes(fBytes);
manifestFiles[file] = { checksum };
}
}

Expand Down
263 changes: 263 additions & 0 deletions src/publish/posit-cloud/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
/*
* index.ts
*
* Copyright (C) 2020-2023 Posit Software, PBC
*/

import { ApiError } from "../../types.ts";
import {
Application,
Bundle,
Content,
OutputRevision,
Task,
User,
} from "./types.ts";

import { md5Hash } from "../../../core/hash.ts";

import { crypto } from "https://deno.land/[email protected]/crypto/mod.ts";

import {
decode as base64Decode,
encode as base64Encode,
} from "encoding/base64.ts";

interface FetchOpts {
body?: string;
queryParams?: Record<string, string>;
}

export class PositCloudClient {
private key_: CryptoKey | undefined;

public constructor(
private readonly server_: string,
private readonly token_: string,
private readonly token_secret_: string,
) {
this.server_ = server_;
this.token_ = token_;
this.token_secret_ = token_secret_;
}

public getUser(): Promise<User> {
return this.get<User>("users/me");
}

public getApplication(id: string): Promise<Application> {
return this.get<Application>(`applications/${id}`);
}

public createOutput(
name: string,
spaceId: number | null,
projectId: number | null,
): Promise<Content> {
return this.post<Content>(
"outputs",
JSON.stringify({
name: name,
application_type: "static",
space: spaceId,
project: projectId,
}),
);
}

public setBundleReady(bundleId: number) {
return this.post(
`bundles/${bundleId}/status`,
JSON.stringify({ status: "ready" }),
);
}
public createBundle(
applicationId: number,
contentLength: number,
checksum: string,
): Promise<Bundle> {
return this.post<Bundle>(
`bundles`,
JSON.stringify({
"application": applicationId,
"content_type": "application/x-tar",
"content_length": contentLength,
"checksum": checksum,
}),
);
}

public getTask(id: number) {
return this.get<Task>(`tasks/${id}`, { legacy: "false" });
}

public createRevision(outputId: number) {
return this.post<OutputRevision>(`outputs/${outputId}/revisions`);
}

public getContent(id: number | string): Promise<Content> {
return this.get<Content>(`content/${id}`);
}

public deployApplication(
applicationId: number,
bundleId: number,
): Promise<Task> {
return this.post<Task>(
`applications/${applicationId}/deploy`,
JSON.stringify({ "bundle": bundleId, rebuild: false }),
);
}

private get = <T>(
path: string,
queryParams?: Record<string, string>,
): Promise<T> => this.fetch<T>("GET", path, { queryParams });
private post = <T>(path: string, body?: string): Promise<T> =>
this.fetch<T>("POST", path, { body });

private fetch = async <T>(
method: string,
path: string,
opts: FetchOpts,
): Promise<T> => {
const fullPath = `/v1/${path}`;

const pathAndQuery = opts.queryParams
? `${fullPath}?${opts.queryParams}`
: fullPath;

const url = `${this.server_}${pathAndQuery}`;
const authHeaders = await this.authorizationHeaders(
method,
fullPath,
opts.body,
);
const contentTypeHeader: HeadersInit = opts.body
? { "Content-Type": "application/json" }
: {};

const headers = {
Accept: "application/json",
...authHeaders,
...contentTypeHeader,
};

const requestInit: RequestInit = {
method,
headers,
body: opts.body,
redirect: "manual",
};
const request = new Request(url, requestInit);

return await this.handleResponse<T>(
await fetch(request),
);
};

private handleResponse = async <T>(
response: Response,
): Promise<T> => {
if (response.status >= 200 && response.status < 400) {
if (
response.headers.get("Content-Type")?.startsWith("application/json")
) {
return await response.json() as unknown as T;
} else {
return await response.text() as unknown as T;
}
} else if (response.status >= 400) {
throw new ApiError(response.status, response.statusText);
} else {
throw new Error(`${response.status} - ${response.statusText}`);
}
};

private authorizationHeaders = async (
method: string,
path: string,
body?: string,
): Promise<HeadersInit> => {
const date = new Date().toUTCString();
const checksum = md5Hash(body || "");

const canonicalRequest = [
method,
path,
date,
checksum,
].join("\n");

const signature = await this.getSignature(canonicalRequest);

return {
"X-Auth-Token": this.token_,
"X-Auth-Signature": `${signature}; version=1`,
"Date": date,
"X-Content-Checksum": checksum,
};
};

private async getSignature(data: string): Promise<string> {
if (!this.key_) {
const decodedTokenSecret = base64Decode(this.token_secret_);
this.key_ = await crypto.subtle.importKey(
"raw",
decodedTokenSecret,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
}

const canonicalRequestBytes = new TextEncoder().encode(data);
const signatureArrayBuffer = await crypto.subtle.sign(
"HMAC",
this.key_,
canonicalRequestBytes,
);
const signatureBytes = Array.from(new Uint8Array(signatureArrayBuffer));

const signatureHex = signatureBytes
.map((b) => b.toString(16).padStart(2, "0"))
.join("");

const signatureB64 = base64Encode(signatureHex);

return signatureB64;
}
}

export class UploadClient {
public constructor(
private readonly url_: string,
) {
this.url_ = url_;
}

upload = async (
fileBody: Blob,
bundleSize: number,
presignedChecksum: string,
) => {
const response = await fetch(this.url_, {
method: "PUT",
headers: {
Accept: "application/json",
"content-type": "application/x-tar",
"content-length": bundleSize.toString(),
"content-md5": presignedChecksum,
},
body: fileBody,
});

if (!response.ok) {
if (response.status !== 200) {
throw new ApiError(response.status, response.statusText);
} else {
throw new Error(`${response.status} - ${response.statusText}`);
}
}
};
}
41 changes: 41 additions & 0 deletions src/publish/posit-cloud/api/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* types.ts
*
* Copyright (C) 2020-2023 Posit Software, PBC
*/

export type User = {
id: number;
email: string;
};

export type Content = {
id: number;
url: string;
space_id: number;
source_id: number;
};

export type OutputRevision = {
id: number;
application_id: number;
};

export type Application = {
id: number;
content_id: number;
};

export type Bundle = {
id: number;
presigned_url: string;
presigned_checksum: string;
};

export type Task = {
task_id: number;
finished: boolean;
description: string;
state: string;
error?: string;
};
Loading

0 comments on commit 5127c05

Please sign in to comment.