@@ -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());
|
||||
|
||||
1
server/routes/api/urls/index.ts
Normal file
1
server/routes/api/urls/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./urls";
|
||||
36
server/routes/api/urls/schema.ts
Normal file
36
server/routes/api/urls/schema.ts
Normal 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>;
|
||||
140
server/routes/api/urls/urls.test.ts
Normal file
140
server/routes/api/urls/urls.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
64
server/routes/api/urls/urls.ts
Normal file
64
server/routes/api/urls/urls.ts
Normal 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;
|
||||
Reference in New Issue
Block a user