Skip to content

Commit

Permalink
feat: switch to Google Sheets as backend
Browse files Browse the repository at this point in the history
  • Loading branch information
bchrobot committed Dec 19, 2019
1 parent a88b67c commit 7978867
Show file tree
Hide file tree
Showing 10 changed files with 1,175 additions and 210 deletions.
10 changes: 6 additions & 4 deletions .fly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ app-name: fly-shortener
config:
adminSecret:
fromSecret: adminSecret
airtableApiKey:
fromSecret: airtableApiKey
airtableBase:
fromSecret: airtableBase
googleSheetDocId:
fromSecret: googleSheetDocId
googleServiceAccountEmail:
fromSecret: googleServiceAccountEmail
googleServiceAccountKey:
fromSecret: googleServiceAccountKey
178 changes: 0 additions & 178 deletions index.js

This file was deleted.

3 changes: 3 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { route } from "./lib/router";

fly.http.respondWith(route);
52 changes: 52 additions & 0 deletions lib/google.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as jwt from "jwt-simple";

const SERVICE_ENDPOINT = "https://sheets.googleapis.com";

// We only care about From, To, and Is Regex
const A1_RANGE = "B2:D";

export type LinkRecord = { from: string; to: string; isRegex: boolean };

export const createAuthJwt = () => {
const claims = {
iss: app.config.googleServiceAccountEmail,
scope: "https://www.googleapis.com/auth/spreadsheets.readonly",
aud: "https://oauth2.googleapis.com/token",
exp: Math.floor(Date.now() / 1000) + 5 * 60,
iat: Math.floor(Date.now() / 1000)
};

return jwt.encode(claims, app.config.googleServiceAccountKey, "RS256");
};

export const getAccessToken = async () => {
const token = createAuthJwt();
const payload = {
grant_type: encodeURI("urn:ietf:params:oauth:grant-type:jwt-bearer"),
assertion: token
};
const auth = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
body: JSON.stringify(payload)
});
const { access_token } = await auth.json();
return access_token;
};

export const fetchEntries = async (): Promise<LinkRecord[]> => {
const accessToken = await getAccessToken();

const docId = app.config.googleSheetDocId;
const url = `${SERVICE_ENDPOINT}/v4/spreadsheets/${docId}/values/${A1_RANGE}?access_token=${accessToken}`;
const response = await fetch(url);
const { values: rows }: { values: string[] } = await response.json();

// Make sure that record has From and To values
return rows
.map(row => ({
from: row[0],
to: row[1],
isRegex: row[2] === "TRUE"
}))
.filter(({ from, to }) => !!from && !!to);
};
90 changes: 90 additions & 0 deletions lib/router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import * as Url from "url-parse";
import * as queryString from "query-string";
import cache from "@fly/cache";

import {
clearCache,
refreshCache,
lookupPath,
CACHE_TAG,
TTL_404
} from "./shortener";

export const parseReq = (req: Request) => {
const url = new Url(req.url);
const { pathname } = url;
const params = queryString.parse(url.query);
return { url, pathname, params };
};

const normalize = (str: string) => str.toLowerCase().trim();

/**
* Handle admin routing
* @param req The admin Request to be routed
*/
const routeAdmin = async (req: Request): Promise<Response> => {
const { pathname, params } = parseReq(req);

if (params.secret !== app.config.adminSecret) {
return new Response("Unauthorized", { status: 403 });
}

if (pathname.includes("/clear")) {
await clearCache();
return new Response("Cleared", { status: 200 });
}

if (pathname.includes("/refresh")) {
await clearCache();
await refreshCache();
return new Response("Refreshed", { status: 200 });
}

return new Response("Not Found", { status: 404 });
};

/**
* Handle short link redirects
* @param req The short link request to be routed
*/
const routeShortlink = async (req: Request): Promise<Response> => {
const { pathname } = parseReq(req);
const path = normalize(pathname);

// Check for existing match
let matchingResponse = await lookupPath(path);
if (matchingResponse) {
return matchingResponse;
}

// Refresh the cache if no match
await refreshCache();

// Re-check for match
matchingResponse = await lookupPath(path);
if (matchingResponse) {
return matchingResponse;
}

await cache.set(path, "404", { ttl: TTL_404, tags: [CACHE_TAG] });
return new Response("", { status: 404 });
};

/**
* Handle routing
* @param req The Request to be routed
*/
export const route = async (req: Request): Promise<Response> => {
const { pathname } = parseReq(req);

if (pathname === "/favicon.ico") {
return new Response("", { status: 404 });
}

if (pathname.includes("/admin")) {
return routeAdmin(req);
}

return routeShortlink(req);
};
Loading

0 comments on commit 7978867

Please sign in to comment.