Preview mentions (#5571)

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Apoorv Mishra
2023-07-22 21:43:09 +05:30
committed by GitHub
parent dbd85d62cb
commit 5d71398ea6
27 changed files with 923 additions and 361 deletions

View File

@@ -0,0 +1,103 @@
import { differenceInMinutes, formatDistanceToNowStrict } from "date-fns";
import { t } from "i18next";
import { head, orderBy } from "lodash";
import { dateLocale } from "@shared/utils/date";
import { Document, User } from "@server/models";
import { opts } from "@server/utils/i18n";
export const presentLastOnlineInfoFor = (user: User) => {
const locale = dateLocale(user.language);
let info: string;
if (!user.lastActiveAt) {
info = t("Never logged in", { ...opts(user) });
} else if (differenceInMinutes(new Date(), user.lastActiveAt) < 5) {
info = t("Online now", { ...opts(user) });
} else {
info = t("Online {{ timeAgo }}", {
timeAgo: formatDistanceToNowStrict(user.lastActiveAt, {
addSuffix: true,
locale,
}),
...opts(user),
});
}
return info;
};
export const presentLastViewedInfoFor = (user: User, document: Document) => {
const lastView = head(orderBy(document.views, ["updatedAt"], ["desc"]));
const lastViewedAt = lastView ? lastView.updatedAt : undefined;
const locale = dateLocale(user.language);
let info: string;
if (!lastViewedAt) {
info = t("Never viewed", { ...opts(user) });
} else if (differenceInMinutes(new Date(), lastViewedAt) < 5) {
info = t("Viewed just now", { ...opts(user) });
} else {
info = t("Viewed {{ timeAgo }}", {
timeAgo: formatDistanceToNowStrict(lastViewedAt, {
addSuffix: true,
locale,
}),
...opts(user),
});
}
return info;
};
export const presentLastActivityInfoFor = (
document: Document,
viewer: User
) => {
const locale = dateLocale(viewer.language);
const wasUpdated = document.createdAt !== document.updatedAt;
let info: string;
if (wasUpdated) {
const lastUpdatedByViewer = document.updatedBy.id === viewer.id;
if (lastUpdatedByViewer) {
info = t("You updated {{ timeAgo }}", {
timeAgo: formatDistanceToNowStrict(document.updatedAt, {
addSuffix: true,
locale,
}),
...opts(viewer),
});
} else {
info = t("{{ user }} updated {{ timeAgo }}", {
user: document.updatedBy.name,
timeAgo: formatDistanceToNowStrict(document.updatedAt, {
addSuffix: true,
locale,
}),
...opts(viewer),
});
}
} else {
const lastCreatedByViewer = document.createdById === viewer.id;
if (lastCreatedByViewer) {
info = t("You created {{ timeAgo }}", {
timeAgo: formatDistanceToNowStrict(document.createdAt, {
addSuffix: true,
locale,
}),
...opts(viewer),
});
} else {
info = t("{{ user }} created {{ timeAgo }}", {
user: document.createdBy.name,
timeAgo: formatDistanceToNowStrict(document.createdAt, {
addSuffix: true,
locale,
}),
...opts(viewer),
});
}
}
return info;
};

View File

@@ -0,0 +1,21 @@
import { Unfurl, UnfurlType } from "@shared/types";
import { User, Document } from "@server/models";
import { presentLastActivityInfoFor } from "./common";
function presentDocument(
document: Document,
viewer: User
): Unfurl<UnfurlType.Document> {
return {
url: document.url,
type: UnfurlType.Document,
title: document.titleWithDefault,
description: presentLastActivityInfoFor(document, viewer),
meta: {
id: document.id,
summary: document.getSummary(),
},
};
}
export default presentDocument;

View File

@@ -0,0 +1,4 @@
import presentDocument from "./document";
import presentMention from "./mention";
export { presentDocument, presentMention };

View File

@@ -0,0 +1,23 @@
import { Unfurl, UnfurlType } from "@shared/types";
import { Document, User } from "@server/models";
import { presentLastOnlineInfoFor, presentLastViewedInfoFor } from "./common";
function presentMention(
user: User,
document: Document
): Unfurl<UnfurlType.Mention> {
return {
type: UnfurlType.Mention,
title: user.name,
description: `${presentLastOnlineInfoFor(
user
)}${presentLastViewedInfoFor(user, document)}`,
thumbnailUrl: user.avatarUrl,
meta: {
id: user.id,
color: user.color,
},
};
}
export default presentMention;

View File

@@ -32,6 +32,7 @@ import shares from "./shares";
import stars from "./stars";
import subscriptions from "./subscriptions";
import teams from "./teams";
import urls from "./urls";
import users from "./users";
import views from "./views";
@@ -86,6 +87,7 @@ router.use("/", attachments.routes());
router.use("/", cron.routes());
router.use("/", groups.routes());
router.use("/", fileOperationsRoute.routes());
router.use("/", urls.routes());
if (env.ENVIRONMENT === "development") {
router.use("/", developer.routes());

View File

@@ -0,0 +1 @@
export { default } from "./urls";

View File

@@ -0,0 +1,36 @@
import { isNil } from "lodash";
import { z } from "zod";
import { isUrl } from "@shared/utils/urls";
import { ValidateURL } from "@server/validation";
import BaseSchema from "../BaseSchema";
export const UrlsUnfurlSchema = BaseSchema.extend({
body: z
.object({
url: z
.string()
.url()
.refine(
(val) => {
try {
const url = new URL(val);
if (url.protocol === "mention:") {
return ValidateURL.isValidMentionUrl(val);
}
return isUrl(val);
} catch (err) {
return false;
}
},
{ message: ValidateURL.message }
),
documentId: z.string().uuid().optional(),
})
.refine(
(val) =>
!(ValidateURL.isValidMentionUrl(val.url) && isNil(val.documentId)),
{ message: "documentId required" }
),
});
export type UrlsUnfurlReq = z.infer<typeof UrlsUnfurlSchema>;

View File

@@ -0,0 +1,140 @@
import { User } from "@server/models";
import { buildDocument, buildUser } from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#urls.unfurl", () => {
let user: User;
beforeEach(async () => {
user = await buildUser();
});
it("should fail with status 400 bad request when url is invalid", async () => {
const res = await server.post("/api/urls.unfurl", {
body: {
token: user.getJwtToken(),
url: "/doc/foo-bar",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("url: Invalid url");
});
it("should fail with status 400 bad request when mention url is invalid", async () => {
const res = await server.post("/api/urls.unfurl", {
body: {
token: user.getJwtToken(),
url: "mention://1/foo/1",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("url: Must be a valid url");
});
it("should fail with status 400 bad request when mention url is supplied without documentId", async () => {
const res = await server.post("/api/urls.unfurl", {
body: {
token: user.getJwtToken(),
url: "mention://2767ba0e-ac5c-4533-b9cf-4f5fc456600e/user/34095ac1-c808-45c0-8c6e-6c554497de64",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("body: documentId required");
});
it("should fail with status 404 not found when mention user does not exist", async () => {
const res = await server.post("/api/urls.unfurl", {
body: {
token: user.getJwtToken(),
url: "mention://2767ba0e-ac5c-4533-b9cf-4f5fc456600e/user/34095ac1-c808-45c0-8c6e-6c554497de64",
documentId: "2767ba0e-ac5c-4533-b9cf-4f5fc456600e",
},
});
const body = await res.json();
expect(res.status).toEqual(404);
expect(body.message).toEqual("Mentioned user does not exist");
});
it("should fail with status 404 not found when document does not exist", async () => {
const mentionedUser = await buildUser({
teamId: user.teamId,
});
const res = await server.post("/api/urls.unfurl", {
body: {
token: user.getJwtToken(),
url: `mention://2767ba0e-ac5c-4533-b9cf-4f5fc456600e/user/${mentionedUser.id}`,
documentId: "2767ba0e-ac5c-4533-b9cf-4f5fc456600e",
},
});
const body = await res.json();
expect(res.status).toEqual(404);
expect(body.message).toEqual("Document does not exist");
});
it("should fail with status 403 forbidden when user is not authorized to read mentioned user info", async () => {
const mentionedUser = await buildUser();
const document = await buildDocument({
teamId: user.teamId,
});
const res = await server.post("/api/urls.unfurl", {
body: {
token: user.getJwtToken(),
url: `mention://2767ba0e-ac5c-4533-b9cf-4f5fc456600e/user/${mentionedUser.id}`,
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body.message).toEqual("Authorization error");
});
it("should succeed with status 200 ok when valid mention url is supplied", async () => {
const mentionedUser = await buildUser({ teamId: user.teamId });
const document = await buildDocument({
teamId: user.teamId,
});
const res = await server.post("/api/urls.unfurl", {
body: {
token: user.getJwtToken(),
url: `mention://2767ba0e-ac5c-4533-b9cf-4f5fc456600e/user/${mentionedUser.id}`,
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.type).toEqual("mention");
expect(body.title).toEqual(mentionedUser.name);
expect(body.meta.id).toEqual(mentionedUser.id);
});
it("should succeed with status 200 ok when valid document url is supplied", async () => {
const document = await buildDocument({
teamId: user.teamId,
});
const res = await server.post("/api/urls.unfurl", {
body: {
token: user.getJwtToken(),
url: `http://localhost:3000/${document.url}`,
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.type).toEqual("document");
expect(body.title).toEqual(document.titleWithDefault);
expect(body.meta.id).toEqual(document.id);
});
});

View File

@@ -0,0 +1,64 @@
import Router from "koa-router";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import parseMentionUrl from "@shared/utils/parseMentionUrl";
import { NotFoundError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import { Document, User } from "@server/models";
import { authorize } from "@server/policies";
import { presentDocument, presentMention } from "@server/presenters/unfurls";
import { APIContext } from "@server/types";
import * as T from "./schema";
const router = new Router();
router.post(
"urls.unfurl",
auth(),
validate(T.UrlsUnfurlSchema),
async (ctx: APIContext<T.UrlsUnfurlReq>) => {
const { url, documentId } = ctx.input.body;
const { user: actor } = ctx.state.auth;
const urlObj = new URL(url);
if (urlObj.protocol === "mention:") {
const { modelId: userId } = parseMentionUrl(url);
const [user, document] = await Promise.all([
User.findByPk(userId),
Document.findByPk(documentId!, {
userId,
}),
]);
if (!user) {
throw NotFoundError("Mentioned user does not exist");
}
if (!document) {
throw NotFoundError("Document does not exist");
}
authorize(actor, "read", user);
authorize(actor, "read", document);
ctx.body = presentMention(user, document);
return;
}
const previewDocumentId = parseDocumentSlug(url);
if (!previewDocumentId) {
ctx.response.status = 204;
return;
}
const document = previewDocumentId
? await Document.findByPk(previewDocumentId)
: undefined;
if (!document) {
throw NotFoundError("Document does not exist");
}
authorize(actor, "read", document);
ctx.body = presentDocument(document, actor);
}
);
export default router;

View File

@@ -2,7 +2,9 @@ import { isArrayLike } from "lodash";
import { Primitive } from "utility-types";
import validator from "validator";
import isUUID from "validator/lib/isUUID";
import parseMentionUrl from "@shared/utils/parseMentionUrl";
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
import { isUrl } from "@shared/utils/urls";
import { CollectionPermission } from "../shared/types";
import { validateColorHex } from "../shared/utils/color";
import { validateIndexCharacters } from "../shared/utils/indexCharacters";
@@ -186,3 +188,24 @@ export class ValidateIndex {
public static regex = new RegExp("^[\x20-\x7E]+$");
public static message = "Must be between x20 to x7E ASCII";
}
export class ValidateURL {
public static isValidMentionUrl = (url: string) => {
if (!isUrl(url)) {
return false;
}
try {
const urlObj = new URL(url);
if (urlObj.protocol !== "mention:") {
return false;
}
const { id, mentionType, modelId } = parseMentionUrl(url);
return id && isUUID(id) && mentionType === "user" && isUUID(modelId);
} catch (err) {
return false;
}
};
public static message = "Must be a valid url";
}