diff --git a/package-lock.json b/package-lock.json index 4b44369d..804bee9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,8 @@ "express-session": "^1.18.2", "jest": "^29.7.0", "jsonwebtoken": "^9.0.0", + "mime": "^3.0.0", + "multer": "^2.2.0", "mysql2": "^2.3.3", "nanoid": "^3.3.8", "nodemailer": "^9.0.1", @@ -51,6 +53,7 @@ "winston": "^3.10.0" }, "devDependencies": { + "@types/multer": "^2.2.0", "eslint": "^8.13.0", "eslint-plugin-vue": "^8.6.0", "sass": "~1.32.0", @@ -1472,6 +1475,15 @@ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" }, + "node_modules/@types/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "18.6.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.6.2.tgz", @@ -2130,6 +2142,11 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -2499,6 +2516,17 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2800,6 +2828,20 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/connect-session-sequelize": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/connect-session-sequelize/-/connect-session-sequelize-7.1.4.tgz", @@ -3751,6 +3793,17 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" }, + "node_modules/express-cookie/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/express-cookie/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -5740,14 +5793,14 @@ } }, "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", "bin": { "mime": "cli.js" }, "engines": { - "node": ">=4" + "node": ">=10.0.0" } }, "node_modules/mime-db": { @@ -5812,6 +5865,24 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mysql2": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-2.3.3.tgz", @@ -6760,6 +6831,17 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/send/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7064,6 +7146,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -7610,6 +7700,11 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, "node_modules/typescript": { "version": "5.4.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", @@ -9231,6 +9326,15 @@ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" }, + "@types/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/node": { "version": "18.6.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.6.2.tgz", @@ -9739,6 +9843,11 @@ "picomatch": "^2.0.4" } }, + "append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" + }, "arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -10007,6 +10116,14 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, + "busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "requires": { + "streamsearch": "^1.1.0" + } + }, "bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -10225,6 +10342,17 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "connect-session-sequelize": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/connect-session-sequelize/-/connect-session-sequelize-7.1.4.tgz", @@ -11012,6 +11140,11 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -12423,9 +12556,9 @@ } }, "mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==" }, "mime-db": { "version": "1.52.0", @@ -12471,6 +12604,17 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "requires": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + } + }, "mysql2": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-2.3.3.tgz", @@ -13107,6 +13251,11 @@ } } }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -13319,6 +13468,11 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" }, + "streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==" + }, "string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -13671,6 +13825,11 @@ "mime-types": "~2.1.24" } }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, "typescript": { "version": "5.4.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", diff --git a/package.json b/package.json index 12d5c16b..442eae76 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,9 @@ "@types/express-session": "^1.17.4", "@types/jsonwebtoken": "^8.5.8", "@types/nodemailer": "^6.4.16", + "@types/supertest": "^6.0.2", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", - "@types/supertest": "^6.0.2", "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", @@ -38,6 +38,8 @@ "express-session": "^1.18.2", "jest": "^29.7.0", "jsonwebtoken": "^9.0.0", + "mime": "^3.0.0", + "multer": "^2.2.0", "mysql2": "^2.3.3", "nanoid": "^3.3.8", "nodemailer": "^9.0.1", @@ -55,6 +57,7 @@ "winston": "^3.10.0" }, "devDependencies": { + "@types/multer": "^2.2.0", "eslint": "^8.13.0", "eslint-plugin-vue": "^8.6.0", "sass": "~1.32.0", diff --git a/src/app.ts b/src/app.ts index 7b89ee33..b95ad968 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,6 +2,7 @@ import { Express } from "express"; import session from "express-session"; import bodyParser from "body-parser"; import cookieParser from "cookie-parser"; +import multer from "multer"; import cors, { CorsOptions } from "cors"; import { Sequelize } from "sequelize"; import sequelizeStore from "connect-session-sequelize"; @@ -12,9 +13,11 @@ import { ALLOWED_ORIGINS } from "./utils"; import { OAS3Options } from "swagger-jsdoc"; import { schemas } from "./openapi/schemas"; -import { COSMICDS_OPENAPI_VERSION, COSMICDS_HOST, COSMICDS_OPENAPI_APIKEY_SCHEME, COSMICDS_OPENAPI_TAGS } from "./openapi/options"; +import { COSMICDS_OPENAPI_VERSION, COSMICDS_OPENAPI_APIKEY_SCHEME, COSMICDS_OPENAPI_TAGS } from "./openapi/options"; import { registerSwaggerDocs } from "./openapi/utils"; +export const uploader = multer({ storage: multer.memoryStorage() }); + export function setupApp(app: Express, db: Sequelize) { const corsOptions: CorsOptions = { diff --git a/src/database.ts b/src/database.ts index 430210de..c651335a 100644 --- a/src/database.ts +++ b/src/database.ts @@ -2,6 +2,7 @@ import { BaseError, Options, Model, Op, QueryTypes, Sequelize, UniqueConstraintE import dotenv from "dotenv"; import { createNamespace } from "cls-hooked"; +import * as AST from "@effect/schema/AST"; import * as S from "@effect/schema/Schema"; import { @@ -27,6 +28,7 @@ import { Either, Mutable, creationToUpdateAttributes, + bufferFromUint8array, } from "./utils"; @@ -47,6 +49,7 @@ import { logger } from "./logger"; import { Stage } from "./models/stage"; import { ClassSetupParams, classSetupRegistry } from "./registries"; import { UserExperienceRating } from "./models/user_experience"; +import { TemporaryFile } from "./models/temporary_file"; export type LoginResponse = { type: "none" | "student" | "educator" | "admin", @@ -1092,3 +1095,62 @@ export async function getUserExperienceForStory(uuid: string, storyName: string) } }); } + +/** Temporary files */ +export const CreateTemporaryFileSchema = S.struct({ + mime_type: S.string, + content: S.Uint8Array.pipe( + S.annotations({ + [AST.JSONSchemaAnnotationId]: { + description: "", + }, + }), + ), + filename: S.optional(S.string), +}); +export type CreateTemporaryFileOptions = S.Schema.To; + +// For now, let's say that only the content (not the MIME type or filename) can be updated +export const UpdateTemporaryFileSchema = S.struct({ + content: S.Uint8Array.pipe( + S.annotations({ + [AST.JSONSchemaAnnotationId]: { + description: "", + }, + }), + ), +}); +export type UpdateTemporaryFileOptions = S.Schema.To; + + +export async function createTemporaryFile(options: CreateTemporaryFileOptions): Promise { + const file = await TemporaryFile.create({ + ...options, + content: bufferFromUint8array(options.content), + }).catch(error => { + console.error(error); + return null; + }); + + if (file != null) { + await file.reload(); + } + + return file; +} + +export async function getTemporaryFile(id: string): Promise { + return TemporaryFile.findOne({ where: { id } }).catch(_error => null); +} + +export async function updateTemporaryFile(file: TemporaryFile, options: UpdateTemporaryFileOptions): Promise { + const now = new Date(); + const expiresAt = new Date(now); + expiresAt.setMinutes(expiresAt.getMinutes() + 30); + return file.update({ + content: bufferFromUint8array(options.content), + expires_at: expiresAt, + last_modified: now, + }, + ); +} diff --git a/src/models/index.ts b/src/models/index.ts index 38f0d630..64663f7e 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -23,6 +23,7 @@ import { Sequelize } from "sequelize/types"; import { initializeStudentOptionsModel } from "./student_options"; import { initializeAPIKeyModel } from "./api_key"; import { initializeUserExperienceRatingModel } from "./user_experience"; +import { initializeTemporaryFileModel } from "./temporary_file"; export { APIKeyRole, @@ -72,4 +73,5 @@ export function initializeModels(db: Sequelize) { initializeDashboardClassGroupModel(db); initializeStoryVisitInfoModel(db); initializeUserExperienceRatingModel(db); + initializeTemporaryFileModel(db); } diff --git a/src/models/temporary_file.ts b/src/models/temporary_file.ts new file mode 100644 index 00000000..36116b2f --- /dev/null +++ b/src/models/temporary_file.ts @@ -0,0 +1,46 @@ +import { Sequelize, DataTypes, Model, InferAttributes, InferCreationAttributes, CreationOptional } from "sequelize"; + +export class TemporaryFile extends Model, InferCreationAttributes> { + declare id: CreationOptional; + declare content: Buffer; + declare mime_type: string; + declare created_at: CreationOptional; + declare last_modified: CreationOptional; + declare expires_at: CreationOptional; +} + +export function initializeTemporaryFileModel(sequelize: Sequelize) { + TemporaryFile.init({ + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + }, + content: { + type: DataTypes.BLOB, + allowNull: false, + }, + mime_type: { + type: DataTypes.STRING, + allowNull: false, + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"), + }, + last_modified: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"), + }, + expires_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: Sequelize.literal("(NOW() + INTERVAL 30 MINUTE)"), + }, + }, { + sequelize, + }); +} diff --git a/src/server.ts b/src/server.ts index ba319fed..c9f32a06 100644 --- a/src/server.ts +++ b/src/server.ts @@ -54,6 +54,9 @@ import { patchStoryState, getUserExperienceForStory, setExperienceInfoForStory, + getTemporaryFile, + createTemporaryFile, + updateTemporaryFile, } from "./database"; import { @@ -70,6 +73,8 @@ import express, { Express, Request, Response as ExpressResponse } from "express" import { Response } from "express-serve-static-core"; import session from "express-session"; import jwt from "jsonwebtoken"; +import mime from "mime"; +import { validate as validateUUID } from "uuid"; import { isStudentOption } from "./models/student_options"; import { ExperienceRating } from "./models/user_experience"; @@ -78,7 +83,7 @@ import * as S from "@effect/schema/Schema"; import * as Either from "effect/Either"; import { JSONSchema } from "@effect/schema"; -import { setupApp } from "./app"; +import { setupApp, uploader } from "./app"; import { getAPIKey, hasPermission } from "./authorization"; import { Sequelize } from "sequelize"; import { sendEmail } from "./email"; @@ -2495,6 +2500,347 @@ export function createApp(db: Sequelize, options?: AppOptions): Express { }); }); + /** + * Temporary files + * + * Currently, all temporary files expire after 30 minutes. + * Maybe in the future we change that? + * **/ + + /** + * @openapi + * /temp: + * post: + * tags: + * - temporary + * description: Create a temporary file in the CosmicDS database + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * file: + * type: string + * format: binary + * description: The content of the temporary file + * responses: + * 201: + * description: The temporary file was successfully created + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * description: The UUID associated with the temporary file + * url: + * type: string + * description: The URL at which the temporary file can be located + * expires_at: + * type: string + * description: A datetime string string indicating when the file is no longer guaranteed to exist + * expires_in: + * type: number + * description: Gives the time, in seconds, until the file is no longer guaranteed to exist + * content_type: + * type: string + * description: The MIME type of the temporary file + * headers: + * Expires: + * description: A datetime string indicating when the file is no longer guaranteed to exist + * schema: + * type: string + * Cache-Control: + * description: max-age gives the time, in seconds, until the file is no longer guaranteed to exist + * schema: + * type: string + * Location: + * description: Gives the URL at which the temporary file can be located + * schema: + * type: string + * 400: + * description: The file content was missing + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * 500: + * description: There was an error creating the temporary file + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + */ + app.post("/temp", uploader.single("file"), async (req, res) => { + const file = req.file; + if (file == null) { + res.status(400).json({ + error: "File content is missing", + }); + return; + } + + const tempFile = await createTemporaryFile({ + mime_type: file.mimetype, + content: file.buffer, + }); + if (tempFile === null) { + res.status(500).json({ + error: "There was an issue creating the temporary file", + }); + return; + } + + const expirationTime = tempFile.get("expires_at"); + res.setHeader("Expires", expirationTime.toString()); + const timeToExpiry = Math.floor((expirationTime.getTime() - Date.now()) / 1000); + res.setHeader("Cache-Control", `max-age=${timeToExpiry}, must-revalidate`); + const location = `${req.protocol}://${req.get("host")}/temp/${tempFile.id}`; + res.setHeader("Location", location); + res.status(201).json({ + id: tempFile.id, + url: location, + expires_at: expirationTime.toString(), + expires_in: timeToExpiry, + content_type: tempFile.mime_type, + }); + }); + + /** + * @openapi + * /temp/{uuid} + * patch: + * tags: + * - temporary + * description: Update an already existing temporary file. The new content must have the same MIME type as the original + * parameters: + * - name: uuid + * in: path + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * file: + * type: string + * format: binary + * description: The updated content of the temporary file + * responses: + * 204: + * description: The temporary file was succesfully updated + * headers: + * Expires: + * description: A datetime string indicating when the file is no longer guaranteed to exist + * schema: + * type: string + * Cache-Control: + * description: max-age gives the time, in seconds, until the file is no longer guaranteed to exist + * schema: + * type: string + * Location: + * description: Gives the URL at which the temporary file can be located + * schema: + * type: string + * 400: + * description: The given UUID was invalid, the file content was missing, or the file content's MIME type did not match the existing file + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * 404: + * description: No temporary file was found with the given UUID + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * 500: + * description: There was an error updating the file content + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * + */ + app.patch("/temp/:uuid", uploader.single("file"), async (req, res) => { + const uuid = req.params.uuid; + if (!validateUUID(uuid)) { + res.status(400).json({ + error: "ID is not a valid v4 UUID", + }); + return; + } + + const tempFile = await getTemporaryFile(uuid); + if (tempFile == null) { + res.status(404).json({ + error: `No temporary file found with ID ${uuid}`, + }); + return; + } + + const file = req.file; + if (file == null) { + res.status(400).json({ + error: "File content is missing", + }); + return; + } + + if (file.mimetype != tempFile.mime_type) { + res.status(400).json({ + error: `Changing MIME type is not allowed. This temporary file has MIME type ${tempFile.mime_type}`, + }); + return; + } + + let success = true; + await updateTemporaryFile(tempFile, { content: file.buffer }) + .catch(_error => { + success = false; + }); + + if (!success) { + res.status(500).json({ + error: "There was an error updating the temporary file", + }); + return; + } + + const expirationTime = tempFile.get("expires_at"); + res.setHeader("Expires", expirationTime.toString()); + const timeToExpiry = Math.floor((expirationTime.getTime() - Date.now()) / 1000); + res.setHeader("Cache-Control", `max-age=${timeToExpiry}, must-revalidate`); + res.setHeader("Location", `${req.protocol}://${req.get("host")}/temp/${tempFile.id}`); + res.status(204).end(); + }); + + + /** + * @openapi + * /temp/{uuid}: + * get: + * tags: + * - temporary + * description: Get the content of a temporary file + * parameters: + * - name: uuid + * in: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The requested temporary file exists and its content has been returned. The MIME type will match the file's contents. + * content: + * * / *: + * schema: + * type: string + * format: binary + * 400: + * description: The given UUID was invalid + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * 404: + * description: No temporary file was found for the given UUID + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * + */ + app.get("/temp/:uuid", async (req, res) => { + const uuid = req.params.uuid; + if (!validateUUID(uuid)) { + res.status(400).json({ + error: "ID is not a valid v4 UUID", + }); + return; + } + + const tempFile = await getTemporaryFile(uuid); + if (tempFile == null) { + res.status(404).json({ + error: `No temporary file found with ID ${uuid}`, + }); + return; + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error `getExtension` exists + const extension = mime.getExtension(tempFile.mime_type); + const filename = `file.${extension}`; + + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Type", tempFile.mime_type); + const timeToExpiry = Math.floor((tempFile.expires_at.getTime() - Date.now()) / 1000); + res.setHeader("Expires", tempFile.expires_at.toString()); + res.setHeader("Cache-Control", `max-age=${timeToExpiry}, must-revalidate`); + res.setHeader("Last-Modified", tempFile.last_modified.toString()); + const age = Math.round((Date.now() - tempFile.created_at.getTime()) / 1000); + res.setHeader("Age", age); + res.send(tempFile.content); + }); + + /** + * @openapi + * /temp/{uuid}: + * delete: + * tags: + * - temporary + * description: Manually delete a temporary file + * parameters: + * - name: uuid + * in: path + * required: true + * schema: + * type: string + * responses: + * 204: + * description: The temporary file was successfully deleted + * 400: + * description: The given UUID was invalid + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * 404: + * description: No temporary file was found for the given UUID + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + */ + app.delete("/temp/:uuid", async (req, res) => { + const uuid = req.params.uuid; + if (!validateUUID(uuid)) { + res.status(400).json({ + error: "ID is not a valid UUID", + }); + return; + } + + const tempFile = await getTemporaryFile(uuid); + if (tempFile == null) { + res.status(404).json({ + error: `No temporary file found with ID ${uuid}`, + }); + return; + } + + await tempFile.destroy(); + res.status(204).send(); + }); + /** Testing Endpoints * * These endpoints are intended for internal use only diff --git a/src/sql/create_temporary_files_table.sql b/src/sql/create_temporary_files_table.sql new file mode 100644 index 00000000..e030b6f9 --- /dev/null +++ b/src/sql/create_temporary_files_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE TemporaryFiles ( + id CHAR(36) NOT NULL DEFAULT (UUID()), + content BLOB NOT NULL, + mime_type VARCHAR(255) NOT NULL, + expires_at DATETIME NOT NULL DEFAULT (NOW() + INTERVAL 30 MINUTE), + + PRIMARY KEY(id) +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci PACK_KEYS=0; diff --git a/src/sql/delete_expired_temp_files.sql b/src/sql/delete_expired_temp_files.sql new file mode 100644 index 00000000..182accbf --- /dev/null +++ b/src/sql/delete_expired_temp_files.sql @@ -0,0 +1,21 @@ +SET GLOBAL event_scheduler = ON; + +USE cosmicds_db; + +-- We need this so that the ";" in the procedure definition isn't read as the statement delimiter +DELIMITER $$ + +CREATE PROCEDURE DeleteExpiredTemporaryFiles() +BEGIN + DELETE FROM TemporaryFiles + WHERE expires_at < NOW(); +END + +DELIMITER ; + + +CREATE EVENT run_delete_expired_temporary_files +ON SCHEDULE EVERY 15 MINUTE +STARTS CURRENT_TIMESTAMP +DO + CALL DeleteExpiredTemporaryFiles(); diff --git a/src/stories/seasons/database.ts b/src/stories/seasons/database.ts index d98076aa..790047e2 100644 --- a/src/stories/seasons/database.ts +++ b/src/stories/seasons/database.ts @@ -2,7 +2,7 @@ import * as S from "@effect/schema/Schema"; import { SeasonsData } from "./models"; import { logger } from "../../logger"; -import { OptionalInt, OptionalNumberArray, OptionalNumberPair, OptionalNumberPairArray, OptionalStringArray, StringArray, UpdateAttributes, arrayType } from "../../utils"; +import { OptionalInt, OptionalNumberArray, OptionalNumberPair, OptionalNumberPairArray, OptionalStringArray, StringArray, UpdateAttributes } from "../../utils"; import { CreationAttributes } from "sequelize"; type SeasonsDataUpdateAttributes = UpdateAttributes; diff --git a/src/utils.ts b/src/utils.ts index 93dfc9ea..a521f95e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -120,6 +120,13 @@ export async function createClassCode(length: number = 6): Promise { return code; } +export function bufferFromUint8array(array: Uint8Array): Buffer { + return Buffer.from( + array.buffer, + array.byteOffset, + array.byteLength, + ); +} // eslint-disable-next-line @typescript-eslint/no-explicit-any export function isArrayThatSatisfies>(array: any, condition: (t: Array) => boolean): array is T {