diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index d5d831fa0..cbf69a373 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -8,12 +8,10 @@ import { Optional } from "utility-types"; import insertFiles from "@shared/editor/commands/insertFiles"; import embeds from "@shared/editor/embeds"; import { Heading } from "@shared/editor/lib/getHeadings"; -import { - getDataTransferFiles, - supportedImageMimeTypes, -} from "@shared/utils/files"; +import { getDataTransferFiles } from "@shared/utils/files"; import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; import { isInternalUrl } from "@shared/utils/urls"; +import { AttachmentValidation } from "@shared/validations"; import Document from "~/models/Document"; import ClickablePadding from "~/components/ClickablePadding"; import ErrorBoundary from "~/components/ErrorBoundary"; @@ -212,7 +210,7 @@ function Editor(props: Props, ref: React.RefObject | null) { // Insert all files as attachments if any of the files are not images. const isAttachment = files.some( - (file) => !supportedImageMimeTypes.includes(file.type) + (file) => !AttachmentValidation.imageContentTypes.includes(file.type) ); insertFiles(view, event, pos, files, { diff --git a/app/editor/components/CommandMenu.tsx b/app/editor/components/CommandMenu.tsx index f6554fc3d..4f828e9d9 100644 --- a/app/editor/components/CommandMenu.tsx +++ b/app/editor/components/CommandMenu.tsx @@ -11,7 +11,8 @@ import { CommandFactory } from "@shared/editor/lib/Extension"; import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators"; import { EmbedDescriptor, MenuItem } from "@shared/editor/types"; import { depths } from "@shared/styles"; -import { supportedImageMimeTypes, getEventFiles } from "@shared/utils/files"; +import { getEventFiles } from "@shared/utils/files"; +import { AttachmentValidation } from "@shared/validations"; import Scrollable from "~/components/Scrollable"; import { Dictionary } from "~/hooks/useDictionary"; import Input from "./Input"; @@ -181,7 +182,9 @@ class CommandMenu extends React.Component, State> { insertItem = (item: any) => { switch (item.name) { case "image": - return this.triggerFilePick(supportedImageMimeTypes.join(", ")); + return this.triggerFilePick( + AttachmentValidation.imageContentTypes.join(", ") + ); case "attachment": return this.triggerFilePick("*"); case "embed": diff --git a/app/scenes/Settings/components/ImageUpload.tsx b/app/scenes/Settings/components/ImageUpload.tsx index 2b33ef4bb..696149b32 100644 --- a/app/scenes/Settings/components/ImageUpload.tsx +++ b/app/scenes/Settings/components/ImageUpload.tsx @@ -5,6 +5,7 @@ import * as React from "react"; import AvatarEditor from "react-avatar-editor"; import Dropzone from "react-dropzone"; import styled from "styled-components"; +import { AttachmentValidation } from "@shared/validations"; import RootStore from "~/stores/RootStore"; import Button from "~/components/Button"; import Flex from "~/components/Flex"; @@ -134,7 +135,7 @@ class ImageUpload extends React.Component { return ( {({ getRootProps, getInputProps }) => ( diff --git a/server/routes/api/attachments.test.ts b/server/routes/api/attachments.test.ts index c201d1bfa..f8ff03758 100644 --- a/server/routes/api/attachments.test.ts +++ b/server/routes/api/attachments.test.ts @@ -13,9 +13,46 @@ import { flushdb } from "@server/test/support"; const app = webService(); const server = new TestServer(app.callback()); +jest.mock("@server/utils/s3"); + beforeEach(() => flushdb()); afterAll(() => server.close()); +describe("#attachments.create", () => { + it("should require authentication", async () => { + const res = await server.post("/api/attachments.create"); + expect(res.status).toEqual(401); + }); + + it("should allow simple image upload for public attachments", async () => { + const user = await buildUser(); + const res = await server.post("/api/attachments.create", { + body: { + name: "test.png", + contentType: "image/png", + size: 1000, + public: true, + token: user.getJwtToken(), + }, + }); + expect(res.status).toEqual(200); + }); + + it("should not allow file upload for public attachments", async () => { + const user = await buildUser(); + const res = await server.post("/api/attachments.create", { + body: { + name: "test.pdf", + contentType: "application/pdf", + size: 1000, + public: true, + token: user.getJwtToken(), + }, + }); + expect(res.status).toEqual(400); + }); +}); + describe("#attachments.delete", () => { it("should require authentication", async () => { const res = await server.post("/api/attachments.delete"); diff --git a/server/routes/api/attachments.ts b/server/routes/api/attachments.ts index fccfc6865..76cccd199 100644 --- a/server/routes/api/attachments.ts +++ b/server/routes/api/attachments.ts @@ -1,6 +1,7 @@ import Router from "koa-router"; import { v4 as uuidv4 } from "uuid"; import { bytesToHumanReadable } from "@shared/utils/files"; +import { AttachmentValidation } from "@shared/validations"; import { sequelize } from "@server/database/sequelize"; import { AuthorizationError, ValidationError } from "@server/errors"; import auth from "@server/middlewares/authentication"; @@ -11,12 +12,13 @@ import { publicS3Endpoint, getSignedUrl, } from "@server/utils/s3"; -import { assertPresent, assertUuid } from "@server/validation"; +import { assertIn, assertPresent, assertUuid } from "@server/validation"; const router = new Router(); const AWS_S3_ACL = process.env.AWS_S3_ACL || "private"; router.post("attachments.create", auth(), async (ctx) => { + const isPublic = ctx.body.public; const { name, documentId, @@ -25,9 +27,15 @@ router.post("attachments.create", auth(), async (ctx) => { } = ctx.body; assertPresent(name, "name is required"); assertPresent(size, "size is required"); + const { user } = ctx.state; authorize(user, "createAttachment", user.team); + // Public attachments are only used for avatars, so this is loosely coupled. + if (isPublic) { + assertIn(contentType, AttachmentValidation.avatarContentTypes); + } + if ( process.env.AWS_S3_UPLOAD_MAX_SIZE && size > process.env.AWS_S3_UPLOAD_MAX_SIZE @@ -39,7 +47,6 @@ router.post("attachments.create", auth(), async (ctx) => { ); } - const isPublic = ctx.body.public; const s3Key = uuidv4(); const acl = isPublic === undefined ? AWS_S3_ACL : isPublic ? "public-read" : "private"; diff --git a/server/utils/__mocks__/s3.ts b/server/utils/__mocks__/s3.ts index 7caa2cf69..407f94a80 100644 --- a/server/utils/__mocks__/s3.ts +++ b/server/utils/__mocks__/s3.ts @@ -5,3 +5,5 @@ export const publicS3Endpoint = jest.fn().mockReturnValue("http://mock"); export const getSignedUrl = jest.fn().mockReturnValue("http://s3mock"); export const getSignedUrlPromise = jest.fn().mockResolvedValue("http://s3mock"); + +export const getPresignedPost = jest.fn().mockReturnValue({}); diff --git a/shared/editor/nodes/Image.tsx b/shared/editor/nodes/Image.tsx index 3c17965b0..b1e3949ce 100644 --- a/shared/editor/nodes/Image.tsx +++ b/shared/editor/nodes/Image.tsx @@ -11,11 +11,8 @@ import { import * as React from "react"; import ImageZoom from "react-medium-image-zoom"; import styled from "styled-components"; -import { - getDataTransferFiles, - supportedImageMimeTypes, - getEventFiles, -} from "../../utils/files"; +import { getDataTransferFiles, getEventFiles } from "../../utils/files"; +import { AttachmentValidation } from "../../validations"; import insertFiles, { Options } from "../commands/insertFiles"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; import uploadPlaceholderPlugin from "../lib/uploadPlaceholder"; @@ -413,7 +410,7 @@ export default class Image extends Node { // create an input element and click to trigger picker const inputElement = document.createElement("input"); inputElement.type = "file"; - inputElement.accept = supportedImageMimeTypes.join(", "); + inputElement.accept = AttachmentValidation.imageContentTypes.join(", "); inputElement.onchange = (event) => { const files = getEventFiles(event); insertFiles(view, event, state.selection.from, files, { diff --git a/shared/utils/files.ts b/shared/utils/files.ts index cd483e0fc..efcb544f6 100644 --- a/shared/utils/files.ts +++ b/shared/utils/files.ts @@ -83,21 +83,3 @@ export function getEventFiles( ? Array.prototype.slice.call(event.target.files) : []; } - -/** - * An array of image mimetypes commonly supported by modern browsers - */ -export const supportedImageMimeTypes = [ - "image/jpg", - "image/jpeg", - "image/pjpeg", - "image/png", - "image/apng", - "image/avif", - "image/gif", - "image/webp", - "image/svg", - "image/svg+xml", - "image/bmp", - "image/tiff", -]; diff --git a/shared/validations.ts b/shared/validations.ts index b1d2bf7b3..e525d524d 100644 --- a/shared/validations.ts +++ b/shared/validations.ts @@ -1,3 +1,24 @@ +export const AttachmentValidation = { + /** The limited allowable mime-types for user and team avatars */ + avatarContentTypes: ["image/jpg", "image/jpeg", "image/png"], + + /** Image mime-types commonly supported by modern browsers */ + imageContentTypes: [ + "image/jpg", + "image/jpeg", + "image/pjpeg", + "image/png", + "image/apng", + "image/avif", + "image/gif", + "image/webp", + "image/svg", + "image/svg+xml", + "image/bmp", + "image/tiff", + ], +}; + export const CollectionValidation = { /** The maximum length of the collection description */ maxDescriptionLength: 1000,