diff --git a/api/.gitignore b/api/.gitignore index 587dace..4742dfb 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -1,3 +1,2 @@ .env -db.json /uploads diff --git a/api/.prettierignore b/api/.prettierignore index 4aa9c0e..cce0279 100644 --- a/api/.prettierignore +++ b/api/.prettierignore @@ -1,3 +1,2 @@ package.json package-lock.json -db.json diff --git a/api/config/UPLOAD_DIRECTORY_URL.mjs b/api/config/UPLOAD_DIRECTORY_URL.mjs new file mode 100644 index 0000000..d107257 --- /dev/null +++ b/api/config/UPLOAD_DIRECTORY_URL.mjs @@ -0,0 +1 @@ +export default new URL('../uploads/', import.meta.url); diff --git a/api/package-lock.json b/api/package-lock.json index 5e177f6..7df4196 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -12,7 +12,6 @@ "graphql": "^15.5.0", "graphql-upload": "^12.0.0", "koa": "^2.13.1", - "lowdb": "^2.1.0", "make-dir": "^3.1.0", "shortid": "^2.2.16" }, @@ -3251,17 +3250,6 @@ "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" }, - "node_modules/lowdb": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-2.1.0.tgz", - "integrity": "sha512-F4Go8/V37gAidTR3c5poyjprOpZSDNSLJVOmI0ny4D4q9rC37OkBhlzX0bqj7LZlT3UIj4FchmZrrSw7qY+eGQ==", - "dependencies": { - "steno": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, "node_modules/lowercase-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", @@ -4397,11 +4385,6 @@ "node": ">= 0.6" } }, - "node_modules/steno": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/steno/-/steno-1.0.0.tgz", - "integrity": "sha512-C/KgCvEa1yWnpHmaPjAXrz1yWxh6hs+HvhqqPa71euaQmNi1wr4+WFo57VQxjKKuFl2KqS7gtlrN0oxj2noQLw==" - }, "node_modules/streamsearch": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", @@ -7530,14 +7513,6 @@ "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" }, - "lowdb": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-2.1.0.tgz", - "integrity": "sha512-F4Go8/V37gAidTR3c5poyjprOpZSDNSLJVOmI0ny4D4q9rC37OkBhlzX0bqj7LZlT3UIj4FchmZrrSw7qY+eGQ==", - "requires": { - "steno": "^1.0.0" - } - }, "lowercase-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", @@ -8383,11 +8358,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" }, - "steno": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/steno/-/steno-1.0.0.tgz", - "integrity": "sha512-C/KgCvEa1yWnpHmaPjAXrz1yWxh6hs+HvhqqPa71euaQmNi1wr4+WFo57VQxjKKuFl2KqS7gtlrN0oxj2noQLw==" - }, "streamsearch": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", diff --git a/api/package.json b/api/package.json index f3240f0..b76adcc 100644 --- a/api/package.json +++ b/api/package.json @@ -25,7 +25,6 @@ "graphql": "^15.5.0", "graphql-upload": "^12.0.0", "koa": "^2.13.1", - "lowdb": "^2.1.0", "make-dir": "^3.1.0", "shortid": "^2.2.16" }, @@ -44,7 +43,7 @@ "test": "npm run test:eslint && npm run test:prettier", "test:eslint": "eslint .", "test:prettier": "prettier -c .", - "dev": "nodemon -i db.json", + "dev": "nodemon", "start": "node -r dotenv/config server.mjs" } } diff --git a/api/schema/FileType.mjs b/api/schema/FileType.mjs index cb9081f..e849add 100644 --- a/api/schema/FileType.mjs +++ b/api/schema/FileType.mjs @@ -1,9 +1,5 @@ -import { - GraphQLID, - GraphQLNonNull, - GraphQLObjectType, - GraphQLString, -} from 'graphql'; +import { GraphQLNonNull, GraphQLObjectType, GraphQLString } from 'graphql'; +import UPLOAD_DIRECTORY_URL from '../config/UPLOAD_DIRECTORY_URL.mjs'; export default new GraphQLObjectType({ name: 'File', @@ -11,19 +7,19 @@ export default new GraphQLObjectType({ fields: () => ({ id: { description: 'Unique ID.', - type: GraphQLNonNull(GraphQLID), - }, - path: { - description: 'Where it’s stored in the filesystem.', type: GraphQLNonNull(GraphQLString), + resolve: (storedFileName) => storedFileName, }, - filename: { - description: 'Filename, including extension.', + name: { + description: 'File name.', type: GraphQLNonNull(GraphQLString), + resolve: (storedFileName) => storedFileName, }, - mimetype: { - description: 'MIME type.', + url: { + description: 'File URL.', type: GraphQLNonNull(GraphQLString), + resolve: (storedFileName) => + new URL(storedFileName, UPLOAD_DIRECTORY_URL), }, }), }); diff --git a/api/schema/MutationType.mjs b/api/schema/MutationType.mjs index 121128c..29bd9ac 100644 --- a/api/schema/MutationType.mjs +++ b/api/schema/MutationType.mjs @@ -1,5 +1,6 @@ import { GraphQLList, GraphQLNonNull, GraphQLObjectType } from 'graphql'; import { GraphQLUpload } from 'graphql-upload'; +import storeUpload from '../storeUpload.mjs'; import FileType from './FileType.mjs'; export default new GraphQLObjectType({ @@ -14,7 +15,7 @@ export default new GraphQLObjectType({ type: GraphQLNonNull(GraphQLUpload), }, }, - resolve: (parent, { file }, { storeUpload }) => storeUpload(file), + resolve: (parent, { file }) => storeUpload(file), }, multipleUpload: { description: 'Stores multiple files.', @@ -25,7 +26,7 @@ export default new GraphQLObjectType({ type: GraphQLNonNull(GraphQLList(GraphQLNonNull(GraphQLUpload))), }, }, - async resolve(parent, { files }, { storeUpload }) { + async resolve(parent, { files }) { // Ensure an error storing one upload doesn’t prevent storing the rest. const results = await Promise.allSettled(files.map(storeUpload)); return results.reduce((storedFiles, { value, reason }) => { diff --git a/api/schema/QueryType.mjs b/api/schema/QueryType.mjs index 7ed66dd..3e7e3ca 100644 --- a/api/schema/QueryType.mjs +++ b/api/schema/QueryType.mjs @@ -1,4 +1,6 @@ +import fs from 'fs'; import { GraphQLList, GraphQLNonNull, GraphQLObjectType } from 'graphql'; +import UPLOAD_DIRECTORY_URL from '../config/UPLOAD_DIRECTORY_URL.mjs'; import FileType from './FileType.mjs'; export default new GraphQLObjectType({ @@ -7,10 +9,7 @@ export default new GraphQLObjectType({ uploads: { description: 'All stored files.', type: GraphQLNonNull(GraphQLList(GraphQLNonNull(FileType))), - async resolve(source, args, { db }) { - await db.read(); - return db.data.uploads; - }, + resolve: () => fs.promises.readdir(UPLOAD_DIRECTORY_URL), }, }), }); diff --git a/api/server.mjs b/api/server.mjs index a900709..fb7a548 100644 --- a/api/server.mjs +++ b/api/server.mjs @@ -1,65 +1,11 @@ -import { createWriteStream, unlink } from 'fs'; import { fileURLToPath } from 'url'; import { ApolloServer } from 'apollo-server-koa'; import { graphqlUploadKoa } from 'graphql-upload'; import Koa from 'koa'; -// `eslint-plugin-node` doesn’t support the package `exports` field, see: -// https://github.com/mysticatea/eslint-plugin-node/issues/255 -// eslint-disable-next-line node/no-missing-import -import { JSONFile, Low } from 'lowdb'; import makeDir from 'make-dir'; -import shortId from 'shortid'; +import UPLOAD_DIRECTORY_URL from './config/UPLOAD_DIRECTORY_URL.mjs'; import schema from './schema/index.mjs'; -const UPLOAD_DIR = new URL('uploads/', import.meta.url); -const db = new Low(new JSONFile('db.json')); - -/** - * Stores a GraphQL file upload. The file is stored in the filesystem and its - * metadata is recorded in the DB. - * @param {Promise} upload GraphQL file upload. - * @returns {Promise} File metadata. - */ -async function storeUpload(upload) { - const { createReadStream, filename, mimetype } = await upload; - const stream = createReadStream(); - const id = shortId.generate(); - const path = new URL(`${id}-${filename}`, UPLOAD_DIR); - const file = { id, filename, mimetype, path }; - - // Store the file in the filesystem. - await new Promise((resolve, reject) => { - // Create a stream to which the upload will be written. - const writeStream = createWriteStream(path); - - // When the upload is fully written, resolve the promise. - writeStream.on('finish', resolve); - - // If there's an error writing the file, remove the partially written file - // and reject the promise. - writeStream.on('error', (error) => { - unlink(path, () => { - reject(error); - }); - }); - - // In Node.js <= v13, errors are not automatically propagated between piped - // streams. If there is an error receiving the upload, destroy the write - // stream with the corresponding error. - stream.on('error', (error) => writeStream.destroy(error)); - - // Pipe the upload into the write stream. - stream.pipe(writeStream); - }); - - // Record the file metadata in the DB. - await db.read(); - db.data.uploads.push(file); - await db.write(); - - return file; -} - const app = new Koa().use( graphqlUploadKoa({ // Limits here should be stricter than config for surrounding @@ -77,24 +23,14 @@ new ApolloServer({ // https://github.com/apollographql/apollo-server/issues/3508#issuecomment-662371289 uploads: false, schema, - context: { db, storeUpload }, }).applyMiddleware({ app }); /** * Starts the API server. */ async function startServer() { - await db.read(); - - // Seed an empty DB. - if (!db.data) { - db.data = { uploads: [] }; - - await db.write(); - } - - // Ensure upload directory exists. - await makeDir(fileURLToPath(UPLOAD_DIR)); + // Ensure the upload directory exists. + await makeDir(fileURLToPath(UPLOAD_DIRECTORY_URL)); app.listen(process.env.PORT, (error) => { if (error) throw error; diff --git a/api/storeUpload.mjs b/api/storeUpload.mjs new file mode 100644 index 0000000..458bd45 --- /dev/null +++ b/api/storeUpload.mjs @@ -0,0 +1,42 @@ +import { createWriteStream, unlink } from 'fs'; +import shortId from 'shortid'; +import UPLOAD_DIRECTORY_URL from './config/UPLOAD_DIRECTORY_URL.mjs'; + +/** + * Stores a GraphQL file upload in the filesystem. + * @param {Promise} upload GraphQL file upload. + * @returns {Promise} Resolves the stored file name. + */ +export default async function storeUpload(upload) { + const { createReadStream, filename } = await upload; + const stream = createReadStream(); + const storedFileName = `${shortId.generate()}-${filename}`; + const storedFileUrl = new URL(storedFileName, UPLOAD_DIRECTORY_URL); + + // Store the file in the filesystem. + await new Promise((resolve, reject) => { + // Create a stream to which the upload will be written. + const writeStream = createWriteStream(storedFileUrl); + + // When the upload is fully written, resolve the promise. + writeStream.on('finish', resolve); + + // If there's an error writing the file, remove the partially written file + // and reject the promise. + writeStream.on('error', (error) => { + unlink(storedFileUrl, () => { + reject(error); + }); + }); + + // In Node.js <= v13, errors are not automatically propagated between piped + // streams. If there is an error receiving the upload, destroy the write + // stream with the corresponding error. + stream.on('error', (error) => writeStream.destroy(error)); + + // Pipe the upload into the write stream. + stream.pipe(writeStream); + }); + + return storedFileName; +} diff --git a/app/components/Uploads.js b/app/components/Uploads.js index 9a1e23a..6802bd4 100644 --- a/app/components/Uploads.js +++ b/app/components/Uploads.js @@ -6,9 +6,7 @@ const UPLOADS_QUERY = gql` query uploads { uploads { id - filename - mimetype - path + url } } `; @@ -21,17 +19,13 @@ export function Uploads() { - - - + - {uploads.map(({ id, filename, mimetype, path }) => ( + {uploads.map(({ id, url }) => ( - - - + ))}
FilenameMIME typePathStored file URL
{filename}{mimetype}{path}{url}