Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support private snips #1

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 115 additions & 31 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import crypto from "crypto";
import { Client, ConnectConfig } from "ssh2";
import { promisify } from "util";
import { invariant } from "./invariant";

const generateKeyPair = promisify(crypto.generateKeyPair);

Expand All @@ -12,6 +13,26 @@ const ANSI_CODE_PATTERN =
/** Removes any ANSI colour codes from the given input. */
export const stripAnsi = (str: string) => str.replace(ANSI_CODE_PATTERN, "");

export const extractId = (str: string) =>
str.match(/id: ([A-Za-z0-9_-]{10})/)?.[1] ?? null;

export const extractSize = (str: string) => {
const match = str.match(/size: ([0-9]+) ([A-Z]{1})/);

return {
value: match?.[1] ? parseInt(match?.[1]) : null,
unit: match?.[2] ?? null,
};
};

export const extractType = (str: string) =>
str.match(/type: ([a-z]+)/)?.[1] ?? null;

export const extractVisibility = (str: string) =>
str.match(/visibility: ([a-z]+)/)?.[1] ?? null;

export const extractSsh = (str: string) => str.match(/ssh (.+)/)?.[1] ?? null;

/** Finds and returns the first URL found in the given input. */
export const extractUrl = (str: string) =>
str.match(/https?:\/\/[^\s]+/)?.[0] ?? null;
Expand Down Expand Up @@ -41,22 +62,73 @@ const connectClient = async (clientOptions: ConnectConfig): Promise<Client> => {
});
};

const writeWithCommand = (client: Client, command: string[], content: string) =>
new Promise<string>((resolve, reject) => {
client.exec(command.join(" "), (err, channel) => {
if (err) {
return reject(err);
}

const data: Buffer[] = [];

channel.on("data", (chunk: Buffer) => {
data.push(chunk);
});

channel.on("close", () => {
const result = data.map((chunk) => chunk.toString("utf8")).join("");

resolve(result);
});

channel.end(content);
});
});

class Snip {
constructor(
public id: string,
public size: { value: number; unit: string },
public type: string,
public visibility: "public" | "private",
public ssh: string,
public url: string | null,
private clientOptions: ConnectConfig
) {}

async sign() {
const client = await connectClient({
...this.clientOptions,
username: `f:${this.id}`,
});

const result = await writeWithCommand(client, ["sign", "-ttl", "5m"], "");

client.destroy();

console.log(result);

return result;
}
}

/** A client for uploading to https://snips.sh (or a self-hosted instance!) */
export class Snips {
private clientOptions: ConnectConfig = {};

/**
* @param options - Options to pass to the SSH2 client. Defaults to using
* snips.sh host but this can be overridden if self-hosting. If no private key
* is provided, one will be generated. Please note that if you don't store the
* generated key, you won't be able to manage created content afterwards.
* @param clientOptions - Options to pass to the SSH2 client. Defaults to
* using snips.sh host but this can be overridden if self-hosting. If no
* private key is provided, one will be generated. Please note that if you
* don't store the generated key, you won't be able to manage created content
* afterwards.
*/
constructor(options?: Partial<ConnectConfig>) {
constructor(clientOptions?: Partial<ConnectConfig>) {
this.clientOptions = {
host: "snips.sh",
username: "ubuntu",
privateKey: undefined,
...options,
...clientOptions,
};
}

Expand Down Expand Up @@ -87,45 +159,57 @@ export class Snips {
* @param content - Body of snip to upload.
* @returns URL of uploaded snip.
*/
async upload(content: string): Promise<{ id: string; url: string }> {
async upload(content: string, { isPrivate = false } = {}) {
const clientOptions = await this.setup();

const client = await connectClient(clientOptions);

const response = await new Promise<string>((resolve, reject) => {
client.exec("", (err, channel) => {
if (err) {
return reject(err);
}
const command: string[] = [];

const data: Buffer[] = [];
if (isPrivate) {
command.push("-private");
}

channel.on("data", (chunk: Buffer) => {
data.push(chunk);
});
const response = await writeWithCommand(client, command, content);

channel.on("close", () => {
const result = data.map((chunk) => chunk.toString("utf8")).join("");
client.destroy();

resolve(result);
});
console.log(response);

channel.end(content);
});
});
const sanitizedResponse = stripAnsi(response);

client.destroy();
const id: string | null = extractId(sanitizedResponse);
invariant(id, "Failed to extract ID from response");

const sanitizedResponse = stripAnsi(response);
const { unit, value } = extractSize(sanitizedResponse);
invariant(value && unit, "Failed to extract size from response");

const url = extractUrl(sanitizedResponse);
const type = extractType(sanitizedResponse);
invariant(type, "Failed to extract type from response");

if (!url) {
throw new Error("Response didn't contain a URL. How bizarre.");
}
const visibility = extractVisibility(sanitizedResponse);
invariant(
visibility === "public" || visibility === "private",
"Failed to extract visibility from response"
);

const id = url.split("/").pop() ?? "";
const ssh = extractSsh(sanitizedResponse);
invariant(ssh, "Failed to extract SSH host from response");

return { id, url };
const url = extractUrl(sanitizedResponse);
invariant(
(url && visibility === "public") || (!url && visibility === "private"),
`Visibility is ${visibility} but ${url ? "found" : "didn't find"} URL`
);

return new Snip(
id,
{ unit, value },
type,
visibility,
ssh,
url,
clientOptions
);
}
}
8 changes: 8 additions & 0 deletions src/invariant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function invariant(
condition: unknown,
message: string
): asserts condition {
if (!condition) {
throw new Error(message);
}
}
34 changes: 32 additions & 2 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,17 @@ describe("extractUrl", () => {
});

describe("Snips", () => {
it("uploads a snip", async () => {
const { id, url } = await new Snips().upload("Hello!");
it("uploads a public snip", async () => {
const { id, size, ssh, type, visibility, url } = await new Snips().upload("Hello!");

expect(id).toMatch(/[A-Za-z0-9_-]{10}/);
expect(size).toMatchObject({
value: 6,
unit: "B",
});
expect(ssh).toBe(`f:${id}@snips.sh`);
expect(type).toBe("plaintext");
expect(visibility).toBe("public");
expect(url).toBe(`https://snips.sh/f/${id}`);
}, 10000);

Expand All @@ -77,4 +84,27 @@ describe("Snips", () => {
expect(secondId).toMatch(/[A-Za-z0-9_-]{10}/);
expect(secondUrl).toBe(`https://snips.sh/f/${secondId}`);
}, 10000);

it("uploads a private snip", async () => {
const { id, size, ssh, type, visibility, url } = await new Snips().upload(
"Hello!",
{ isPrivate: true }
);

expect(id).toMatch(/[A-Za-z0-9_-]{10}/);
expect(size).toMatchObject({
value: 6,
unit: "B",
});
expect(ssh).toBe(`f:${id}@snips.sh`);
expect(type).toBe("plaintext");
expect(visibility).toBe("private");
expect(url).toBe(null);
}, 10000);

it("signs a private snip", async () => {
const snip = await new Snips().upload("Hello!", { isPrivate: true });

const result = await snip.sign();
}, 10000);
});