[CORL-1108] Media Embeds (#3010)

* Create Twitter and YouTube embed components

Uses the `/api/oembed` endpoint to proxy the
oembed requests that the embed components drop
into an iframe.

CORL-1012

* Create preliminary embed link parsing/storage

CORL-1012

* Create preliminary embed section on comments

CORL-1012

* Preliminarily add admin embed config options

CORL-1012

* Show a "missing" message when embed is unavailable

CORL-1012

* Simplify naming of embeds in schema

embedLinks -> embed

CORL-1012

* Rename oEmbedHandler to oembedHandler

CORL-1012

* add backend services for giphy

* search gifs on frontend

* display selected gif

* show gif previews

* display giphy attribution and no results text

* save a gif to a comment

* use embeds feature for gif embeds

* clean up gif/video/tweet display

* style and configure post comment form

* preview and confirm twitter and youtube embeds

* moderate embeds on server

* update reply and edit forms

* update snaps

* fix some of the tests

* fix tests and types

* fix tests

* fix types

* show gifs in moderate cards

* correctly attach embeds to comments

* make gif rating configurable

* make gif rating configurable

* configure giphy api key

* refactor comment form

* only allow embeds if settings enabled

* scale youtube

* resize embeds if necessary

* make tweets and videos responsive

* set maxwidth on tweet embeds

* update copy for embed config

* force gif search results to fit container

* prevent double posting of gifs

* undo hiding html if empmty because now it doesn't contain random break tags

* use downsampled preview images

* update fixtures and snapshots

* remove unused css

* add i18n string

* remove console logs

* Fix styles on logged-out comment form

* click to pause gif in moderation

* style youtube and twitter embeds in mod stream"

* use mp4s for stream gifs

* use mp4 for moderation gifs

* clean up commentform

* fix dom tests

* update rte

* import oembed module with correct casing

* bump rte

* add correct return type for setInterval

* add migration for embeds config

* catch errors from gif search

* return early from iframe container size calculation if width and height are set

* remove unused classnames

* make giphy api key protected

* reorganize tenant embed settings schema

* update schema on backend to support single comment embed instead of array

* move findEmbedLinks to common

* wrap error

* return function for linkify instead of ternary

* remove unused url param

* clean up oembed service

* remove conditional in repeat post check

* use joi to validate giphy responses

* fix types for embeds

* fix optimistic responses

* move attachEmbeds function

* update snapshots

* fix: improved repeatPost checking

* force case change on oembed

* force rename file name

* feat: Rename Embed -> Media

* fix: cleanup of service functions

* fix: moved types

* fix: fixed logic bug

* fix: fixed translation

* show embeds on history comments

* fix: fixed iframe csp and query param bug

* correct validation for twitter oembed

* feat: save youtube still

* fix: typeerror

* fix: fixed errors related to final form

* fix: fixed issue with types

* fix: added docs to the schema

* fix: linting + tests

Co-authored-by: nick-funk <nick.funk@outlook.com>
Co-authored-by: Wyatt Johnson <me@wyattjoh.ca>
This commit is contained in:
Tessa Thornton
2020-07-15 02:16:06 +00:00
committed by GitHub
co-authored by nick-funk Wyatt Johnson
parent f8234e53ed
commit c59c345756
147 changed files with 6366 additions and 1971 deletions
@@ -7,3 +7,4 @@ export * from "./version";
export * from "./dashboard";
export * from "./user";
export * from "./story";
export * from "./remoteMedia";
@@ -0,0 +1,110 @@
import Joi from "@hapi/joi";
import { stripIndent } from "common-tags";
import { validate } from "coral-server/app/request/body";
import { supportsMediaType } from "coral-server/models/tenant";
import { fetchOEmbedResponse } from "coral-server/services/oembed";
import { RequestHandler } from "coral-server/types/express";
const createNotFoundMessage = (type: "twitter" | "youtube") => {
switch (type) {
case "twitter":
return "Tweet could not be found. Perhaps it was deleted?";
case "youtube":
return "YouTube video could not be found. Perhaps it was deleted?";
default:
throw new Error(`invalid type provided: ${type}`);
}
};
const OEmbedQuerySchema = Joi.object().keys({
url: Joi.string().uri().required(),
type: Joi.string().allow("twitter", "youtube").only(),
maxWidth: Joi.number().optional(),
});
interface OEmbedQuery {
type: "twitter" | "youtube";
url: string;
maxWidth?: number;
}
export const oembedHandler = (): RequestHandler => {
// TODO: add some kind of rate limiting or spam protection
return async (req, res, next) => {
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
try {
const { type, url, maxWidth }: OEmbedQuery = validate(
OEmbedQuerySchema,
req.query
);
if (!supportsMediaType(tenant, type)) {
res.sendStatus(400);
return;
}
// Get the oEmbed response.
const response = await fetchOEmbedResponse(type, url, maxWidth);
if (response === null || !response.html) {
res.status(404);
res.send(
`<html>
<body>
${createNotFoundMessage(type)}
</body>
<html>`
);
return;
}
const { width, height, html } = response;
// Compile the style to be used for the embed.
let style = `
body {
margin: 0;
}
.container * {
margin: 0!important;
}
`;
if (width && height) {
style += `
.container {
overflow: hidden;
position: relative;
padding-bottom: ${(height / width) * 100}%;
}
.container iframe {
border: 0;
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
}
`;
}
// Send back the HTML for the oEmbed.
res.send(
stripIndent`<html>
<style>
${style}
</style>
<body>
<div class="container">
${html}
</div>
</body>
<html>`
);
} catch (err) {
next(err);
}
};
};
@@ -0,0 +1,30 @@
import { searchGiphy } from "coral-server/services/giphy";
import { Request, RequestHandler } from "coral-server/types/express";
export const gifSearchHandler: RequestHandler = async (
req: Request,
res,
next
) => {
if (!req.coral) {
return next(new Error("coral was not set"));
}
if (!req.query.query) {
return next(new Error("search query required"));
}
const coral = req.coral;
const tenant = coral.tenant!;
try {
const results = await searchGiphy(
req.query.query,
req.query.offset || "0",
tenant
);
res.json(results);
} catch (err) {
next(err);
}
};
@@ -13,6 +13,7 @@ interface RequestQuery {
parentUrl?: string;
storyURL?: string;
storyID?: string;
siteID?: string;
}
async function retrieveSiteFromEmbed(
@@ -34,8 +35,14 @@ async function retrieveSiteFromEmbed(
storyURL = "",
storyID = "",
parentUrl = "",
siteID = "",
}: RequestQuery = req.query;
// If the siteID is available, use that.
if (siteID) {
return retrieveSite(mongo, tenant.id, siteID);
}
// If the storyURL is available, we can lookup the site directly based on it.
if (storyURL) {
// If the site can't be found based on it's allowed origins and the story
+11
View File
@@ -3,6 +3,8 @@ import passport from "passport";
import { AppOptions } from "coral-server/app";
import { graphQLHandler } from "coral-server/app/handlers";
import { oembedHandler } from "coral-server/app/handlers/api/oembed/oembed";
import { cspSiteMiddleware } from "coral-server/app/middleware/csp/tenant";
import { JSONErrorHandler } from "coral-server/app/middleware/error";
import { persistedQueryMiddleware } from "coral-server/app/middleware/graphql";
import { jsonMiddleware } from "coral-server/app/middleware/json";
@@ -18,6 +20,7 @@ import { createNewAccountRouter } from "./account";
import { createNewAuthRouter } from "./auth";
import { createDashboardRouter } from "./dashboard";
import { createNewInstallRouter } from "./install";
import { createRemoteMediaRouter } from "./remoteMedia";
import { createStoryRouter } from "./story";
import { createNewUserRouter } from "./user";
@@ -51,6 +54,8 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) {
router.use("/account", createNewAccountRouter(app, options));
router.use("/user", createNewUserRouter(app));
router.get("/oembed", cspSiteMiddleware(app), oembedHandler());
// Configure the GraphQL route.
router.use(
"/graphql",
@@ -67,6 +72,12 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) {
roleMiddleware(STAFF_ROLES),
createDashboardRouter(app)
);
router.use(
"/remote-media",
authenticate(options.passport),
loggedInMiddleware,
createRemoteMediaRouter(app)
);
// General API error handler.
router.use(notFoundMiddleware);
@@ -0,0 +1,13 @@
import { AppOptions } from "coral-server/app";
import { gifSearchHandler } from "coral-server/app/handlers";
import { createAPIRouter } from "./helpers";
export function createRemoteMediaRouter(app: AppOptions) {
// All responses from the GIF search are cached for 30 seconds on the CDN.
const router = createAPIRouter({ cache: "30s" });
router.get("/gifs", gifSearchHandler);
return router;
}
+11 -2
View File
@@ -11,6 +11,7 @@ import {
removeDontAgree,
removeReaction,
} from "coral-server/services/comments/actions";
import { CreateCommentMediaInput } from "coral-server/services/comments/media";
import { publishCommentFeatured } from "coral-server/services/events";
import {
approveComment,
@@ -41,6 +42,7 @@ export const Comments = (ctx: GraphContext) => ({
create: ({
clientMutationId,
nudge = false,
media,
...comment
}: GQLCreateCommentInput | GQLCreateCommentReplyInput) =>
mapFieldsetToErrorCodes(
@@ -51,7 +53,12 @@ export const Comments = (ctx: GraphContext) => ({
ctx.broker,
ctx.tenant,
ctx.user!,
{ authorID: ctx.user!.id, ...comment },
{
...comment,
authorID: ctx.user!.id,
// TODO: (wyattjoh) check this type to get it to match.
media: media as CreateCommentMediaInput,
},
nudge,
ctx.now,
ctx.req
@@ -65,7 +72,7 @@ export const Comments = (ctx: GraphContext) => ({
"input.storyID": [ERROR_CODES.STORY_NOT_FOUND],
}
),
edit: ({ commentID, body }: GQLEditCommentInput) =>
edit: ({ commentID, body, media }: GQLEditCommentInput) =>
mapFieldsetToErrorCodes(
editComment(
ctx.mongo,
@@ -77,6 +84,8 @@ export const Comments = (ctx: GraphContext) => ({
{
id: commentID,
body,
// TODO: (wyattjoh) check this type to get it to match.
media: media as CreateCommentMediaInput,
},
ctx.now,
ctx.req
@@ -0,0 +1,21 @@
import { GQLCommentMediaTypeResolver } from "coral-server/graph/schema/__generated__/types";
import * as comment from "coral-server/models/comment";
const resolveType: GQLCommentMediaTypeResolver<comment.CommentMedia> = (
embed
) => {
switch (embed.type) {
case "giphy":
return "GiphyMedia";
case "youtube":
return "YouTubeMedia";
case "twitter":
return "TwitterMedia";
default:
// TODO: replace with better error.
throw new Error("invalid embed type");
}
};
export const CommentMedia = {
__resolveType: resolveType,
};
@@ -17,4 +17,5 @@ export const CommentRevision: Required<GQLCommentRevisionTypeResolver<
// Defaults to an empty object if not set on the revision.
metadata: (w) => w.revision.metadata || {},
createdAt: (w) => w.revision.createdAt,
media: (w) => w.revision.media,
};
@@ -0,0 +1,10 @@
import {
GQLMediaConfiguration,
GQLMediaConfigurationTypeResolver,
} from "coral-server/graph/schema/__generated__/types";
export const MediaConfiguration: GQLMediaConfigurationTypeResolver<GQLMediaConfiguration> = {
twitter: ({ twitter }) => (twitter ? twitter : false),
youtube: ({ youtube }) => (youtube ? youtube : false),
giphy: ({ giphy }) => (giphy ? giphy : false),
};
@@ -31,4 +31,19 @@ export const Settings: GQLSettingsTypeResolver<Tenant> = {
},
webhookEvents: () => Object.values(GQLWEBHOOK_EVENT_NAME),
rte: ({ rte = defaultRTEConfiguration }) => rte,
media: ({ media }) => {
if (!media) {
return {
twitter: { enabled: false },
youtube: { enabled: false },
giphy: { enabled: false },
};
}
return {
twitter: media.twitter || { enabled: false },
youtube: media.youtube || { enabled: false },
giphy: media.giphy || { enabled: false, maxRating: "g" },
};
},
};
+9 -5
View File
@@ -14,6 +14,7 @@ import { CommentCounts } from "./CommentCounts";
import { CommentCreatedPayload } from "./CommentCreatedPayload";
import { CommentEnteredModerationQueuePayload } from "./CommentEnteredModerationQueuePayload";
import { CommentLeftModerationQueuePayload } from "./CommentLeftModerationQueuePayload";
import { CommentMedia } from "./CommentMedia";
import { CommentModerationAction } from "./CommentModerationAction";
import { CommentReleasedPayload } from "./CommentReleasedPayload";
import { CommentReplyCreatedPayload } from "./CommentReplyCreatedPayload";
@@ -27,6 +28,7 @@ import { Flag } from "./Flag";
import { GoogleAuthIntegration } from "./GoogleAuthIntegration";
import { Invite } from "./Invite";
import { LiveConfiguration } from "./LiveConfiguration";
import { MediaConfiguration } from "./MediaConfiguration";
import { ModerationQueue } from "./ModerationQueue";
import { ModerationQueues } from "./ModerationQueues";
import { ModeratorNote } from "./ModeratorNote";
@@ -69,6 +71,7 @@ const Resolvers: GQLResolver = {
CommentCreatedPayload,
CommentEnteredModerationQueuePayload,
CommentLeftModerationQueuePayload,
CommentMedia,
CommentModerationAction,
CommentReleasedPayload,
CommentReplyCreatedPayload,
@@ -84,6 +87,7 @@ const Resolvers: GQLResolver = {
Invite,
LiveConfiguration,
Locale,
MediaConfiguration,
ModerationQueue,
ModerationQueues,
ModeratorNote,
@@ -93,11 +97,15 @@ const Resolvers: GQLResolver = {
PremodStatusHistory,
Profile,
Query,
Queue,
Queues,
RecentCommentHistory,
RejectCommentPayload,
SSOAuthIntegration,
Settings,
SigningSecret,
Site,
SlackConfiguration,
SSOAuthIntegration,
Story,
StorySettings,
Subscription,
@@ -107,13 +115,9 @@ const Resolvers: GQLResolver = {
Time,
User,
UserModerationScopes,
Queue,
Queues,
UsernameHistory,
UsernameStatus,
UserStatus,
Settings,
SlackConfiguration,
WebhookEndpoint,
};
+245
View File
@@ -1278,6 +1278,58 @@ type StoryMessageBox {
content: String
}
################################################################################
## Embed Links
################################################################################
type GiphyMediaConfiguration {
"""
enabled is true when gif search via giphy and giphy media objects are enabled.
"""
enabled: Boolean!
"""
maximum allowed rating for gifs, g, pg, pg-13, r.
"""
maxRating: String
"""
key is the API key for Giphy.
"""
key: String @auth(roles: [ADMIN])
}
type TwitterMediaConfiguration {
"""
enabled is true when twitter media objects are enabled.
"""
enabled: Boolean!
}
type YouTubeMediaConfiguration {
"""
enabled is true when youtube media objects are enabled.
"""
enabled: Boolean!
}
type MediaConfiguration {
"""
twitter is the configuration for Twitter support.
"""
twitter: TwitterMediaConfiguration!
"""
youtube is the configuration for YouTube support.
"""
youtube: YouTubeMediaConfiguration!
"""
giphy is the configuration for Giphy support.
"""
giphy: GiphyMediaConfiguration!
}
################################################################################
## Settings
################################################################################
@@ -1688,6 +1740,11 @@ type Settings {
"""
stories: StoryConfiguration! @auth(roles: [ADMIN])
"""
media is the configuration media content attached to Comment's.
"""
media: MediaConfiguration
"""
featureFlags provides the enabled feature flags.
"""
@@ -1703,7 +1760,11 @@ type Settings {
"""
newCommenters: NewCommentersConfiguration! @auth(roles: [ADMIN])
"""
announcement is the currently active Announcement.
"""
announcement: Announcement
"""
multisite is whether multiple sites exist for this tenant.
"""
@@ -2551,6 +2612,96 @@ type CommentRevisionMetadata {
perspective: CommentRevisionPerspectiveMetadata
}
"""
GiphyMedia is a particular GIF that is provided by the Giphy platform.
"""
type GiphyMedia {
"""
url is the URL to a image of the GIF.
"""
url: String!
"""
original is the URL to a image of the GIF.
"""
original: String!
"""
still is a thumbnail preview of the GIF.
"""
still: String!
"""
video is a URL to the mp4 video of the GIF.
"""
video: String!
"""
width is the width of the GIF in pixels.
"""
width: Int
"""
height is the height of the GIF in pixels.
"""
height: Int
"""
title is the title of the GIF.
"""
title: String
}
"""
TwitterMedia is a specific Twitter Tweet.
"""
type TwitterMedia {
"""
url is the URL of a Twitter tweet.
"""
url: String!
"""
width is the width of the Twitter tweet in pixels.
"""
width: Int
}
"""
YouTubeMedia is a specific YouTube video.
"""
type YouTubeMedia {
"""
url is the URL of a YouTube video.
"""
url: String!
"""
still is the thumbnail of the YouTube video.
"""
still: String!
"""
title is the title of the YouTube video.
"""
title: String
"""
width is the width of the YouTube video in pixels.
"""
width: Int
"""
height is the height of the YouTube video in pixels.
"""
height: Int
}
"""
CommentMedia is the various media types that can be attached to a Comment.
"""
union CommentMedia = GiphyMedia | TwitterMedia | YouTubeMedia
type CommentRevision {
"""
id is the identifier of the CommentRevision.
@@ -2575,6 +2726,11 @@ type CommentRevision {
"""
body: String
"""
media is the optional media object attached to this revision.
"""
media: CommentMedia
"""
metadata stores details on a CommentRevision.
"""
@@ -3463,6 +3619,11 @@ input CreateCommentInput {
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
media is the optional media attachment to be added to a Comment.
"""
media: CreateCommentMediaInput
}
"""
@@ -3481,6 +3642,27 @@ type CreateCommentPayload {
clientMutationId: String!
}
"""
CreateCommentMediaInput is used for creating media to be attached to a Comment.
"""
input CreateCommentMediaInput {
"""
type specifies the type of media that can be attached. Valid values for this
are: `giphy`, `twitter`, and `youtube`.
"""
type: String!
"""
id refers to a foreign id for the specific media provider.
"""
id: String
"""
url is the URL to the media resource.
"""
url: String!
}
##################
## createCommentReply
##################
@@ -3520,6 +3702,11 @@ input CreateCommentReplyInput {
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
media is the optional media attachment to be added to a Comment.
"""
media: CreateCommentMediaInput
}
"""
@@ -3560,6 +3747,11 @@ input EditCommentInput {
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
media is the optional media attachment to be added to a Comment.
"""
media: CreateCommentMediaInput
}
"""
@@ -4201,6 +4393,54 @@ input SlackConfigurationInput {
channels: [SlackChannelConfigurationInput!]
}
input TwitterMediaConfigurationInput {
"""
enabled is true when twitter media objects are enabled.
"""
enabled: Boolean
}
input YouTubeMediaConfigurationInput {
"""
enabled is true when youtube media objects are enabled.
"""
enabled: Boolean
}
input GiphyMediaConfigurationInput {
"""
enabled is true when gif search via giphy and giphy media objects are enabled.
"""
enabled: Boolean
"""
maximum allowed rating for gifs, g, pg, pg-13, r
"""
maxRating: String!
"""
key is the API key for Giphy.
"""
key: String
}
input MediaConfigurationInput {
"""
twitter is the configuration for Twitter support.
"""
twitter: TwitterMediaConfigurationInput
"""
youtube is the configuration for YouTube support.
"""
youtube: YouTubeMediaConfigurationInput
"""
giphy is the configuration for Giphy support.
"""
giphy: GiphyMediaConfigurationInput
}
"""
NewCommenterConfigurationInput specifies the features that apply to new commenters
"""
@@ -4361,6 +4601,11 @@ input SettingsInput {
rte is the configuration of the Rich-Text-Editor.
"""
rte: RTEConfigurationInput
"""
media is the configuration media content attached to Comment's.
"""
media: MediaConfigurationInput
}
"""
+1
View File
@@ -1,2 +1,3 @@
export { default as createTimer } from "./createTimer";
export { default as relativeTo } from "./relativeTo";
export { default as validateSchema } from "./validateSchema";
+19
View File
@@ -0,0 +1,19 @@
import Joi from "@hapi/joi";
function validateSchema<T extends {}>(schema: Joi.Schema, body: any): T {
// Extract the schema from the body.
const { value, error: err } = schema.validate(body, {
stripUnknown: true,
presence: "required",
abortEarly: false,
});
if (err) {
// TODO: wrap error?
throw err;
}
return value;
}
export default validateSchema;
+6 -3
View File
@@ -146,7 +146,7 @@ export type CreateCommentInput = Omit<
| "deletedAt"
> &
Required<Pick<Revision, "body">> &
Pick<Revision, "metadata"> &
Pick<Revision, "metadata" | "media"> &
Partial<Pick<Comment, "actionCounts" | "siteID">>;
export async function createComment(
@@ -156,7 +156,7 @@ export async function createComment(
now = new Date()
) {
// Pull out some useful properties from the input.
const { body, actionCounts = {}, metadata, ...rest } = input;
const { body, actionCounts = {}, metadata, media, ...rest } = input;
// Generate the revision.
const revision: Readonly<Revision> = {
@@ -165,6 +165,7 @@ export async function createComment(
actionCounts,
metadata,
createdAt: now,
media,
};
// default are the properties set by the application when a new comment is
@@ -227,6 +228,7 @@ export type EditCommentInput = Pick<Comment, "id" | "authorID" | "status"> & {
*/
lastEditableCommentCreatedAt: Date;
} & Required<Pick<Revision, "body" | "metadata">> &
Pick<Revision, "media"> &
Partial<Pick<Comment, "actionCounts">>;
// Only comments with the following status's can be edited.
@@ -298,16 +300,17 @@ export async function editComment(
status,
authorID,
metadata,
media,
actionCounts = {},
} = input;
// Generate the revision.
const revision: Revision = {
id: uuid.v4(),
body,
actionCounts,
metadata,
createdAt: now,
media,
};
const update: Record<string, any> = {
@@ -35,6 +35,35 @@ export interface RevisionMetadata {
nudge?: boolean;
}
export interface GiphyMedia {
type: "giphy";
id: string;
url: string;
original: string;
still: string;
video: string;
width?: number;
height?: number;
title?: string;
}
export interface TwitterMedia {
type: "twitter";
url: string;
width?: number;
}
export interface YouTubeMedia {
type: "youtube";
url: string;
still: string;
title?: string;
width?: number;
height?: number;
}
export type CommentMedia = GiphyMedia | TwitterMedia | YouTubeMedia;
/**
* Revision stores a Comment's body for a specific edit. Actions can be tied to
* a Revision, as can moderation actions.
@@ -64,4 +93,9 @@ export interface Revision {
* createdAt is the date that this revision was created at.
*/
createdAt: Date;
/**
* media is the optional media object attached to this revision.
*/
media?: CommentMedia;
}
@@ -185,6 +185,7 @@ export type Settings = GlobalModerationSettings &
| "createdAt"
| "slack"
| "announcement"
| "media"
> & {
/**
* auth is the set of configured authentication integrations.
+21
View File
@@ -468,3 +468,24 @@ export function retrieveAnnouncementIfEnabled(
}
return null;
}
export function supportsMediaType(
tenant: Tenant,
type: "twitter" | "youtube" | "giphy"
): tenant is Omit<Tenant, "media"> & Required<Pick<Tenant, "media">> {
if (!tenant.media) {
return false;
}
if (type === "twitter") {
return tenant.media.twitter && tenant.media.twitter.enabled;
} else if (type === "youtube") {
return tenant.media.youtube && tenant.media.youtube.enabled;
} else if (type === "giphy") {
return (
tenant.media.giphy &&
tenant.media.giphy.enabled &&
!!tenant.media.giphy.key
);
}
return false;
}
@@ -2,3 +2,4 @@ export * from "./comments";
export * from "./actions";
export * from "./pipeline";
export * from "./moderation";
export * from "./media";
+139
View File
@@ -0,0 +1,139 @@
import { findMediaLinks } from "coral-common/helpers/findMediaLinks";
import { WrappedInternalError } from "coral-server/errors";
import {
GiphyMedia,
TwitterMedia,
YouTubeMedia,
} from "coral-server/models/comment";
import { supportsMediaType, Tenant } from "coral-server/models/tenant";
import {
ratingIsAllowed,
retrieveFromGiphy,
} from "coral-server/services/giphy";
import { fetchOEmbedResponse } from "coral-server/services/oembed";
async function attachGiphyMedia(
tenant: Tenant,
id: string,
url: string
): Promise<GiphyMedia | undefined> {
try {
// Get the response from Giphy.
const { data } = await retrieveFromGiphy(tenant, id);
if (!data) {
return;
}
// Check to see if the rating is allowed.
if (!data.rating || !ratingIsAllowed(data.rating, tenant)) {
return;
}
// Parse some of the parameters.
const width = parseInt(data.images.original.width, 10);
const height = parseInt(data.images.original.height, 10);
// Return the formed Giphy Media.
return {
type: "giphy",
id,
url,
title: data.title,
width,
height,
original: data.url,
still: data.images.original_still.url,
video: data.images.original.mp4,
};
} catch (err) {
throw new WrappedInternalError(err, "cannot attach Giphy Media");
}
}
async function attachOEmbedMedia(
type: "twitter" | "youtube",
url: string,
body: string
): Promise<YouTubeMedia | TwitterMedia | undefined> {
// Find all the media links in the body.
const links = findMediaLinks(body);
if (!links) {
return;
}
// Ensure that the link that we're attaching matches the link found in the
// body.
const found = links.find((link) => link.type === type && link.url === url);
if (!found) {
return;
}
try {
// Get the oEmbed response to save.
const res = await fetchOEmbedResponse(type, url);
if (!res) {
return;
}
// Extract the response.
const { width, height, thumbnail_url, title } = res;
// If the type is YouTube, ensure that the thumbnail url is provided.
if (type === "youtube") {
if (height === null || !thumbnail_url) {
return;
}
// Return the formed YouTubeMedia.
return {
type: "youtube",
url,
still: thumbnail_url,
width,
height,
title,
};
}
// Return the formed TwitterMedia.
return {
type: "twitter",
url,
width,
};
} catch (err) {
throw new WrappedInternalError(err, "cannot attach oEmbed Media");
}
}
export interface CreateCommentMediaInput {
type: "giphy" | "twitter" | "youtube";
url: string;
id?: string;
}
export async function attachMedia(
tenant: Tenant,
input: CreateCommentMediaInput,
body: string
) {
if (!supportsMediaType(tenant, input.type)) {
return;
}
switch (input.type) {
case "giphy":
if (!input.id) {
throw new Error(
"id is required when attaching a GiphyMedia object to a comment"
);
}
return attachGiphyMedia(tenant, input.id, input.url);
case "twitter":
case "youtube":
return attachOEmbedMedia(input.type, input.url, body);
default:
throw new Error("invalid media type");
}
}
@@ -1,3 +1,5 @@
import { isUndefined } from "lodash";
import { PhaseResult } from "./pipeline";
export function mergePhaseResult(
@@ -19,7 +21,7 @@ export function mergePhaseResult(
}
// If the result modified the comment body, we should replace it.
if (result.body) {
if (!isUndefined(result.body)) {
final.body = result.body;
}
@@ -4,6 +4,7 @@ import {
CommentBodyExceedsMaxLengthError,
CommentBodyTooShortError,
} from "coral-server/errors";
import { supportsMediaType } from "coral-server/models/tenant";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -12,6 +13,7 @@ import {
export const commentLength: IntermediateModerationPhase = ({
tenant,
bodyText,
media,
}): IntermediatePhaseResult | void => {
const length = bodyText.length;
let min: number | null = null;
@@ -28,9 +30,14 @@ export const commentLength: IntermediateModerationPhase = ({
// Comment body should have at least 1 character.
min = 1;
}
if (length < min) {
throw new CommentBodyTooShortError(min);
// If the Giphy support is enabled, we don't need to check for a minimum!
if (!supportsMediaType(tenant, "giphy") || !media || media.type !== "giphy") {
if (length < min) {
throw new CommentBodyTooShortError(min);
}
}
if (max && length > max) {
throw new CommentBodyExceedsMaxLengthError(max);
}
@@ -13,6 +13,11 @@ const config = {
export const linkify: IntermediateModerationPhase = async ({
comment,
}): Promise<IntermediatePhaseResult | void> => ({
body: linkifyjs(comment.body, config),
});
bodyText,
}): Promise<IntermediatePhaseResult | void> => {
if (bodyText.trim().length > 0) {
return {
body: linkifyjs(comment.body, config),
};
}
};
@@ -2,6 +2,7 @@ import getHTMLPlainText from "coral-common/helpers/getHTMLPlainText";
import { RepeatPostCommentError } from "coral-server/errors";
import { ACTION_TYPE } from "coral-server/models/action/comment";
import { getLatestRevision } from "coral-server/models/comment/helpers";
import { supportsMediaType } from "coral-server/models/tenant";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -21,8 +22,9 @@ export const repeatPost: IntermediateModerationPhase = async ({
nudge,
redis,
log,
media,
}): Promise<IntermediatePhaseResult | void> => {
if (!bodyText) {
if (!bodyText && !media) {
return;
}
@@ -42,19 +44,43 @@ export const repeatPost: IntermediateModerationPhase = async ({
return;
}
const revision = getHTMLPlainText(
getLatestRevision(lastComment).body
).trim();
const compareTo = bodyText.trim();
const lastCommentRevision = getLatestRevision(lastComment);
const lastCommentBodyText = getHTMLPlainText(lastCommentRevision.body);
// Calculate the comment similarity. At the moment, we only do a string
// comparison, so it's either completely equal (they match) or the
// similarity can't be determined (null). This gives us room in the future
// to include a percentage matching.
const similarity = revision === compareTo ? 1 : null;
let similarity: null | boolean = null;
// Check to see if the comment text is the same on both comments.
if (lastCommentBodyText !== bodyText) {
// Body text is not the same, can't be a repeat post!
similarity = false;
} else if (supportsMediaType(tenant, "giphy")) {
// Giphy is enabled. If the medias are the same, then this is a repeat
// comment otherwise they are not.
if (
// Check to see if the last comment revision has a Giphy Media
// object.
lastCommentRevision.media &&
lastCommentRevision.media.type === "giphy" &&
// Check to see if the current comment revision has a Giphy Media
// object.
media &&
media.type === "giphy" &&
// Check to see if the media id's are the same.
lastCommentRevision.media.id === media.id
) {
// Comment body text was the same and the media was the same.
similarity = true;
} else {
// Comment body text was the same but the media was different.
similarity = false;
}
} else {
// Body text was the same and Giphy support was not enabled.
similarity = false;
}
if (similarity) {
log.trace({ similarity }, "comment contains repeat content");
log.trace({ similarity }, "comment content is repeated");
// Throw an error if we're nudging instead of recording.
if (nudge) {
@@ -73,7 +99,7 @@ export const repeatPost: IntermediateModerationPhase = async ({
};
}
log.trace({ similarity }, "comment is not repeated");
log.trace({ similarity }, "comment content is not repeated");
} catch (err) {
// Rethrow any RepeatPostError.
if (err instanceof RepeatPostCommentError) {
@@ -9,6 +9,7 @@ import {
CreateCommentInput,
RevisionMetadata,
} from "coral-server/models/comment";
import { CommentMedia } from "coral-server/models/comment/revision";
import { Story } from "coral-server/models/story";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
@@ -64,12 +65,16 @@ export interface ModerationPhaseContextInput {
log: Logger;
story: Story;
tenant: Tenant;
comment: RequireProperty<Partial<CreateCommentInput>, "body" | "ancestorIDs">;
comment: RequireProperty<
Partial<Omit<CreateCommentInput, "media">>,
"body" | "ancestorIDs"
>;
author: User;
now: Date;
action: "NEW" | "EDIT";
nudge?: boolean;
req?: Request;
media?: CommentMedia;
}
export interface ModerationPhaseContext extends ModerationPhaseContextInput {
+173
View File
@@ -0,0 +1,173 @@
import Joi from "@hapi/joi";
import { URL } from "url";
import { GIPHY_FETCH, GIPHY_SEARCH } from "coral-common/constants";
import { LanguageCode } from "coral-common/helpers";
import {
GiphyGifRetrieveResponse,
GiphyGifSearchResponse,
} from "coral-common/rest/external/giphy";
import { InternalError } from "coral-server/errors";
import { validateSchema } from "coral-server/helpers";
import { supportsMediaType, Tenant } from "coral-server/models/tenant";
import { createFetch } from "coral-server/services/fetch";
const RATINGS_ORDER = ["g", "pg", "pg13", "r"];
const fetch = createFetch({ name: "giphy" });
type GiphyLanguage = "en" | "es" | "fr" | "de" | "pt";
const GiphyGifImageSchema = Joi.object().keys({
url: Joi.string().required(),
width: Joi.string().required(),
height: Joi.string().required(),
});
const GiphyGifOriginalImageSchema = Joi.object().keys({
url: Joi.string().required(),
width: Joi.string().required(),
height: Joi.string().required(),
mp4: Joi.string().required(),
});
const GiphyGifImagesSchema = Joi.object().keys({
original: GiphyGifOriginalImageSchema.required(),
fixed_height_downsampled: GiphyGifImageSchema.required(),
original_still: GiphyGifImageSchema.required(),
});
const GiphyGifSchema = Joi.object().keys({
id: Joi.string().required(),
url: Joi.string().required(),
title: Joi.string().optional().allow(""),
rating: Joi.string().required(),
images: GiphyGifImagesSchema,
});
const GiphySearchResponseSchema = Joi.object().keys({
data: Joi.array().items(GiphyGifSchema),
pagination: Joi.object().keys({
offset: Joi.number().required(),
total_count: Joi.number().required(),
count: Joi.number().required(),
}),
});
const GiphyRetrieveResponseSchema = Joi.object().keys({
data: GiphyGifSchema.required(),
});
export function ratingIsAllowed(rating: string, tenant: Tenant) {
const compareRating = rating.toLowerCase();
if (tenant.media?.giphy.maxRating && RATINGS_ORDER.includes(compareRating)) {
return (
RATINGS_ORDER.indexOf(compareRating) <=
RATINGS_ORDER.indexOf(tenant.media.giphy.maxRating)
);
}
return false;
}
/**
* convertLanguage returns the language code for the related Perspective API
* model in the ISO 631-1 format.
*
* @param locale the language on the tenant in the BCP 47 format.
*/
function convertLanguage(locale: LanguageCode): GiphyLanguage {
switch (locale) {
case "en-US":
return "en";
case "es":
return "es";
case "fr-FR":
return "fr";
case "de":
return "de";
case "pt-BR":
return "pt";
default:
return "en";
}
}
export async function searchGiphy(
query: string,
offset: string,
tenant: Tenant
): Promise<GiphyGifSearchResponse> {
if (!supportsMediaType(tenant, "giphy")) {
throw new InternalError("Giphy was not enabled");
}
const language = convertLanguage(tenant.locale);
const url = new URL(GIPHY_SEARCH);
url.searchParams.set("api_key", tenant.media.giphy.key!);
url.searchParams.set("limit", "10");
url.searchParams.set("lang", language);
url.searchParams.set("offset", offset);
url.searchParams.set("rating", tenant.media.giphy.maxRating!);
url.searchParams.set("q", query);
try {
const res = await fetch(url.toString());
if (!res.ok) {
throw new InternalError("response from Giphy was not ok", {
status: res.status,
});
}
// Parse the JSON body and send back the result!
const data = await res.json();
return validateSchema(GiphySearchResponseSchema, data);
} catch (err) {
// Ensure that the API key doesn't get leaked to the logs by accident.
if (err.message) {
err.message = err.message.replace(
url.searchParams.toString(),
"[Sensitive]"
);
}
// Rethrow the error.
throw err;
}
}
export async function retrieveFromGiphy(
tenant: Tenant,
id: string
): Promise<GiphyGifRetrieveResponse> {
if (!supportsMediaType(tenant, "giphy")) {
throw new InternalError("Giphy was not enabled");
}
const url = new URL(`${GIPHY_FETCH}/${id}`);
url.searchParams.set("api_key", tenant.media.giphy.key!);
try {
const res = await fetch(url.toString());
if (!res.ok) {
throw new InternalError("response from Giphy was not ok", {
status: res.status,
});
}
// Parse the JSON body and send back the result!
const data = await res.json();
return validateSchema(GiphyRetrieveResponseSchema, data);
} catch (err) {
// Ensure that the API key doesn't get leaked to the logs by accident.
if (err.message) {
err.message = err.message.replace(
url.searchParams.toString(),
"[Sensitive]"
);
}
// Rethrow the error.
throw err;
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./giphy";
+1
View File
@@ -0,0 +1 @@
export * from "./oembed";
+69
View File
@@ -0,0 +1,69 @@
import Joi from "@hapi/joi";
import { InternalError } from "coral-server/errors";
import { validateSchema } from "coral-server/helpers";
import { createFetch } from "coral-server/services/fetch";
const OEmbedResponseSchema = Joi.object().keys({
width: Joi.number().optional(),
height: Joi.number().optional().allow(null),
thumbnail_url: Joi.string().optional(),
title: Joi.string().optional(),
html: Joi.string().optional(),
});
interface OEmbedResponse {
width?: number;
height?: number | null;
title?: string;
thumbnail_url?: string;
html: string;
}
const fetch = createFetch({ name: "oEmbed-fetch" });
export async function fetchOEmbedResponse(
type: "twitter" | "youtube",
url: string,
maxWidth?: number
) {
let uri: string;
switch (type) {
case "youtube": {
uri = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}`;
if (maxWidth) {
uri += `&maxWidth=${encodeURIComponent(maxWidth)}`;
}
break;
}
case "twitter": {
uri = `https://publish.twitter.com/oembed?url=${encodeURIComponent(url)}`;
break;
}
default:
throw new Error(`invalid oEmbed type: ${type}`);
}
const res = await fetch(uri);
if (!res.ok) {
if (res.status === 404) {
return null;
}
throw new InternalError("response from oEmbed was not ok", {
type,
uri,
status: res.status,
});
}
// Parse the json from the oEmbed response.
const json = await res.json();
// Validate and return the response.
return validateSchema<OEmbedResponse>(OEmbedResponseSchema, json);
}
+22 -2
View File
@@ -16,6 +16,7 @@ import {
} from "coral-server/models/action/comment";
import {
Comment,
CommentMedia,
createComment,
CreateCommentInput,
pushChildCommentIDOntoParent,
@@ -39,6 +40,10 @@ import {
addCommentActions,
CreateAction,
} from "coral-server/services/comments/actions";
import {
attachMedia,
CreateCommentMediaInput,
} from "coral-server/services/comments/media";
import {
PhaseResult,
processForModeration,
@@ -57,8 +62,16 @@ import { publishChanges, updateAllCommentCounts } from "./helpers";
export type CreateComment = Omit<
CreateCommentInput,
"status" | "metadata" | "ancestorIDs" | "actionCounts" | "tags" | "siteID"
>;
| "status"
| "metadata"
| "ancestorIDs"
| "actionCounts"
| "tags"
| "siteID"
| "media"
> & {
media?: CreateCommentMediaInput;
};
const markCommentAsAnswered = async (
mongo: Db,
@@ -167,6 +180,11 @@ export default async function create(
);
}
let media: CommentMedia | undefined;
if (input.media) {
media = await attachMedia(tenant, input.media, input.body);
}
let result: PhaseResult;
try {
// Run the comment through the moderation phases.
@@ -183,6 +201,7 @@ export default async function create(
author,
req,
now,
media,
});
} catch (err) {
if (
@@ -231,6 +250,7 @@ export default async function create(
ancestorIDs,
metadata,
actionCounts,
media,
},
now
);
+20 -2
View File
@@ -14,6 +14,7 @@ import { createCommentModerationAction } from "coral-server/models/action/modera
import {
editComment,
EditCommentInput,
getLatestRevision,
retrieveComment,
validateEditable,
} from "coral-server/models/comment";
@@ -24,6 +25,10 @@ import {
addCommentActions,
CreateAction,
} from "coral-server/services/comments/actions";
import {
attachMedia,
CreateCommentMediaInput,
} from "coral-server/services/comments/media";
import { processForModeration } from "coral-server/services/comments/pipeline";
import { AugmentedRedis } from "coral-server/services/redis";
import { Request } from "coral-server/types/express";
@@ -49,8 +54,10 @@ function getLastCommentEditableUntilDate(
export type EditComment = Omit<
EditCommentInput,
"status" | "authorID" | "lastEditableCommentCreatedAt" | "metadata"
>;
"status" | "authorID" | "lastEditableCommentCreatedAt" | "metadata" | "media"
> & {
media?: CreateCommentMediaInput;
};
export default async function edit(
mongo: Db,
@@ -76,6 +83,9 @@ export default async function edit(
throw new CommentNotFoundError(input.id);
}
// Get the original stale revision.
const originalStaleRevision = getLatestRevision(originalStaleComment);
// The editable time is based on the current time, and the edit window
// length. By subtracting the current date from the edit window length, we
// get the maximum value for the `createdAt` time that would be permitted
@@ -101,6 +111,12 @@ export default async function edit(
throw new StoryNotFoundError(originalStaleComment.storyID);
}
let media = originalStaleRevision.media;
if (input.media) {
// TODO: (wyattjoh) check to see if the media is the same.
media = await attachMedia(tenant, input.media, input.body);
}
// Run the comment through the moderation phases.
const { body, status, metadata, actions } = await processForModeration({
log,
@@ -115,6 +131,7 @@ export default async function edit(
...input,
authorID: author.id,
},
media,
author,
req,
now,
@@ -139,6 +156,7 @@ export default async function edit(
metadata,
actionCounts,
lastEditableCommentCreatedAt,
media,
},
now
);