chore: edge functions

This commit is contained in:
Cory Dransfeldt 2024-12-03 04:50:43 -08:00
parent 76a50caaee
commit 7a3b40842c
No known key found for this signature in database
16 changed files with 950 additions and 1346 deletions

View file

@ -8,6 +8,7 @@ export default defineConfig({
output: "server",
adapter: netlify({
cacheOnDemandPages: true,
edgeMiddleware: true,
imageCDN: false,
}),
integrations: [

203
edge-functions/scrobble.js Normal file
View file

@ -0,0 +1,203 @@
import { createClient } from "@supabase/supabase-js";
import slugify from "slugify";
const sanitizeMediaString = (str) => {
const sanitizedString = str
.normalize("NFD")
.replace(/[\u0300-\u036f\u2010\-\.\?\(\)\[\]\{\}]/g, "")
.replace(/\.{3}/g, "");
return slugify(sanitizedString, {
replacement: "-",
remove: /[#,&,+()$~%.'":*?<>{}]/g,
lower: true,
});
};
const sendEmail = async (subject, text, authHeader) => {
const emailData = new URLSearchParams({
from: "coryd.dev <hi@admin.coryd.dev>",
to: "hi@coryd.dev",
subject: subject,
text: text,
}).toString();
const response = await fetch("https://api.forwardemail.net/v1/emails", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: authHeader,
},
body: emailData,
});
if (!response.ok) {
const responseText = await response.text();
throw new Error(`Failed to send email: ${responseText}`);
}
};
const parseMultipartFormData = (body, boundary) => {
const parts = body.split(`--${boundary}`).filter((part) => part.trim());
const formData = {};
parts.forEach((part) => {
const [headers, value] = part.split("\r\n\r\n");
const nameMatch = headers.match(/name="(.+?)"/);
if (nameMatch) {
const name = nameMatch[1];
formData[name] = value.trim().replace(/\r\n$/, "");
}
});
return formData;
};
export default async (req) => {
const supabaseUrl = Netlify.env.get("SUPABASE_URL");
const supabaseKey = Netlify.env.get("SUPABASE_KEY");
const FORWARDEMAIL_API_KEY = Netlify.env.get("FORWARDEMAIL_API_KEY");
const ACCOUNT_ID_PLEX = Netlify.env.get("ACCOUNT_ID_PLEX");
const authHeader = `Basic ${btoa(`${FORWARDEMAIL_API_KEY}:`)}`;
const supabase = createClient(supabaseUrl, supabaseKey);
let id;
try {
const queryParams = new URLSearchParams(req.url.split("?")[1]);
id = queryParams.get("id");
if (!id || id !== ACCOUNT_ID_PLEX) {
return new Response(
JSON.stringify({
status: "Forbidden",
message: "Invalid or missing ID",
}),
{ status: 403 },
);
}
} catch (error) {
console.error("Error parsing query parameters:", error);
return new Response(
JSON.stringify({
status: "Bad request",
message: "Oops! Bad request.",
}),
{ status: 400 },
);
}
const contentType = req.headers.get("content-type") || "";
if (!contentType.includes("multipart/form-data")) {
return new Response(
JSON.stringify({
status: "Bad request",
message: "Invalid Content-Type. Expected multipart/form-data.",
}),
{ status: 400 },
);
}
try {
const boundary = contentType.split("boundary=")[1];
if (!boundary) throw new Error("Missing boundary in Content-Type");
const rawBody = await req.text();
const formData = parseMultipartFormData(rawBody, boundary);
const payload = JSON.parse(formData.payload);
if (payload?.event === "media.scrobble") {
const artistName = payload["Metadata"]["grandparentTitle"];
const albumName = payload["Metadata"]["parentTitle"];
const trackName = payload["Metadata"]["title"];
const listenedAt = Math.floor(Date.now() / 1000);
const artistKey = sanitizeMediaString(artistName);
const albumKey = `${artistKey}-${sanitizeMediaString(albumName)}`;
let { data: artistData, error: artistError } = await supabase
.from("artists")
.select("*")
.ilike("name_string", artistName)
.single();
if (artistError && artistError.code === "PGRST116") {
await supabase.from("artists").insert([
{
mbid: null,
art: "4cef75db-831f-4f5d-9333-79eaa5bb55ee",
name: artistName,
slug: "/music",
tentative: true,
total_plays: 0,
},
]);
await sendEmail(
"New tentative artist record",
`A new tentative artist record was inserted:\n\nArtist: ${artistName}\nKey: ${artistKey}`,
authHeader,
);
({ data: artistData } = await supabase
.from("artists")
.select("*")
.ilike("name_string", artistName)
.single());
}
let { data: albumData, error: albumError } = await supabase
.from("albums")
.select("*")
.ilike("key", albumKey)
.single();
if (albumError && albumError.code === "PGRST116") {
await supabase.from("albums").insert([
{
mbid: null,
art: "4cef75db-831f-4f5d-9333-79eaa5bb55ee",
key: albumKey,
name: albumName,
tentative: true,
total_plays: 0,
artist: artistData.id,
},
]);
await sendEmail(
"New tentative album record",
`A new tentative album record was inserted:\n\nAlbum: ${albumName}\nKey: ${albumKey}\nArtist: ${artistName}`,
authHeader,
);
({ data: albumData } = await supabase
.from("albums")
.select("*")
.ilike("key", albumKey)
.single());
}
await supabase.from("listens").insert([
{
artist_name: artistData.name_string || artistName,
album_name: albumData.name || albumName,
track_name: trackName,
listened_at: listenedAt,
album_key: albumKey,
},
]);
console.log("Listen record inserted successfully");
}
return new Response(JSON.stringify({ status: "success" }), { status: 200 });
} catch (e) {
console.error("Error occurred during request processing:", e.message, e);
return new Response(
JSON.stringify({ status: "error", message: "Oops! Error." }),
{ status: 500 },
);
}
};

59
edge-functions/search.js Normal file
View file

@ -0,0 +1,59 @@
import { createClient } from "@supabase/supabase-js";
export default async (req) => {
const supabaseUrl = Netlify.env.get("SUPABASE_URL");
const supabaseKey = Netlify.env.get("SUPABASE_KEY");
const supabase = createClient(supabaseUrl, supabaseKey);
try {
const url = new URL(req.url);
const searchParams = url.searchParams;
const query = searchParams.get("q")?.trim() || "";
const rawTypes = searchParams.get("type") || "";
const types = rawTypes ? rawTypes.split(",") : null;
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "10", 10);
const offset = (page - 1) * pageSize;
if (!query) {
return new Response(
JSON.stringify({ error: "Missing or invalid 'q' parameter" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const { data, error } = await supabase.rpc("search_optimized_index", {
search_query: query,
page_size: pageSize,
page_offset: offset,
types: types && types.length ? types : null,
});
if (error) {
console.error("Error fetching search data:", error.message);
return new Response(
JSON.stringify({
results: [],
total: 0,
error: error.message,
}),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
const total = data.length > 0 ? data[0].total_count : 0;
const results = data.map(({ total_count, ...item }) => item);
return new Response(JSON.stringify({ results, total, page, pageSize }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Unexpected error:", error.message);
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};

View file

@ -1,294 +0,0 @@
import { createClient } from "@supabase/supabase-js";
import slugify from "slugify";
const sanitizeMediaString = (str) => {
const sanitizedString = str
.normalize("NFD")
.replace(/[\u0300-\u036f\u2010\-\.\?\(\)\[\]\{\}]/g, "")
.replace(/\.{3}/g, "");
return slugify(sanitizedString, {
replacement: "-",
remove: /[#,&,+()$~%.'":*?<>{}]/g,
lower: true,
});
};
const sendEmail = async (subject, text, authHeader, maxRetries = 3) => {
const emailData = new URLSearchParams({
from: "coryd.dev <hi@admin.coryd.dev>",
to: "hi@coryd.dev",
subject: subject,
text: text,
}).toString();
let attempt = 0;
let success = false;
while (attempt < maxRetries && !success) {
attempt++;
try {
const response = await fetch("https://api.forwardemail.net/v1/emails", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: authHeader,
},
body: emailData,
});
if (!response.ok) {
const responseText = await response.text();
console.error(
`Attempt ${attempt}: Email API response error:`,
response.status,
responseText,
);
throw new Error(`Failed to send email: ${responseText}`);
}
console.log("Email sent successfully on attempt", attempt);
success = true;
} catch (error) {
console.error(`Attempt ${attempt}: Error sending email.`);
if (attempt < maxRetries) {
console.log(
`Retrying email send (attempt ${attempt + 1}/${maxRetries})...`,
);
} else {
console.error("All attempts to send email failed.");
}
}
}
return success;
};
const parseMultipartFormData = (body, boundary) => {
const parts = body.split(`--${boundary}`).filter((part) => part.trim());
const formData = {};
parts.forEach((part) => {
const [headers, value] = part.split("\r\n\r\n");
const nameMatch = headers.match(/name="(.+?)"/);
if (nameMatch) {
const name = nameMatch[1];
formData[name] = value.trim().replace(/\r\n$/, "");
}
});
return formData;
};
export async function handler(event, context) {
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_KEY;
const FORWARDEMAIL_API_KEY = process.env.FORWARDEMAIL_API_KEY;
const ACCOUNT_ID_PLEX = process.env.ACCOUNT_ID_PLEX;
const authHeader = "Basic " + btoa(`${FORWARDEMAIL_API_KEY}:`);
const supabase = createClient(supabaseUrl, supabaseKey);
let id;
try {
const queryParams = new URLSearchParams(event.queryStringParameters || {});
id = queryParams.get("id");
if (!id || id !== ACCOUNT_ID_PLEX)
return {
statusCode: 403,
body: JSON.stringify({
status: "Forbidden",
message: "Invalid or missing ID",
}),
};
} catch (error) {
console.error("Error parsing query parameters.");
return {
statusCode: 400,
body: JSON.stringify({
status: "Bad request",
message: "Oops! Bad request.",
}),
};
}
const contentType = event.headers["content-type"] || "";
if (!contentType.includes("multipart/form-data"))
return {
statusCode: 400,
body: JSON.stringify({
status: "Bad request",
message: "Invalid Content-Type. Expected multipart/form-data.",
}),
};
try {
const boundary = contentType.split("boundary=")[1];
if (!boundary) throw new Error("Missing boundary in Content-Type");
const rawBody = Buffer.from(event.body, "base64").toString("utf-8");
const formData = parseMultipartFormData(rawBody, boundary);
const payload = JSON.parse(formData.payload);
if (payload?.event === "media.scrobble") {
const artistName = payload["Metadata"]["grandparentTitle"];
const albumName = payload["Metadata"]["parentTitle"];
const trackName = payload["Metadata"]["title"];
const listenedAt = Math.floor(Date.now() / 1000);
const artistKey = sanitizeMediaString(artistName);
const albumKey = `${artistKey}-${sanitizeMediaString(albumName)}`;
let { data: artistData, error: artistError } = await supabase
.from("artists")
.select("*")
.ilike("name_string", artistName)
.single();
if (artistError && artistError.code === "PGRST116") {
const { error: insertArtistError } = await supabase
.from("artists")
.insert([
{
mbid: null,
art: "4cef75db-831f-4f5d-9333-79eaa5bb55ee",
name: artistName,
slug: "/music",
tentative: true,
total_plays: 0,
},
]);
if (insertArtistError) {
console.error("Error inserting artist.");
return {
statusCode: 500,
body: JSON.stringify({
status: "error",
message: "Error inserting artist.",
}),
};
}
await sendEmail(
"New tentative artist record",
`A new tentative artist record was inserted:\n\nArtist: ${artistName}\nKey: ${artistKey}`,
authHeader,
);
({ data: artistData, error: artistError } = await supabase
.from("artists")
.select("*")
.ilike("name_string", artistName)
.single());
}
if (artistError) {
console.error("Artist not found or created.");
return {
statusCode: 500,
body: JSON.stringify({
status: "error",
message: "Artist not found",
}),
};
}
let { data: albumData, error: albumError } = await supabase
.from("albums")
.select("*")
.ilike("key", albumKey)
.single();
if (albumError && albumError.code === "PGRST116") {
console.log("Inserting new album:", albumName);
const { error: insertAlbumError } = await supabase
.from("albums")
.insert([
{
mbid: null,
art: "4cef75db-831f-4f5d-9333-79eaa5bb55ee",
key: albumKey,
name: albumName,
tentative: true,
total_plays: 0,
artist: artistData.id,
},
]);
if (insertAlbumError) {
console.error("Error inserting album.");
return {
statusCode: 500,
body: JSON.stringify({
status: "error",
message: "Error inserting album.",
}),
};
}
await sendEmail(
"New tentative album record",
`A new tentative album record was inserted:\n\nAlbum: ${albumName}\nKey: ${albumKey}\nArtist: ${artistName}`,
authHeader,
);
({ data: albumData, error: albumError } = await supabase
.from("albums")
.select("*")
.ilike("key", albumKey)
.single());
}
if (albumError) {
console.error("Album not found or created.");
return {
statusCode: 500,
body: JSON.stringify({ status: "error", message: "Album not found" }),
};
}
const { error: listenError } = await supabase.from("listens").insert([
{
artist_name: artistData.name_string || artistName,
album_name: albumData.name || albumName,
track_name: trackName,
listened_at: listenedAt,
album_key: albumKey,
},
]);
if (listenError) {
console.error("Error inserting listen.");
return {
statusCode: 500,
body: JSON.stringify({
status: "error",
message: "Error inserting listen.",
}),
};
}
console.log("Listen record inserted successfully");
}
return {
statusCode: 200,
body: JSON.stringify({ status: "success" }),
};
} catch (e) {
console.error(
"Error occurred during request processing:",
e.message,
e.stack,
);
return {
statusCode: 500,
body: JSON.stringify({ status: "error", message: "Oops! Error." }),
};
}
}

View file

@ -1,53 +0,0 @@
import { createClient } from "@supabase/supabase-js";
export async function handler(event, context) {
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey);
try {
const searchParams = event.queryStringParameters || {};
const query = searchParams.q?.trim() || "";
const rawTypes = searchParams.type || "";
const types = rawTypes ? rawTypes.split(",") : null;
const page = parseInt(searchParams.page || "1", 10);
const pageSize = parseInt(searchParams.pageSize || "10", 10);
const offset = (page - 1) * pageSize;
if (!query) throw new Error("Missing or invalid 'q' parameter");
const { data, error } = await supabase.rpc("search_optimized_index", {
search_query: query,
page_size: pageSize,
page_offset: offset,
types: types && types.length ? types : null,
});
if (error) {
console.error("Error fetching search data:", error.message);
return {
statusCode: 500,
body: JSON.stringify({
results: [],
total: 0,
error: error.message,
}),
};
}
const total = data.length > 0 ? data[0].total_count : 0;
const results = data.map(({ total_count, ...item }) => item);
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ results, total, page, pageSize }),
};
} catch (error) {
console.error("Unexpected error:", error.message);
return {
statusCode: error.message.includes("Missing or invalid") ? 400 : 500,
body: JSON.stringify({ error: error.message }),
};
}
}

View file

@ -1,6 +1,7 @@
[build]
command = "npm run build"
publish = "dist"
edge_functions = "edge-functions"
[functions]
directory = "functions"
@ -8,24 +9,20 @@ directory = "functions"
[functions.mastodon]
schedule = "*/15 * * * *"
[[edge_functions]]
function = "scrobble"
path = "/api/scrobble"
[[edge_functions]]
function = "search"
path = "/api/search"
[[redirects]]
from = "/api/artist-import"
to = "/.netlify/functions/artist-import"
status = 200
query = "*"
[[redirects]]
from = "/api/scrobble"
to = "/.netlify/functions/scrobble"
status = 200
query = "*"
[[redirects]]
from = "/api/search"
to = "/.netlify/functions/search"
status = 200
query = "*"
[[redirects]]
from = "/scripts/util.js"
to = "https://plausible.io/js/plausible.outbound-links.tagged-events.js"

1628
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"name": "coryd.dev",
"type": "module",
"version": "1.5.1",
"version": "1.6.0",
"scripts": {
"start": "astro dev",
"build": "astro build",
@ -12,9 +12,9 @@
"update:deps": "npm upgrade && ncu && npx @astrojs/upgrade && npm i"
},
"dependencies": {
"@astrojs/netlify": "^5.5.4",
"@astrojs/netlify": "^6.0.0",
"@supabase/supabase-js": "^2.46.2",
"astro": "4.16.16",
"astro": "^5.0.1",
"cdn-cache-control": "^1.2.0",
"highlight.js": "11.10.0",
"sanitize-html": "2.13.1",
@ -28,8 +28,6 @@
"astro-embed": "0.9.0",
"date-fns": "4.1.0",
"date-fns-tz": "3.2.0",
"dotenv": "^16.4.6",
"dotenv-flow": "^4.1.0",
"fast-xml-parser": "4.5.0",
"html-to-text": "9.0.5",
"i18n-iso-countries": "7.13.0",

View file

@ -6,6 +6,7 @@
name="q"
autocomplete="off"
autofocus
onkeydown="return event.key !== 'Enter'"
/>
<details>
<summary class="highlight-text">Filter by type</summary>

View file

@ -8,9 +8,6 @@ import { fetchShowByUrl } from "@utils/data/dynamic/showByUrl.js";
import { isbnRegex } from "@utils/helpers/media.js";
import { isExcludedPath } from "@utils/helpers/general.js";
import { CACHE_DURATION } from "@utils/constants/index.js";
import dotenvFlow from "dotenv-flow";
dotenvFlow.config();
let cachedGlobals = null;
let cachedNav = null;
@ -90,7 +87,7 @@ export async function onRequest(context, next) {
if (!data)
return new Response(
`${resourceType.charAt(0).toUpperCase() + resourceType.slice(1)} Not Found`,
{ status: 404 }
{ status: 404 },
);
cachedByType[urlPath] = data;

View file

@ -1,5 +1,5 @@
---
import { CacheHeaders, ONE_HOUR } from 'cdn-cache-control';
import { CacheHeaders, ONE_DAY } from 'cdn-cache-control';
import Layout from "@layouts/Layout.astro";
import Warning from "@components/blocks/banners/Warning.astro";
import AssociatedMedia from "@components/blocks/AssociatedMedia.astro";
@ -15,7 +15,7 @@ if (!book) return Astro.redirect("/404", 404);
const headers = new CacheHeaders()
.swr()
.ttl(ONE_HOUR)
.ttl(ONE_DAY)
.tag(['book', `book-${book.isbn}`]);
headers.forEach((value, key) => {

View file

@ -1,5 +1,5 @@
---
import { CacheHeaders, ONE_HOUR } from 'cdn-cache-control';
import { CacheHeaders, ONE_DAY } from 'cdn-cache-control';
import { parseISO, format } from "date-fns";
import Layout from "@layouts/Layout.astro";
import Modal from "@components/blocks/Modal.astro";
@ -17,7 +17,7 @@ if (!artist) return Astro.redirect("/404", 404);
const headers = new CacheHeaders()
.swr()
.ttl(ONE_HOUR)
.ttl(ONE_DAY)
.tag(['artist', `artist-${slugify(artist.name, { lower: true })}`]);
headers.forEach((value, key) => {

View file

@ -1,5 +1,5 @@
---
import { CacheHeaders, ONE_HOUR } from 'cdn-cache-control';
import { CacheHeaders, ONE_DAY } from 'cdn-cache-control';
import Layout from "@layouts/Layout.astro";
import AssociatedMedia from "@components/blocks/AssociatedMedia.astro";
import icons from "@cdransf/astro-tabler-icons";
@ -14,7 +14,7 @@ if (!genre) return Astro.redirect("/404", 404);
const headers = new CacheHeaders()
.swr()
.ttl(ONE_HOUR)
.ttl(ONE_DAY)
.tag(['genre', `genre-${slugify(genre.name, { lower: true })}`]);
headers.forEach((value, key) => {

View file

@ -1,5 +1,5 @@
---
import { CacheHeaders, ONE_HOUR } from 'cdn-cache-control';
import { CacheHeaders, ONE_DAY } from 'cdn-cache-control';
import { parseISO, format } from "date-fns";;
import Layout from "@layouts/Layout.astro";
import AssociatedMedia from "@components/blocks/AssociatedMedia.astro";
@ -14,7 +14,7 @@ if (!movie) return Astro.redirect("/404", 404);
const headers = new CacheHeaders()
.swr()
.ttl(ONE_HOUR)
.ttl(ONE_DAY)
.tag(['movie', `movie-${movie.id}`]);
headers.forEach((value, key) => {

View file

@ -1,5 +1,5 @@
---
import { CacheHeaders, ONE_HOUR } from 'cdn-cache-control';
import { CacheHeaders, ONE_DAY } from 'cdn-cache-control';
import { parseISO, format } from "date-fns";
import Layout from "@layouts/Layout.astro";
import AssociatedMedia from "@components/blocks/AssociatedMedia.astro";
@ -14,7 +14,7 @@ if (!show) return Astro.redirect("/404", 404);
const headers = new CacheHeaders()
.swr()
.ttl(ONE_HOUR)
.ttl(ONE_DAY)
.tag(['show', `show-${show.id}`]);
headers.forEach((value, key) => {

View file

@ -2390,6 +2390,9 @@
{
"loc": "https://coryd.dev/books/1770410651"
},
{
"loc": "https://coryd.dev/books/0063396807"
},
{
"loc": "https://coryd.dev/books/9781472270399"
},