Preview arbitrary urls within a document (#5598)
This commit is contained in:
@@ -21,13 +21,13 @@ const StyledText = styled(Text)`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const Preview = styled(Link)`
|
export const Preview = styled(Link)`
|
||||||
cursor: var(--pointer);
|
cursor: ${(props: any) =>
|
||||||
|
props.as === "div" ? "default" : "var(--pointer)"};
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
|
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
|
||||||
0 0 1px 1px rgba(0, 0, 0, 0.05);
|
0 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
${(props) => (!props.to ? "pointer-events: none;" : "")}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const Title = styled.h2`
|
export const Title = styled.h2`
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type Props = {
|
|||||||
url: string;
|
url: string;
|
||||||
/** Title for the preview card */
|
/** Title for the preview card */
|
||||||
title: string;
|
title: string;
|
||||||
/** Url for thumbnail served by the link provider*/
|
/** Url for thumbnail served by the link provider */
|
||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
/** Some description about the link provider */
|
/** Some description about the link provider */
|
||||||
description: string;
|
description: string;
|
||||||
@@ -25,7 +25,7 @@ type Props = {
|
|||||||
|
|
||||||
function HoverPreviewLink({ url, thumbnailUrl, title, description }: Props) {
|
function HoverPreviewLink({ url, thumbnailUrl, title, description }: Props) {
|
||||||
return (
|
return (
|
||||||
<Preview to={{ pathname: url }} target="_blank" rel="noopener noreferrer">
|
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||||
<Flex column>
|
<Flex column>
|
||||||
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
|
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type Props = {
|
|||||||
|
|
||||||
function HoverPreviewMention({ url, title, info, color }: Props) {
|
function HoverPreviewMention({ url, title, info, color }: Props) {
|
||||||
return (
|
return (
|
||||||
<Preview to="">
|
<Preview as="div">
|
||||||
<Card fadeOut={false}>
|
<Card fadeOut={false}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Flex gap={12}>
|
<Flex gap={12}>
|
||||||
|
|||||||
@@ -1,30 +1,35 @@
|
|||||||
import fetch from "fetch-with-proxy";
|
import fetch from "fetch-with-proxy";
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import { InternalError } from "@server/errors";
|
import { InternalError } from "@server/errors";
|
||||||
|
import Logger from "@server/logging/Logger";
|
||||||
import Redis from "@server/redis";
|
import Redis from "@server/redis";
|
||||||
|
|
||||||
class Iframely {
|
class Iframely {
|
||||||
private static apiUrl = `${env.IFRAMELY_URL}/api`;
|
private static apiUrl = `${env.IFRAMELY_URL}/api`;
|
||||||
private static apiKey = env.IFRAMELY_API_KEY;
|
private static apiKey = env.IFRAMELY_API_KEY;
|
||||||
private static cacheKeyPrefix = "unfurl";
|
private static cacheKeyPrefix = "unfurl";
|
||||||
|
private static defaultCacheExpiry = 86400;
|
||||||
|
|
||||||
private static cacheKey(url: string) {
|
private static cacheKey(url: string) {
|
||||||
return `${this.cacheKeyPrefix}-${url}`;
|
return `${this.cacheKeyPrefix}-${url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async cache(url: string) {
|
private static async cache(url: string, response: any) {
|
||||||
const data = await this.fetch(url);
|
// do not cache error responses
|
||||||
|
if (response.error) {
|
||||||
if (!data.error) {
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
await Redis.defaultClient.set(
|
await Redis.defaultClient.set(
|
||||||
this.cacheKey(url),
|
this.cacheKey(url),
|
||||||
JSON.stringify(data),
|
JSON.stringify(response),
|
||||||
"EX",
|
"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") {
|
private static async fetch(url: string, type = "oembed") {
|
||||||
@@ -37,16 +42,33 @@ class Iframely {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async cached(url: string) {
|
private static async cached(url: string) {
|
||||||
const val = await Redis.defaultClient.get(this.cacheKey(url));
|
try {
|
||||||
if (val) {
|
const val = await Redis.defaultClient.get(this.cacheKey(url));
|
||||||
return JSON.parse(val);
|
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) {
|
public static async get(url: string) {
|
||||||
try {
|
try {
|
||||||
const cached = await this.cached(url);
|
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) {
|
} catch (err) {
|
||||||
throw InternalError(err);
|
throw InternalError(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -617,7 +617,7 @@ export class Environment {
|
|||||||
*/
|
*/
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@CannotUseWithout("IFRAMELY_URL")
|
@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
|
* The product name
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { User } from "@server/models";
|
import { User } from "@server/models";
|
||||||
import { buildDocument, buildUser } from "@server/test/factories";
|
import { buildDocument, buildUser } from "@server/test/factories";
|
||||||
import { getTestServer } from "@server/test/support";
|
import { getTestServer } from "@server/test/support";
|
||||||
import { Iframely } from "@server/utils/unfurl";
|
import resolvers from "@server/utils/unfurl";
|
||||||
|
|
||||||
jest.mock("@server/utils/unfurl", () => ({
|
jest.mock("@server/utils/unfurl", () => ({
|
||||||
Iframely: {
|
Iframely: {
|
||||||
@@ -146,7 +146,7 @@ describe("#urls.unfurl", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should succeed with status 200 ok for a valid external url", async () => {
|
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({
|
Promise.resolve({
|
||||||
url: "https://www.flickr.com",
|
url: "https://www.flickr.com",
|
||||||
type: "rich",
|
type: "rich",
|
||||||
@@ -167,7 +167,9 @@ describe("#urls.unfurl", () => {
|
|||||||
|
|
||||||
const body = await res.json();
|
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(res.status).toEqual(200);
|
||||||
expect(body.url).toEqual("https://www.flickr.com");
|
expect(body.url).toEqual("https://www.flickr.com");
|
||||||
expect(body.type).toEqual("rich");
|
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 () => {
|
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({
|
Promise.resolve({
|
||||||
status: 404,
|
status: 404,
|
||||||
error:
|
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);
|
expect(res.status).toEqual(204);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { presentDocument, presentMention } from "@server/presenters/unfurls";
|
|||||||
import presentUnfurl from "@server/presenters/unfurls/unfurl";
|
import presentUnfurl from "@server/presenters/unfurls/unfurl";
|
||||||
import { APIContext } from "@server/types";
|
import { APIContext } from "@server/types";
|
||||||
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
||||||
import { Iframely } from "@server/utils/unfurl";
|
import resolvers from "@server/utils/unfurl";
|
||||||
import * as T from "./schema";
|
import * as T from "./schema";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
@@ -62,12 +62,14 @@ router.post(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await Iframely.unfurl(url);
|
if (resolvers.Iframely) {
|
||||||
if (data.error) {
|
const data = await resolvers.Iframely.unfurl(url);
|
||||||
ctx.response.status = 204;
|
return data.error
|
||||||
return;
|
? (ctx.response.status = 204)
|
||||||
|
: (ctx.body = presentUnfurl(data));
|
||||||
}
|
}
|
||||||
ctx.body = presentUnfurl(data);
|
|
||||||
|
return (ctx.response.status = 204);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { existsSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import glob from "glob";
|
import glob from "glob";
|
||||||
import { startCase } from "lodash";
|
import { startCase } from "lodash";
|
||||||
@@ -5,20 +6,36 @@ import env from "@server/env";
|
|||||||
import Logger from "@server/logging/Logger";
|
import Logger from "@server/logging/Logger";
|
||||||
import { UnfurlResolver } from "@server/types";
|
import { UnfurlResolver } from "@server/types";
|
||||||
|
|
||||||
const resolvers: Record<string, UnfurlResolver> = {};
|
|
||||||
const rootDir = env.ENVIRONMENT === "test" ? "" : "build";
|
const rootDir = env.ENVIRONMENT === "test" ? "" : "build";
|
||||||
|
|
||||||
glob
|
const hasResolver = (plugin: string) => {
|
||||||
.sync(path.join(rootDir, "plugins/*/server/unfurl.js"))
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
.forEach((filePath: string) => {
|
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<string, UnfurlResolver> = plugins
|
||||||
|
.filter(hasResolver)
|
||||||
|
.map(resolverPath)
|
||||||
|
.reduce((resolvers, resolverPath) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const resolver: UnfurlResolver = require(path.join(
|
const resolver: UnfurlResolver = require(path.join(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
filePath
|
resolverPath
|
||||||
));
|
));
|
||||||
const name = startCase(filePath.split("/")[2]);
|
const name = startCase(resolverPath.split("/")[2]);
|
||||||
resolvers[name] = resolver;
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user