Parcourir la source

feat: threshold url param & auth tokens

Sv443 il y a 2 ans
Parent
commit
70458a023e
3 fichiers modifiés avec 82 ajouts et 28 suppressions
  1. 1 0
      .env.template
  2. 52 18
      src/server.ts
  3. 29 10
      src/songMeta.ts

+ 1 - 0
.env.template

@@ -1,2 +1,3 @@
 HTTP_PORT=8074
 GENIUS_ACCESS_TOKEN=abcdef # Gotten from POST https://api.genius.com/oauth/token or from creating a client on https://genius.com/api-clients
+AUTH_TOKENS= # Comma-separated list of HTTP bearer tokens that are excluded from rate limiting

+ 52 - 18
src/server.ts

@@ -25,6 +25,7 @@ const rateLimiter = new RateLimiterMemory({
     duration: 10,
 });
 
+const authTokens = getAuthTokens();
 
 export async function init()
 {
@@ -47,16 +48,22 @@ export async function init()
         // rate limiting
         app.use(async (req, res, next) => {
             const fmt = req?.query?.format ? String(req.query.format) : undefined;
+            const { authorization } = req.headers;
+            const authHeader = authorization?.startsWith("Bearer") ? authorization.substring(7) : authorization;
 
             res.setHeader("API-Info", `geniURL v${packageJson.version} (${packageJson.homepage})`);
 
+            if(authHeader && authTokens.has(authHeader))
+                return next();
+
             rateLimiter.consume(req.ip)
                 .catch((err) => {
                     if(err instanceof RateLimiterRes) {
                         res.set("Retry-After", String(Math.ceil(err.msBeforeNext / 1000)));
                         return respond(res, 429, { message: "You are being rate limited" }, fmt);
                     }
-                    else return respond(res, 500, { message: "Internal error in rate limiting middleware. Please try again later." }, fmt);
+                    else
+                        return respond(res, 500, { message: "Internal error in rate limiting middleware. Please try again later." }, fmt);
                 })
                 .finally(next);
         });
@@ -82,17 +89,21 @@ function registerEndpoints()
         app.get("/search", async (req, res) => {
             try
             {
-                const { q, artist, song, format: fmt } = req.query;
+                const { q, artist, song, format: fmt, threshold: thr } = req.query;
 
-                const format = fmt ? String(fmt) : "json";
+                const format: string = fmt ? String(fmt) : "json";
+                const threshold = isNaN(Number(thr)) ? undefined : Number(thr);
 
                 if(hasArg(q) || (hasArg(artist) && hasArg(song)))
                 {
-                    const meta = await getMeta(q ? {
-                        q: String(q),
-                    } : {
-                        artist: String(artist),
-                        song: String(song),
+                    const meta = await getMeta({
+                        ...(q ? {
+                            q: String(q),
+                        } : {
+                            artist: String(artist),
+                            song: String(song),
+                        }),
+                        threshold,
                     });
 
                     if(!meta || meta.all.length < 1)
@@ -115,17 +126,21 @@ function registerEndpoints()
         app.get("/search/top", async (req, res) => {
             try
             {
-                const { q, artist, song, format: fmt } = req.query;
+                const { q, artist, song, format: fmt, threshold: thr } = req.query;
 
-                const format = fmt ? String(fmt) : "json";
+                const format: string = fmt ? String(fmt) : "json";
+                const threshold = isNaN(Number(thr)) ? undefined : Number(thr);
 
                 if(hasArg(q) || (hasArg(artist) && hasArg(song)))
                 {
-                    const meta = await getMeta(q ? {
-                        q: String(q),
-                    } : {
-                        artist: String(artist),
-                        song: String(song),
+                    const meta = await getMeta({
+                        ...(q ? {
+                            q: String(q),
+                        } : {
+                            artist: String(artist),
+                            song: String(song),
+                        }),
+                        threshold,
                     });
 
                     if(!meta || !meta.top)
@@ -148,6 +163,12 @@ function registerEndpoints()
     }
 }
 
+/**
+ * Responds to an incoming request
+ * @param type Type of response or status code
+ * @param data The data to send in the response body
+ * @param format json / xml
+ */
 function respond(res: Response, type: ResponseType | number, data: Stringifiable | Record<string, unknown>, format = "json", matchesAmt = 0)
 {
     let statusCode = 500;
@@ -167,7 +188,7 @@ function respond(res: Response, type: ResponseType | number, data: Stringifiable
             error = false;
             matches = matchesAmt;
             statusCode = 200;
-            resData = { ...data };
+            resData = typeof data === "string" ? data : { ...data };
             break;
         case "clientError":
             error = true;
@@ -187,7 +208,7 @@ function respond(res: Response, type: ResponseType | number, data: Stringifiable
                 error = false;
                 matches = matchesAmt ?? 0;
                 statusCode = type;
-                resData = { ...data };
+                resData = typeof data === "string" ? data : { ...data };
             }
             break;
     }
@@ -204,5 +225,18 @@ function respond(res: Response, type: ResponseType | number, data: Stringifiable
     const finalData = format === "xml" ? jsonToXml.parse("data", resData) : resData;
 
     res.setHeader("Content-Type", mimeType);
-    res.status(statusCode).send(finalData);
+    res.status(statusCode)
+        .send(finalData);
+}
+
+function getAuthTokens() {
+    const envVal = process.env["AUTH_TOKENS"];
+    let tokens: string[] = [];
+
+    if(!envVal || envVal.length === 0)
+        tokens = [];
+    else
+        tokens = envVal.split(/,/g);
+
+    return new Set<string>(tokens);
 }

+ 29 - 10
src/songMeta.ts

@@ -1,16 +1,30 @@
 import axios from "axios";
 import Fuse from "fuse.js";
-import { randomUUID } from "crypto";
-import { JSONCompatible, reserialize } from "svcorelib";
-import { ApiSearchResult, SongMeta } from "./types";
+import { nanoid } from "nanoid";
+import { clamp } from "svcorelib";
+import type { ApiSearchResult, SongMeta } from "./types";
 
 type MetaSearchHit = SongMeta & { uuid?: string; };
 
+interface GetMetaProps {
+    q?: string;
+    artist?: string;
+    song?: string;
+    threshold?: number;
+}
+
+interface GetMetaResult {
+    top: SongMeta;
+    all: SongMeta[];
+}
+
+const defaultFuzzyThreshold = 0.7;
+
 /**
  * Returns meta information about the top results of a search using the genius API
  * @param param0 Pass an object with either a `q` prop or the props `artist` and `song` to make use of fuzzy filtering
  */
-export async function getMeta({ q, artist, song }: Partial<Record<"q" | "artist" | "song", string>>): Promise<{ top: SongMeta, all: SongMeta[] } | null>
+export async function getMeta({ q, artist, song, threshold = defaultFuzzyThreshold }: GetMetaProps): Promise<GetMetaResult | null>
 {
     const accessToken = process.env.GENIUS_ACCESS_TOKEN ?? "ERR_NO_ENV";
 
@@ -20,6 +34,10 @@ export async function getMeta({ q, artist, song }: Partial<Record<"q" | "artist"
         headers: { "Authorization": `Bearer ${accessToken}` },
     });
 
+    if(isNaN(threshold))
+        threshold = defaultFuzzyThreshold;
+    threshold = clamp(threshold, 0.0, 1.0);
+
     if(status >= 200 && status < 300 && Array.isArray(response?.hits))
     {
         if(response.hits.length === 0)
@@ -36,14 +54,14 @@ export async function getMeta({ q, artist, song }: Partial<Record<"q" | "artist"
                     fullTitle: formatStr(result.full_title),
                     artists: formatStr(result.artist_names),
                     primaryArtist: {
-                        name: formatStr(result.primary_artist.name) ?? null,
+                        name: result.primary_artist.name ? formatStr(result.primary_artist.name) : null,
                         url: result.primary_artist.url ?? null,
                         headerImage: result.primary_artist.header_image_url ?? null,
                         image: result.primary_artist.image_url ?? null,
                     },
                     featuredArtists: Array.isArray(result.featured_artists) && result.featured_artists.length > 0
                         ? result.featured_artists.map((a) => ({
-                            name: a.name ?? null,
+                            name: a.name ? formatStr(a.name) : null,
                             url: a.url ?? null,
                             headerImage: a.header_image_url ?? null,
                             image: a.image_url ?? null,
@@ -64,14 +82,13 @@ export async function getMeta({ q, artist, song }: Partial<Record<"q" | "artist"
             const scoreMap: Record<string, number> = {};
 
             hits = hits.map(h => {
-                h.uuid = randomUUID();
+                h.uuid = nanoid();
                 return h;
             }) as (SongMeta & { uuid: string })[];
 
             const fuseOpts: Fuse.IFuseOptions<MetaSearchHit> = {
-                ignoreLocation: true,
                 includeScore: true,
-                threshold: 0.6,
+                threshold,
             };
 
             const titleFuse = new Fuse(hits, { ...fuseOpts, keys: [ "meta.title" ] });
@@ -128,5 +145,7 @@ function formatStr(str: unknown): string
     if(!str || typeof str !== "string")
         throw new TypeError("formatStr(): input is not a string");
 
-    return str.replace(/[\u0000-\u001F\u007F-\u009F\u200B]/g, "").replace(/\u00A0/g, " ");
+    return str
+        .replace(/[\u0000-\u001F\u007F-\u009F\u200B]/g, "") // 0-width spaces & control characters
+        .replace(/\u00A0/g, " "); // non-standard 1-width spaces
 }