diff --git a/app/components/HoverPreview/Components.tsx b/app/components/HoverPreview/Components.tsx index d2ba153f1..9e2967c05 100644 --- a/app/components/HoverPreview/Components.tsx +++ b/app/components/HoverPreview/Components.tsx @@ -21,13 +21,13 @@ const StyledText = styled(Text)` `; export const Preview = styled(Link)` - cursor: var(--pointer); + cursor: ${(props: any) => + props.as === "div" ? "default" : "var(--pointer)"}; border-radius: 4px; box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3), 0 0 1px 1px rgba(0, 0, 0, 0.05); overflow: hidden; position: absolute; - ${(props) => (!props.to ? "pointer-events: none;" : "")} `; export const Title = styled.h2` diff --git a/app/components/HoverPreview/HoverPreviewLink.tsx b/app/components/HoverPreview/HoverPreviewLink.tsx index 259724900..7f01f2653 100644 --- a/app/components/HoverPreview/HoverPreviewLink.tsx +++ b/app/components/HoverPreview/HoverPreviewLink.tsx @@ -17,7 +17,7 @@ type Props = { url: string; /** Title for the preview card */ title: string; - /** Url for thumbnail served by the link provider*/ + /** Url for thumbnail served by the link provider */ thumbnailUrl: string; /** Some description about the link provider */ description: string; @@ -25,7 +25,7 @@ type Props = { function HoverPreviewLink({ url, thumbnailUrl, title, description }: Props) { return ( - + {thumbnailUrl ? : null} diff --git a/app/components/HoverPreview/HoverPreviewMention.tsx b/app/components/HoverPreview/HoverPreviewMention.tsx index ffdb0c6b7..10ddde0c3 100644 --- a/app/components/HoverPreview/HoverPreviewMention.tsx +++ b/app/components/HoverPreview/HoverPreviewMention.tsx @@ -17,7 +17,7 @@ type Props = { function HoverPreviewMention({ url, title, info, color }: Props) { return ( - + diff --git a/plugins/iframely/server/iframely.ts b/plugins/iframely/server/iframely.ts index e84b858ae..081b4fec1 100644 --- a/plugins/iframely/server/iframely.ts +++ b/plugins/iframely/server/iframely.ts @@ -1,30 +1,35 @@ import fetch from "fetch-with-proxy"; import env from "@server/env"; import { InternalError } from "@server/errors"; +import Logger from "@server/logging/Logger"; import Redis from "@server/redis"; class Iframely { private static apiUrl = `${env.IFRAMELY_URL}/api`; private static apiKey = env.IFRAMELY_API_KEY; private static cacheKeyPrefix = "unfurl"; + private static defaultCacheExpiry = 86400; private static cacheKey(url: string) { return `${this.cacheKeyPrefix}-${url}`; } - private static async cache(url: string) { - const data = await this.fetch(url); - - if (!data.error) { + private static async cache(url: string, response: any) { + // do not cache error responses + if (response.error) { + return; + } + try { await Redis.defaultClient.set( this.cacheKey(url), - JSON.stringify(data), + JSON.stringify(response), "EX", - data.cache_age + response.cache_age || this.defaultCacheExpiry ); + } catch (err) { + // just log it, can skip caching and directly return response + Logger.error("Could not cache Iframely response", err); } - - return data; } private static async fetch(url: string, type = "oembed") { @@ -37,16 +42,33 @@ class Iframely { } private static async cached(url: string) { - const val = await Redis.defaultClient.get(this.cacheKey(url)); - if (val) { - return JSON.parse(val); + try { + const val = await Redis.defaultClient.get(this.cacheKey(url)); + if (val) { + return JSON.parse(val); + } + } catch (err) { + // just log it, response can still be obtained using the fetch call + Logger.error("Could not fetch cached Iframely response", err); } } + /** + * Fetches the preview data for the given url + * using Iframely oEmbed API + * + * @param url + * @returns Preview data for the url + */ public static async get(url: string) { try { const cached = await this.cached(url); - return cached ? cached : this.cache(url); + if (cached) { + return cached; + } + const res = await this.fetch(url); + await this.cache(url, res); + return res; } catch (err) { throw InternalError(err); } diff --git a/server/env.ts b/server/env.ts index c768b9abc..47c71eb09 100644 --- a/server/env.ts +++ b/server/env.ts @@ -617,7 +617,7 @@ export class Environment { */ @IsOptional() @CannotUseWithout("IFRAMELY_URL") - public IFRAMELY_API_KEY = process.env.IFRAMELY_API_KEY ?? ""; + public IFRAMELY_API_KEY = this.toOptionalString(process.env.IFRAMELY_API_KEY); /** * The product name diff --git a/server/routes/api/urls/urls.test.ts b/server/routes/api/urls/urls.test.ts index 4a1e0dd58..dbfcf20cd 100644 --- a/server/routes/api/urls/urls.test.ts +++ b/server/routes/api/urls/urls.test.ts @@ -1,7 +1,7 @@ import { User } from "@server/models"; import { buildDocument, buildUser } from "@server/test/factories"; import { getTestServer } from "@server/test/support"; -import { Iframely } from "@server/utils/unfurl"; +import resolvers from "@server/utils/unfurl"; jest.mock("@server/utils/unfurl", () => ({ Iframely: { @@ -146,7 +146,7 @@ describe("#urls.unfurl", () => { }); it("should succeed with status 200 ok for a valid external url", async () => { - (Iframely.unfurl as jest.Mock).mockResolvedValue( + (resolvers.Iframely.unfurl as jest.Mock).mockResolvedValue( Promise.resolve({ url: "https://www.flickr.com", type: "rich", @@ -167,7 +167,9 @@ describe("#urls.unfurl", () => { const body = await res.json(); - expect(Iframely.unfurl).toHaveBeenCalledWith("https://www.flickr.com"); + expect(resolvers.Iframely.unfurl).toHaveBeenCalledWith( + "https://www.flickr.com" + ); expect(res.status).toEqual(200); expect(body.url).toEqual("https://www.flickr.com"); expect(body.type).toEqual("rich"); @@ -181,7 +183,7 @@ describe("#urls.unfurl", () => { }); it("should succeed with status 204 no content for a non-existing external url", async () => { - (Iframely.unfurl as jest.Mock).mockResolvedValue( + (resolvers.Iframely.unfurl as jest.Mock).mockResolvedValue( Promise.resolve({ status: 404, error: @@ -196,7 +198,9 @@ describe("#urls.unfurl", () => { }, }); - expect(Iframely.unfurl).toHaveBeenCalledWith("https://random.url"); + expect(resolvers.Iframely.unfurl).toHaveBeenCalledWith( + "https://random.url" + ); expect(res.status).toEqual(204); }); }); diff --git a/server/routes/api/urls/urls.ts b/server/routes/api/urls/urls.ts index c52b2c95f..a6212b804 100644 --- a/server/routes/api/urls/urls.ts +++ b/server/routes/api/urls/urls.ts @@ -11,7 +11,7 @@ import { presentDocument, presentMention } from "@server/presenters/unfurls"; import presentUnfurl from "@server/presenters/unfurls/unfurl"; import { APIContext } from "@server/types"; import { RateLimiterStrategy } from "@server/utils/RateLimiter"; -import { Iframely } from "@server/utils/unfurl"; +import resolvers from "@server/utils/unfurl"; import * as T from "./schema"; const router = new Router(); @@ -62,12 +62,14 @@ router.post( return; } - const data = await Iframely.unfurl(url); - if (data.error) { - ctx.response.status = 204; - return; + if (resolvers.Iframely) { + const data = await resolvers.Iframely.unfurl(url); + return data.error + ? (ctx.response.status = 204) + : (ctx.body = presentUnfurl(data)); } - ctx.body = presentUnfurl(data); + + return (ctx.response.status = 204); } ); diff --git a/server/utils/unfurl.ts b/server/utils/unfurl.ts index b9518fd5b..b17f3b995 100644 --- a/server/utils/unfurl.ts +++ b/server/utils/unfurl.ts @@ -1,3 +1,4 @@ +import { existsSync } from "fs"; import path from "path"; import glob from "glob"; import { startCase } from "lodash"; @@ -5,20 +6,36 @@ import env from "@server/env"; import Logger from "@server/logging/Logger"; import { UnfurlResolver } from "@server/types"; -const resolvers: Record = {}; const rootDir = env.ENVIRONMENT === "test" ? "" : "build"; -glob - .sync(path.join(rootDir, "plugins/*/server/unfurl.js")) - .forEach((filePath: string) => { +const hasResolver = (plugin: string) => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const config = require(path.join(process.cwd(), plugin, "plugin.json")); + + return ( + existsSync(resolverPath(plugin)) && + (config.requiredEnvVars ?? []).every((name: string) => !!env[name]) + ); +}; + +const resolverPath = (plugin: string) => + path.join(plugin, "server", "unfurl.js"); + +const plugins = glob.sync(path.join(rootDir, "plugins/*")); +const resolvers: Record = plugins + .filter(hasResolver) + .map(resolverPath) + .reduce((resolvers, resolverPath) => { // eslint-disable-next-line @typescript-eslint/no-var-requires const resolver: UnfurlResolver = require(path.join( process.cwd(), - filePath + resolverPath )); - const name = startCase(filePath.split("/")[2]); + const name = startCase(resolverPath.split("/")[2]); resolvers[name] = resolver; - Logger.debug("utils", `Registered unfurl resolver ${filePath}`); - }); + Logger.debug("utils", `Registered unfurl resolver ${resolverPath}`); -export const Iframely = resolvers["Iframely"]; + return resolvers; + }, {}); + +export default resolvers;