1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-09-13 23:55:35 -04:00

v5: Revenge of the JavaScript 🦸 (#711)

Hugo ➡️ Next.js
This commit is contained in:
2021-12-30 08:18:41 -05:00
committed by GitHub
parent b7505fa260
commit 9979e1bf3f
577 changed files with 8019 additions and 11864 deletions

112
pages/api/contact.ts Normal file
View File

@@ -0,0 +1,112 @@
import * as Sentry from "@sentry/node";
import fetch from "node-fetch";
import queryString from "query-string";
import type { NextApiRequest, NextApiResponse } from "next";
Sentry.init({
dsn: process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN || "",
environment: process.env.NODE_ENV || process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV || "",
});
// fallback to dummy secret for testing: https://docs.hcaptcha.com/#integration-testing-test-keys
const HCAPTCHA_SITE_KEY =
process.env.HCAPTCHA_SITE_KEY || process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY || "10000000-ffff-ffff-ffff-000000000001";
const HCAPTCHA_SECRET_KEY = process.env.HCAPTCHA_SECRET_KEY || "0x0000000000000000000000000000000000000000";
const HCAPTCHA_API_ENDPOINT = "https://hcaptcha.com/siteverify";
const { AIRTABLE_API_KEY, AIRTABLE_BASE } = process.env;
const AIRTABLE_API_ENDPOINT = "https://api.airtable.com/v0/";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
// permissive access control headers
res.setHeader("Access-Control-Allow-Methods", "POST");
res.setHeader("Access-Control-Allow-Origin", "*");
// disable caching on both ends
res.setHeader("Cache-Control", "private, no-cache, no-store, must-revalidate");
res.setHeader("Expires", 0);
res.setHeader("Pragma", "no-cache");
if (req.method !== "POST") {
return res.status(405).send(""); // 405 Method Not Allowed
}
const { body } = req;
// these are both backups to client-side validations just in case someone squeezes through without them. the codes
// are identical so they're caught in the same fashion.
if (!body || !body.name || !body.email || !body.message) {
// all fields are required
throw new Error("USER_MISSING_DATA");
}
if (!body["h-captcha-response"] || !(await validateCaptcha(body["h-captcha-response"]))) {
// either the captcha is wrong or completely missing
throw new Error("USER_INVALID_CAPTCHA");
}
// sent directly to airtable
const airtableResult = await sendToAirtable({
Name: body.name,
Email: body.email,
Message: body.message,
});
// throw an internal error, not user's fault
if (airtableResult !== true) {
throw new Error("AIRTABLE_API_ERROR");
}
// return in JSON format
return res.status(200).json({ success: true });
} catch (error) {
console.error(error);
const message = error instanceof Error ? error.message : "UNKNOWN_EXCEPTION";
// don't log PEBCAK errors to sentry
if (!message.startsWith("USER_")) {
// log error to sentry, give it 2 seconds to finish sending
Sentry.captureException(error);
await Sentry.flush(2000);
}
// 500 Internal Server Error
return res.status(500).json({ success: false, message });
}
};
const validateCaptcha = async (formResponse) => {
const response = await fetch(HCAPTCHA_API_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: queryString.stringify({
response: formResponse,
sitekey: HCAPTCHA_SITE_KEY,
secret: HCAPTCHA_SECRET_KEY,
}),
});
const result = await response.json();
// @ts-ignore
return result.success;
};
const sendToAirtable = async (data) => {
const response = await fetch(`${AIRTABLE_API_ENDPOINT}${AIRTABLE_BASE}/Messages`, {
method: "POST",
headers: {
Authorization: `Bearer ${AIRTABLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
fields: data,
}),
});
return response.ok;
};
export default handler;

133
pages/api/hits.ts Normal file
View File

@@ -0,0 +1,133 @@
import * as Sentry from "@sentry/node";
import * as config from "../../lib/config";
import Parser from "rss-parser";
import { decode } from "html-entities";
import pRetry from "p-retry";
import faunadb from "faunadb";
const q = faunadb.query;
import type { NextApiRequest, NextApiResponse } from "next";
Sentry.init({
dsn: process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN || "",
environment: process.env.NODE_ENV || process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV || "",
});
const BASE_URL = config.baseURL === "" ? `${config.siteDomain}/` : `${config.baseURL}/`;
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
// permissive access control headers
res.setHeader("Access-Control-Allow-Methods", "GET");
res.setHeader("Access-Control-Allow-Origin", "*");
if (req.method !== "GET") {
return res.status(405).send(""); // 405 Method Not Allowed
}
const client = new faunadb.Client({
secret: process.env.FAUNADB_SERVER_SECRET,
checkNewVersion: false, // https://github.com/fauna/faunadb-js/pull/504
});
const { slug } = req.query;
let result;
if (!slug || slug === "/") {
// return overall site stats if slug not specified
result = await getSiteStats(client);
// let Vercel edge and browser cache results for 15 mins
res.setHeader("Cache-Control", "public, max-age=900, s-maxage=900, stale-while-revalidate");
} else {
// increment this page's hits. retry 3 times in case of Fauna "contended transaction" error:
// https://sentry.io/share/issue/9c60a58211954ed7a8dfbe289bd107b5/
result = await pRetry(() => incrementPageHits(slug, client), {
onFailedAttempt: (error) => {
console.warn(`Attempt ${error.attemptNumber} failed, trying again...`);
},
retries: 3,
});
// disable caching on both ends
res.setHeader("Cache-Control", "private, no-cache, no-store, must-revalidate");
res.setHeader("Expires", 0);
res.setHeader("Pragma", "no-cache");
}
return res.status(200).json(result);
} catch (error) {
console.error(error);
// log error to sentry, give it 2 seconds to finish sending
Sentry.captureException(error);
await Sentry.flush(2000);
const message = error instanceof Error ? error.message : "Unknown error.";
// 500 Internal Server Error
return res.status(500).json({ success: false, message });
}
};
const incrementPageHits = async (slug, client) => {
const result = await client.query(
q.Let(
{ match: q.Match(q.Index("hits_by_slug"), slug) },
q.If(
q.Exists(q.Var("match")),
q.Let(
{
ref: q.Select("ref", q.Get(q.Var("match"))),
hits: q.ToInteger(q.Select("hits", q.Select("data", q.Get(q.Var("match"))))),
},
q.Update(q.Var("ref"), { data: { hits: q.Add(q.Var("hits"), 1) } })
),
q.Create(q.Collection("hits"), { data: { slug, hits: 1 } })
)
)
);
// send client the *new* hit count
return result.data;
};
const getSiteStats = async (client) => {
// get database and RSS results asynchronously
const parser = new Parser();
const [feed, result] = await Promise.all([
parser.parseURL(`${BASE_URL}feed.xml`),
client.query(
q.Map(
q.Paginate(q.Documents(q.Collection("hits")), { size: 99 }),
q.Lambda((x) => q.Select("data", q.Get(x)))
)
),
]);
const pages = result.data;
const stats = {
total: { hits: 0 },
pages,
};
pages.map((p) => {
// match URLs from RSS feed with db to populate some metadata
const match = feed.items.find((x) => x.link === `${BASE_URL}${p.slug}/`);
if (match) {
p.title = decode(match.title);
p.url = match.link;
p.date = new Date(match.pubDate);
}
// add these hits to running tally
stats.total.hits += p.hits;
return p;
});
// sort by hits (descending)
stats.pages.sort((a, b) => (a.hits > b.hits ? -1 : 1));
return stats;
};
export default handler;

99
pages/api/projects.ts Normal file
View File

@@ -0,0 +1,99 @@
import * as Sentry from "@sentry/node";
import { graphql } from "@octokit/graphql";
import type { NextApiRequest, NextApiResponse } from "next";
Sentry.init({
dsn: process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN || "",
environment: process.env.NODE_ENV || process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV || "",
});
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
// permissive access control headers
res.setHeader("Access-Control-Allow-Methods", "GET");
res.setHeader("Access-Control-Allow-Origin", "*");
if (req.method !== "GET") {
return res.status(405).send(""); // 405 Method Not Allowed
}
// allow custom limit, max. 24 results
let limit = 24;
if (parseInt(req.query.limit as string) > 0 && parseInt(req.query.limit as string) < limit) {
limit = parseInt(req.query.limit as string);
}
const result = await fetchRepos(req.query.sort === "top" ? "STARGAZERS" : "PUSHED_AT", limit);
// let Vercel edge and browser cache results for 15 mins
res.setHeader("Cache-Control", "public, max-age=900, s-maxage=900, stale-while-revalidate");
return res.status(200).json(result);
} catch (error) {
console.error(error);
// log error to sentry, give it 2 seconds to finish sending
Sentry.captureException(error);
await Sentry.flush(2000);
const message = error instanceof Error ? error.message : "Unknown error.";
// 500 Internal Server Error
return res.status(500).json({ success: false, message });
}
};
const fetchRepos = async (sort, limit) => {
// https://docs.github.com/en/graphql/reference/objects#repository
const { user } = await graphql(
`
query ($username: String!, $sort: String, $limit: Int) {
user(login: $username) {
repositories(
first: $limit
isLocked: false
isFork: false
ownerAffiliations: OWNER
privacy: PUBLIC
orderBy: { field: $sort, direction: DESC }
) {
edges {
node {
name
url
description
pushedAt
stargazerCount
forkCount
primaryLanguage {
name
color
}
}
}
}
}
}
`,
{
username: "jakejarvis",
limit,
sort,
headers: {
authorization: `token ${process.env.GH_PUBLIC_TOKEN}`,
},
}
);
return user.repositories.edges.map(({ node: repo }) => ({
name: repo.name,
url: repo.url,
description: repo.description,
updatedAt: new Date(repo.pushedAt),
stars: repo.stargazerCount,
forks: repo.forkCount,
language: repo.primaryLanguage,
}));
};
export default handler;

139
pages/api/tracks.ts Normal file
View File

@@ -0,0 +1,139 @@
// @ts-nocheck
// Fetches my Spotify most-played tracks or currently playing track.
// Heavily inspired by @leerob: https://leerob.io/snippets/spotify
import * as Sentry from "@sentry/node";
import fetch from "node-fetch";
import queryString from "query-string";
import type { NextApiRequest, NextApiResponse } from "next";
Sentry.init({
dsn: process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN || "",
environment: process.env.NODE_ENV || process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV || "",
});
const { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_REFRESH_TOKEN } = process.env;
const basic = Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString("base64");
// https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow
const TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token";
// https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-the-users-currently-playing-track
const NOW_PLAYING_ENDPOINT = "https://api.spotify.com/v1/me/player/currently-playing";
// https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-users-top-artists-and-tracks
const TOP_TRACKS_ENDPOINT = "https://api.spotify.com/v1/me/top/tracks?time_range=long_term&limit=10";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
// permissive access control headers
res.setHeader("Access-Control-Allow-Methods", "GET");
res.setHeader("Access-Control-Allow-Origin", "*");
if (req.method !== "GET") {
return res.status(405).send(""); // 405 Method Not Allowed
}
// default to top tracks
let response;
// get currently playing track (/api/tracks/?now), otherwise top 10 tracks
if (typeof req.query.now !== "undefined") {
response = await getNowPlaying();
// let Vercel edge and browser cache results for 5 mins
res.setHeader("Cache-Control", "public, max-age=300, s-maxage=300, stale-while-revalidate");
} else {
response = await getTopTracks();
// let Vercel edge and browser cache results for 3 hours
res.setHeader("Cache-Control", "public, max-age=10800, s-maxage=10800, stale-while-revalidate");
}
return res.status(200).json(response);
} catch (error) {
console.error(error);
// log error to sentry, give it 2 seconds to finish sending
Sentry.captureException(error);
await Sentry.flush(2000);
const message = error instanceof Error ? error.message : "Unknown error.";
// 500 Internal Server Error
return res.status(500).json({ success: false, message });
}
};
const getAccessToken = async () => {
const response = await fetch(TOKEN_ENDPOINT, {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: queryString.stringify({
grant_type: "refresh_token",
refresh_token: SPOTIFY_REFRESH_TOKEN,
}),
});
return response.json();
};
const getNowPlaying = async () => {
const { access_token } = await getAccessToken();
const response = await fetch(NOW_PLAYING_ENDPOINT, {
headers: {
// eslint-disable-next-line camelcase
Authorization: `Bearer ${access_token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
});
if (response.status === 204 || response.status > 400) {
return { isPlaying: false };
}
const active = await response.json();
if (active.is_playing === true && active.item) {
return {
isPlaying: active.is_playing,
artist: active.item.artists.map((artist) => artist.name).join(", "),
title: active.item.name,
album: active.item.album.name,
imageUrl: active.item.album.images ? active.item.album.images[0].url : undefined,
songUrl: active.item.external_urls.spotify,
};
}
return { isPlaying: false };
};
const getTopTracks = async () => {
const { access_token } = await getAccessToken();
const response = await fetch(TOP_TRACKS_ENDPOINT, {
headers: {
// eslint-disable-next-line camelcase
Authorization: `Bearer ${access_token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
});
const { items } = await response.json();
return items.map((track) => ({
artist: track.artists.map((artist) => artist.name).join(", "),
title: track.name,
album: track.album.name,
imageUrl: track.album.images ? track.album.images[0].url : undefined,
songUrl: track.external_urls.spotify,
}));
};
export default handler;