mirror of
https://github.com/wassname/talk.git
synced 2026-09-11 12:51:33 +08:00
[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:
co-authored by
nick-funk
Wyatt Johnson
parent
f8234e53ed
commit
c59c345756
@@ -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
|
||||
|
||||
@@ -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,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" },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
export { default as createTimer } from "./createTimer";
|
||||
export { default as relativeTo } from "./relativeTo";
|
||||
export { default as validateSchema } from "./validateSchema";
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./giphy";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./oembed";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user