mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2025-09-13 23:55:35 -04:00
112
pages/api/contact.ts
Normal file
112
pages/api/contact.ts
Normal 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
133
pages/api/hits.ts
Normal 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
99
pages/api/projects.ts
Normal 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
139
pages/api/tracks.ts
Normal 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;
|
Reference in New Issue
Block a user