diff --git a/.changeset/good-books-double.md b/.changeset/good-books-double.md new file mode 100644 index 00000000..f31e2a30 --- /dev/null +++ b/.changeset/good-books-double.md @@ -0,0 +1,5 @@ +--- +"medialit": minor +--- + +API key and signature are passed via header instead of request body diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..9b9cb65b --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: "51 18 * * 2" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ javascript ] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{ matrix.language }}" diff --git a/apps/api/__tests__/media/handlers.test.ts b/apps/api/__tests__/media/handlers.test.ts index a0c1e9d1..3076d217 100644 --- a/apps/api/__tests__/media/handlers.test.ts +++ b/apps/api/__tests__/media/handlers.test.ts @@ -80,6 +80,7 @@ describe("Media handlers", () => { }, body: {}, query: {}, + headers: {}, }; const res = { @@ -97,6 +98,7 @@ describe("Media handlers", () => { ); const response = await uploadMedia(req, res, () => {}); + console.log("Response", response); assert.strictEqual(response.code, 200); }); }); diff --git a/apps/api/__tests__/media/storage-middleware.test.ts b/apps/api/__tests__/media/storage-middleware.test.ts index fb87306f..27277eb1 100644 --- a/apps/api/__tests__/media/storage-middleware.test.ts +++ b/apps/api/__tests__/media/storage-middleware.test.ts @@ -7,6 +7,7 @@ import { maxStorageAllowedNotSubscribed, maxStorageAllowedSubscribed, } from "../../src/config/constants"; +import { NOT_ENOUGH_STORAGE } from "../../src/config/strings"; describe("storageValidation middleware", () => { afterEach(() => { @@ -108,11 +109,8 @@ describe("storageValidation middleware", () => { }; const response = await storageValidation(req, res, next); - assert.strictEqual(response.code, 400); - assert.strictEqual( - response.data.error, - "You do not have enough storage space in your account to upload this file", - ); + assert.strictEqual(response.code, 403); + assert.strictEqual(response.data.error, NOT_ENOUGH_STORAGE); assert.strictEqual(nextCalled, false); }); @@ -145,11 +143,8 @@ describe("storageValidation middleware", () => { }; const response = await storageValidation(req, res, next); - assert.strictEqual(response.code, 400); - assert.strictEqual( - response.data.error, - "You do not have enough storage space in your account to upload this file", - ); + assert.strictEqual(response.code, 403); + assert.strictEqual(response.data.error, NOT_ENOUGH_STORAGE); assert.strictEqual(nextCalled, false); }); diff --git a/apps/api/package.json b/apps/api/package.json index cbc2e8ad..71725386 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -38,10 +38,12 @@ "@medialit/models": "workspace:*", "@medialit/thumbnail": "workspace:*", "@medialit/utils": "workspace:^0.1.0", + "@tus/file-store": "^2.0.0", + "@tus/server": "^2.3.0", "aws-sdk": "^2.1692.0", "cors": "^2.8.5", "dotenv": "^16.4.7", - "express": "^4.18.2", + "express": "^4.2.0", "express-fileupload": "^1.3.1", "joi": "^17.6.0", "mongoose": "^8.0.1", diff --git a/apps/api/src/apikey/middleware.ts b/apps/api/src/apikey/middleware.ts index 98b58dec..278f8dd3 100644 --- a/apps/api/src/apikey/middleware.ts +++ b/apps/api/src/apikey/middleware.ts @@ -1,20 +1,18 @@ -import { - BAD_REQUEST, - SUBSCRIPTION_NOT_VALID, - UNAUTHORISED, -} from "../config/strings"; +import { BAD_REQUEST, UNAUTHORISED } from "../config/strings"; import { getApiKeyUsingKeyId } from "./queries"; import { getUser } from "../user/queries"; import { Apikey } from "@medialit/models"; +import logger from "../services/log"; export default async function apikey( req: any, res: any, next: (...args: any[]) => void, ) { - const reqKey = req.body.apikey; + const reqKey = req.body?.apikey || req.headers["x-medialit-apikey"]; if (!reqKey) { + logger.error({}, "API key is missing"); return res.status(400).json({ error: BAD_REQUEST }); } @@ -23,13 +21,6 @@ export default async function apikey( return res.status(401).json({ error: UNAUTHORISED }); } - // const isSubscriptionValid = await validateSubscription( - // apiKey!.userId.toString(), - // ); - // if (!isSubscriptionValid) { - // return res.status(403).json({ error: SUBSCRIPTION_NOT_VALID }); - // } - req.user = await getUser(apiKey!.userId.toString()); req.apikey = apiKey.key; diff --git a/apps/api/src/config/constants.ts b/apps/api/src/config/constants.ts index fc2a6919..0ba5e7cd 100644 --- a/apps/api/src/config/constants.ts +++ b/apps/api/src/config/constants.ts @@ -6,19 +6,19 @@ export const tempFileDirForUploads = process.env.TEMP_FILE_DIR_FOR_UPLOADS; export const maxFileUploadSizeSubscribed = process.env .MAX_UPLOAD_SIZE_SUBSCRIBED ? +process.env.MAX_UPLOAD_SIZE_SUBSCRIBED - : 2147483648; + : 2147483648; // 2GB export const maxFileUploadSizeNotSubscribed = process.env .MAX_UPLOAD_SIZE_NOT_SUBSCRIBED ? +process.env.MAX_UPLOAD_SIZE_NOT_SUBSCRIBED - : 52428800; + : 52428800; // 50MB export const maxStorageAllowedSubscribed = process.env .MAX_STORAGE_ALLOWED_SUBSCRIBED ? +process.env.MAX_STORAGE_ALLOWED_SUBSCRIBED - : 107374182400; + : 107374182400; // 100GB export const maxStorageAllowedNotSubscribed = process.env .MAX_STORAGE_ALLOWED_NOT_SUBSCRIBED ? +process.env.MAX_STORAGE_ALLOWED_NOT_SUBSCRIBED - : 1073741824; + : 1073741824; // 1GB export const PRESIGNED_URL_VALIDITY_MINUTES = 5; export const PRESIGNED_URL_LENGTH = 100; export const MEDIA_ID_LENGTH = 40; @@ -62,3 +62,8 @@ export const CDN_MAX_AGE = process.env.CDN_MAX_AGE export const ENDPOINT = USE_CLOUDFRONT ? CLOUDFRONT_ENDPOINT : S3_ENDPOINT; export const HOSTNAME_OVERRIDE = process.env.HOSTNAME_OVERRIDE || ""; // Useful for hosting via Docker + +// Tus upload config +export const TUS_UPLOAD_EXPIRATION_HOURS = parseInt( + process.env.TUS_UPLOAD_EXPIRATION_HOURS || "48", +); diff --git a/apps/api/src/config/strings.ts b/apps/api/src/config/strings.ts index cecdf848..e0b4372f 100644 --- a/apps/api/src/config/strings.ts +++ b/apps/api/src/config/strings.ts @@ -6,3 +6,5 @@ export const FILE_IS_REQUIRED = "File is required"; export const FILE_SIZE_EXCEEDED = "File size exceeded"; export const NOT_FOUND = "Not found"; export const PRESIGNED_URL_INVALID = "The link is invalid"; +export const NOT_ENOUGH_STORAGE = + "Not enough storage space in your account to upload this file"; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7fe885ab..8a5a8ab5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -5,13 +5,15 @@ import express from "express"; import connectToDatabase from "./config/db"; import passport from "passport"; import mediaRoutes from "./media/routes"; -import presignedUrlRoutes from "./presigning/routes"; +import signatureRoutes from "./signature/routes"; import mediaSettingsRoutes from "./media-settings/routes"; +import tusRoutes from "./tus/routes"; import logger from "./services/log"; import { createUser, findByEmail } from "./user/queries"; -import { Apikey, Constants, User } from "@medialit/models"; +import { Apikey, User } from "@medialit/models"; import { createApiKey } from "./apikey/queries"; import { spawn } from "child_process"; +import { Cleanup } from "./tus/cleanup"; connectToDatabase(); const app = express(); @@ -28,7 +30,8 @@ app.get("/health", (req, res) => { }); app.use("/settings/media", mediaSettingsRoutes(passport)); -app.use("/media/presigned", presignedUrlRoutes); +app.use("/media/signature", signatureRoutes); +app.use("/media", tusRoutes); app.use("/media", mediaRoutes); const port = process.env.PORT || 80; @@ -41,6 +44,14 @@ checkDependencies().then(() => { app.listen(port, () => { logger.info(`Medialit server running at ${port}`); }); + + // Setup background cleanup job for expired tus uploads + setInterval( + async () => { + await Cleanup(); + }, + 1000 * 60 * 60, // 1 hours + ); }); async function checkDependencies() { diff --git a/apps/api/src/media/handlers.ts b/apps/api/src/media/handlers.ts index a08c1b2e..b76421bb 100644 --- a/apps/api/src/media/handlers.ts +++ b/apps/api/src/media/handlers.ts @@ -15,7 +15,8 @@ import logger from "../services/log"; import { Request } from "express"; import mediaService from "./service"; import { getMediaCount as getCount, getTotalSpace } from "./queries"; -import { Constants, getSubscriptionStatus } from "@medialit/models"; +import { getSubscriptionStatus } from "@medialit/models"; +import { getSignatureFromReq } from "../signature/utils"; function validateUploadOptions(req: Request): Joi.ValidationResult { const uploadSchema = Joi.object({ @@ -69,7 +70,7 @@ export async function uploadMedia( access, caption, group, - signature: req.query.signature, + signature: getSignatureFromReq(req), }); const media = await mediaService.getMediaDetails({ diff --git a/apps/api/src/media/routes.ts b/apps/api/src/media/routes.ts index 32ac684d..f26f957c 100644 --- a/apps/api/src/media/routes.ts +++ b/apps/api/src/media/routes.ts @@ -14,11 +14,13 @@ import { getMediaCount, getTotalSpaceOccupied, } from "./handlers"; -import presigned from "../presigning/middleware"; +import signatureMiddleware from "../signature/middleware"; import storage from "./storage-middleware"; +import { getSignatureFromReq } from "../signature/utils"; const router = express.Router(); +router.options("/create", cors()); router.post( "/create", cors(), @@ -31,9 +33,9 @@ router.post( }, }), (req: Request, res: Response, next: (...args: any[]) => void) => { - const { signature } = req.query; + const signature = getSignatureFromReq(req); if (signature) { - presigned( + signatureMiddleware( req as Request & { user: any; apikey: string }, res, next, diff --git a/apps/api/src/media/service.ts b/apps/api/src/media/service.ts index df379dd1..627b54d9 100644 --- a/apps/api/src/media/service.ts +++ b/apps/api/src/media/service.ts @@ -35,7 +35,7 @@ import { getPaginatedMedia, createMedia, } from "./queries"; -import * as presignedUrlService from "../presigning/service"; +import * as presignedUrlService from "../signature/service"; import getTags from "./utils/get-tags"; import { getMainFileUrl, getThumbnailUrl } from "./utils/get-public-urls"; diff --git a/apps/api/src/media/storage-middleware.ts b/apps/api/src/media/storage-middleware.ts index 09de214a..6a918474 100644 --- a/apps/api/src/media/storage-middleware.ts +++ b/apps/api/src/media/storage-middleware.ts @@ -1,7 +1,8 @@ import { maxStorageAllowedNotSubscribed } from "../config/constants"; import { maxStorageAllowedSubscribed } from "../config/constants"; -import { Constants, getSubscriptionStatus } from "@medialit/models"; +import { getSubscriptionStatus, User } from "@medialit/models"; import mediaQueries from "./queries"; +import { NOT_ENOUGH_STORAGE } from "../config/strings"; export default async function storageValidation( req: any, @@ -14,21 +15,25 @@ export default async function storageValidation( }); } + if (!(await hasEnoughStorage((req.files.file as any).size, req.user))) { + return res.status(403).json({ + error: NOT_ENOUGH_STORAGE, + }); + } + + next(); +} + +export async function hasEnoughStorage( + size: number, + user: User, +): Promise { const totalSpaceOccupied = await mediaQueries.getTotalSpace({ - userId: (req as any).user.id, + userId: user.id, }); - const maxStorageAllowed = getSubscriptionStatus(req.user) + const maxStorageAllowed = getSubscriptionStatus(user) ? maxStorageAllowedSubscribed : maxStorageAllowedNotSubscribed; - if ( - totalSpaceOccupied + (req.files?.file as any).size > - maxStorageAllowed - ) { - return res.status(400).json({ - error: "You do not have enough storage space in your account to upload this file", - }); - } - - next(); + return totalSpaceOccupied + size <= maxStorageAllowed; } diff --git a/apps/api/src/presigning/handlers.ts b/apps/api/src/signature/handlers.ts similarity index 84% rename from apps/api/src/presigning/handlers.ts rename to apps/api/src/signature/handlers.ts index 6d3084a3..a71a0b01 100644 --- a/apps/api/src/presigning/handlers.ts +++ b/apps/api/src/signature/handlers.ts @@ -12,7 +12,7 @@ function validatePresigningOptions(req: Request): Joi.ValidationResult { return uploadSchema.validate({ group }); } -export async function getPresignedUrl( +export async function getSignature( req: any, res: any, next: (...args: any[]) => void, @@ -23,14 +23,14 @@ export async function getPresignedUrl( } try { - const presignedUrl = await preSignedUrlService.generateSignedUrl({ + const signature = await preSignedUrlService.generateSignature({ userId: req.user.id, apikey: req.apikey, protocol: req.protocol, host: HOSTNAME_OVERRIDE || req.get("Host"), group: req.body.group, }); - return res.status(200).json({ message: presignedUrl }); + return res.status(200).json({ signature }); } catch (err: any) { logger.error({ err }, err.message); return res.status(500).json(err.message); diff --git a/apps/api/src/presigning/middleware.ts b/apps/api/src/signature/middleware.ts similarity index 75% rename from apps/api/src/presigning/middleware.ts rename to apps/api/src/signature/middleware.ts index 0490fe79..2cd1550e 100644 --- a/apps/api/src/presigning/middleware.ts +++ b/apps/api/src/signature/middleware.ts @@ -1,13 +1,14 @@ import { Request, Response } from "express"; import { PRESIGNED_URL_INVALID } from "../config/strings"; import * as preSignedUrlService from "./service"; +import { getSignatureFromReq } from "./utils"; -export default async function presigned( - req: Request & { user: any; apikey: string }, +export default async function signature( + req: Request & { user?: any; apikey?: string }, res: Response, next: (...args: any[]) => void, ) { - const { signature } = req.query; + const signature = getSignatureFromReq(req); const response = await preSignedUrlService.getUserAndGroupFromPresignedUrl( signature as string, diff --git a/apps/api/src/presigning/model.ts b/apps/api/src/signature/model.ts similarity index 100% rename from apps/api/src/presigning/model.ts rename to apps/api/src/signature/model.ts diff --git a/apps/api/src/presigning/queries.ts b/apps/api/src/signature/queries.ts similarity index 86% rename from apps/api/src/presigning/queries.ts rename to apps/api/src/signature/queries.ts index 13094751..8fb18a26 100644 --- a/apps/api/src/presigning/queries.ts +++ b/apps/api/src/signature/queries.ts @@ -1,7 +1,5 @@ import mongoose from "mongoose"; import PreSignedUrlModel, { PreSignedUrl } from "./model"; -import { getUniqueId } from "@medialit/utils"; -import { PRESIGNED_URL_LENGTH } from "../config/constants"; export async function getPresignedUrl( signature: string, @@ -19,7 +17,7 @@ export async function createPresignedUrl( userId: string, apikey: string, group?: string, -): Promise { +): Promise { const presignedUrl = await PreSignedUrlModel.create({ userId, apikey, diff --git a/apps/api/src/presigning/routes.ts b/apps/api/src/signature/routes.ts similarity index 58% rename from apps/api/src/presigning/routes.ts rename to apps/api/src/signature/routes.ts index d1ff698b..018c8115 100644 --- a/apps/api/src/presigning/routes.ts +++ b/apps/api/src/signature/routes.ts @@ -1,8 +1,8 @@ import express from "express"; import apikey from "../apikey/middleware"; -import { getPresignedUrl } from "./handlers"; +import { getSignature } from "./handlers"; const router = express.Router(); -router.post("/create", apikey, getPresignedUrl); +router.post("/create", apikey, getSignature); export default router; diff --git a/apps/api/src/presigning/service.ts b/apps/api/src/signature/service.ts similarity index 92% rename from apps/api/src/presigning/service.ts rename to apps/api/src/signature/service.ts index 781ac2f7..b282716d 100644 --- a/apps/api/src/presigning/service.ts +++ b/apps/api/src/signature/service.ts @@ -42,7 +42,7 @@ interface GenerateSignedUrlProps { group?: string; } -export async function generateSignedUrl({ +export async function generateSignature({ userId, apikey, protocol, @@ -62,7 +62,7 @@ export async function generateSignedUrl({ ); }); - return `${protocol}://${host}/media/create?signature=${presignedUrl?.signature}`; + return presignedUrl.signature; } export async function cleanup(userId: string, signature: string) { diff --git a/apps/api/src/signature/utils.ts b/apps/api/src/signature/utils.ts new file mode 100644 index 00000000..abaf2ed7 --- /dev/null +++ b/apps/api/src/signature/utils.ts @@ -0,0 +1,7 @@ +export function getSignatureFromReq(req: any) { + return ( + req.query.signature || + req.headers["x-medialit-signature"] || + req.headers["X-Medialit-Signature"] + ); +} diff --git a/apps/api/src/tus/cleanup.ts b/apps/api/src/tus/cleanup.ts new file mode 100644 index 00000000..0df5f8c6 --- /dev/null +++ b/apps/api/src/tus/cleanup.ts @@ -0,0 +1,19 @@ +import logger from "../services/log"; +import TusUploadModel, { TusUpload } from "./model"; +import { removeTusFiles } from "./utils"; + +export async function Cleanup() { + logger.info({}, "Starting the tus uploads cleanup job"); + + const now = new Date(); + const expiredUploads = (await TusUploadModel.find({ + expiresAt: { $lt: now }, + }).lean()) as unknown as TusUpload[]; + + for (const expiredUpload of expiredUploads) { + removeTusFiles(expiredUpload.tempFilePath); + await TusUploadModel.deleteOne({ _id: (expiredUpload as any)._id }); + } + + logger.info({}, "Ending the tus uploads cleanup job"); +} diff --git a/apps/api/src/tus/finalize.ts b/apps/api/src/tus/finalize.ts new file mode 100644 index 00000000..8c5d1932 --- /dev/null +++ b/apps/api/src/tus/finalize.ts @@ -0,0 +1,206 @@ +import { readFileSync, createReadStream, rmdirSync, existsSync } from "fs"; +import path from "path"; +import thumbnail from "@medialit/thumbnail"; +import mongoose from "mongoose"; +import { + tempFileDirForUploads, + imagePattern, + imagePatternForThumbnailGeneration, + videoPattern, + USE_CLOUDFRONT, +} from "../config/constants"; +import imageUtils from "@medialit/images"; +import { + foldersExist, + createFolders, +} from "../media/utils/manage-files-on-disk"; +import type { MediaWithUserId } from "../media/model"; +import { putObject, UploadParams } from "../services/s3"; +import logger from "../services/log"; +import generateKey from "../media/utils/generate-key"; +import { getMediaSettings } from "../media-settings/queries"; +import generateFileName from "../media/utils/generate-file-name"; +import { createMedia } from "../media/queries"; +import getTags from "../media/utils/get-tags"; +import { getTusUpload, markTusUploadComplete } from "./queries"; +import * as presignedUrlService from "../signature/service"; +import { getUser } from "../user/queries"; +import { hasEnoughStorage } from "../media/storage-middleware"; +import { NOT_ENOUGH_STORAGE } from "../config/strings"; +import { removeTusFiles } from "./utils"; + +export default async function finalizeUpload(uploadId: string) { + const tusUpload = await getTusUpload(uploadId); + if (!tusUpload) { + throw new Error(`Tus upload not found: ${uploadId}`); + } + + if (tusUpload.isComplete) { + logger.info({ uploadId }, "Upload already finalized"); + return; + } + + const { userId, apikey, metadata, uploadLength, tempFilePath, signature } = + tusUpload; + + const user = await getUser(userId); + if (!(await hasEnoughStorage(uploadLength, user!))) { + throw new Error(NOT_ENOUGH_STORAGE); + } + + // Read the completed file from tus data store + const tusFilePath = path.join( + `${tempFileDirForUploads}/tus-uploads`, + tempFilePath, + ); + + if (!existsSync(tusFilePath)) { + logger.error({ uploadId, tusFilePath }, "Tus file not found"); + throw new Error(`Tus file not found: ${tusFilePath}`); + } + + const mediaSettings = await getMediaSettings(userId, apikey); + const useWebP = mediaSettings?.useWebP || false; + const webpOutputQuality = mediaSettings?.webpOutputQuality || 0; + + // Generate unique media ID + const fileName = generateFileName(metadata.fileName); + const temporaryFolderForWork = `${tempFileDirForUploads}/${fileName.name}`; + if (!foldersExist([temporaryFolderForWork])) { + createFolders([temporaryFolderForWork]); + } + + let fileExtension = path.extname(metadata.fileName).replace(".", ""); + let mimeType = metadata.mimeType; + if (useWebP && imagePattern.test(mimeType)) { + fileExtension = "webp"; + mimeType = "image/webp"; + } + + const mainFilePath = `${temporaryFolderForWork}/main.${fileExtension}`; + + //Copy file from tus store to working directory + const tusFileContent = readFileSync(tusFilePath); + require("fs").writeFileSync(mainFilePath, tusFileContent); + + // Apply WebP conversion if needed + if (useWebP && imagePattern.test(metadata.mimeType)) { + await imageUtils.convertToWebp(mainFilePath, webpOutputQuality); + } + + const uploadParams: UploadParams = { + Key: generateKey({ + mediaId: fileName.name, + access: metadata.accessControl === "public" ? "public" : "private", + filename: `main.${fileExtension}`, + }), + Body: createReadStream(mainFilePath), + ContentType: mimeType, + ACL: USE_CLOUDFRONT + ? "private" + : metadata.accessControl === "public" + ? "public-read" + : "private", + }; + const tags = getTags(userId, metadata.group); + uploadParams.Tagging = tags; + + await putObject(uploadParams); + + let isThumbGenerated = false; + try { + isThumbGenerated = await generateAndUploadThumbnail({ + workingDirectory: temporaryFolderForWork, + mimetype: metadata.mimeType, + originalFilePath: mainFilePath, + key: generateKey({ + mediaId: fileName.name, + access: "public", + filename: "thumb.webp", + }), + tags, + }); + } catch (err: any) { + logger.error({ err }, err.message); + } + + rmdirSync(temporaryFolderForWork, { recursive: true }); + + const mediaObject: MediaWithUserId = { + fileName: `main.${fileExtension}`, + mediaId: fileName.name, + userId: new mongoose.Types.ObjectId(userId), + apikey, + originalFileName: metadata.fileName, + mimeType, + size: uploadLength, + thumbnailGenerated: isThumbGenerated, + caption: metadata.caption, + accessControl: + metadata.accessControl === "public" ? "public-read" : "private", + group: metadata.group, + }; + const media = await createMedia(mediaObject); + + // Mark upload as complete + await markTusUploadComplete(uploadId); + + // Cleanup presigned URL if used + if (signature) { + presignedUrlService.cleanup(userId, signature).catch((err: any) => { + logger.error( + { err }, + `Error in cleaning up expired links for ${userId}`, + ); + }); + } + + // Cleanup tus file + try { + if (existsSync(tusFilePath)) { + removeTusFiles(tempFilePath); + } + } catch (err) { + logger.error({ err }, "Error cleaning up tus file"); + } + + return media.mediaId; +} + +const generateAndUploadThumbnail = async ({ + workingDirectory, + key, + mimetype, + originalFilePath, + tags, +}: { + workingDirectory: string; + key: string; + mimetype: string; + originalFilePath: string; + tags: string; +}): Promise => { + const thumbPath = `${workingDirectory}/thumb.webp`; + + let isThumbGenerated = false; + if (imagePatternForThumbnailGeneration.test(mimetype)) { + await thumbnail.forImage(originalFilePath, thumbPath); + isThumbGenerated = true; + } + if (videoPattern.test(mimetype)) { + await thumbnail.forVideo(originalFilePath, thumbPath); + isThumbGenerated = true; + } + + if (isThumbGenerated) { + await putObject({ + Key: key, + Body: createReadStream(thumbPath), + ContentType: "image/webp", + ACL: USE_CLOUDFRONT ? "private" : "public-read", + Tagging: tags, + }); + } + + return isThumbGenerated; +}; diff --git a/apps/api/src/tus/model.ts b/apps/api/src/tus/model.ts new file mode 100644 index 00000000..44a1d1ef --- /dev/null +++ b/apps/api/src/tus/model.ts @@ -0,0 +1,43 @@ +import type { Media } from "@medialit/models"; +import mongoose from "mongoose"; + +export interface TusUpload { + uploadId: string; + userId: string; + apikey: string; + uploadLength: number; + uploadOffset: number; + metadata: Pick< + Media, + "fileName" | "mimeType" | "accessControl" | "caption" | "group" + >; + tempFilePath: string; + isComplete: boolean; + expiresAt?: Date; + signature?: string; +} + +const TusUploadSchema = new mongoose.Schema( + { + uploadId: { type: String, required: true, unique: true }, + userId: { type: String, required: true }, + apikey: { type: String, required: true }, + uploadLength: { type: Number, required: true }, + uploadOffset: { type: Number, required: true, default: 0 }, + metadata: { + fileName: { type: String, required: true }, + mimeType: { type: String, required: true }, + accessControl: { type: String, required: true, default: "private" }, + caption: String, + group: String, + }, + signature: String, + tempFilePath: String, + isComplete: Boolean, + expiresAt: Date, + }, + { timestamps: true }, +); + +export default mongoose.models.TusUpload || + mongoose.model("TusUpload", TusUploadSchema); diff --git a/apps/api/src/tus/queries.ts b/apps/api/src/tus/queries.ts new file mode 100644 index 00000000..8ea5ce8f --- /dev/null +++ b/apps/api/src/tus/queries.ts @@ -0,0 +1,55 @@ +import { TUS_UPLOAD_EXPIRATION_HOURS } from "../config/constants"; +import TusUploadModel, { TusUpload } from "./model"; + +type TusUploadDocument = any; + +export async function createTusUpload( + data: Omit, +): Promise { + // const uploadId = new mongoose.Types.ObjectId().toString(); + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + TUS_UPLOAD_EXPIRATION_HOURS); + + const tusUploadData: TusUpload = { + uploadId: data.uploadId, + userId: data.userId, + apikey: data.apikey, + uploadLength: data.uploadLength, + metadata: data.metadata, + tempFilePath: data.tempFilePath, + signature: data.signature, + uploadOffset: 0, + isComplete: false, + expiresAt, + }; + const tusUpload = await TusUploadModel.create(tusUploadData); + + return tusUpload; +} + +export async function getTusUpload( + uploadId: string, +): Promise { + return TusUploadModel.findOne({ uploadId }); +} + +export async function updateTusUploadOffset( + uploadId: string, + uploadOffset: number, +): Promise { + await TusUploadModel.updateOne({ uploadId }, { uploadOffset }); +} + +export async function markTusUploadComplete(uploadId: string): Promise { + await TusUploadModel.updateOne({ uploadId }, { isComplete: true }); +} + +export async function deleteTusUpload(uploadId: string): Promise { + await TusUploadModel.deleteOne({ uploadId }); +} + +export async function getTusUploadsByUserId( + userId: string, +): Promise { + return TusUploadModel.find({ userId }).sort({ createdAt: -1 }); +} diff --git a/apps/api/src/tus/routes.ts b/apps/api/src/tus/routes.ts new file mode 100644 index 00000000..59d3d632 --- /dev/null +++ b/apps/api/src/tus/routes.ts @@ -0,0 +1,9 @@ +import express from "express"; +import cors from "cors"; +import { server } from "./tus-server"; + +const router = express.Router(); + +router.all("/create/resumable*", cors(), server.handle.bind(server)); + +export default router; diff --git a/apps/api/src/tus/tus-server.ts b/apps/api/src/tus/tus-server.ts new file mode 100644 index 00000000..ba022bbe --- /dev/null +++ b/apps/api/src/tus/tus-server.ts @@ -0,0 +1,152 @@ +import { EVENTS, Server } from "@tus/server"; +import { FileStore } from "@tus/file-store"; +import { tempFileDirForUploads } from "../config/constants"; +import logger from "../services/log"; +import finalizeUpload from "./finalize"; +import * as preSignedUrlService from "../signature/service"; +import { + NOT_ENOUGH_STORAGE, + PRESIGNED_URL_INVALID, + UNAUTHORISED, +} from "../config/strings"; +import { Apikey, User } from "@medialit/models"; +import { getApiKeyUsingKeyId } from "../apikey/queries"; +import { getUser } from "../user/queries"; +import { hasEnoughStorage } from "../media/storage-middleware"; +import { createTusUpload, updateTusUploadOffset } from "./queries"; + +const store = new FileStore({ + directory: `${tempFileDirForUploads}/tus-uploads`, +}); + +export const server = new Server({ + path: "/media/create/resumable", + datastore: store, + respectForwardedHeaders: true, + onIncomingRequest: async (req: any) => { + try { + const response = await getUserAndAPIKey(req); + if (!isUser(response)) { + throw response; + } + req.user = response.user; + req.apikey = response.apikey; + } catch (err) { + logger.error({ err }, "Error validating user creds"); + throw err; + } + }, + onUploadCreate: async (req: any, upload: any) => { + const metadata = upload.metadata; + const { user, apikey } = req; + + try { + if (!(await hasEnoughStorage(upload.size, user))) { + throw { + status_code: 403, + body: NOT_ENOUGH_STORAGE, + }; + } + + await createTusUpload({ + uploadId: upload.id, + userId: user.id, + apikey: apikey!, + uploadLength: upload.size, + metadata: { + fileName: metadata.fileName || "unknown", + mimeType: metadata.mimeType || "application/octet-stream", + accessControl: metadata.access, + caption: metadata.caption, + group: metadata.group || (req.body?.group as string), + }, + tempFilePath: upload.id, + }); + } catch (err: any) { + logger.error({ err }, "Error creating tus upload record"); + throw err; + } + return metadata; + }, + onUploadFinish: async (req: any, upload: any) => { + try { + console.time("finalize"); + await finalizeUpload(upload.id); + console.timeEnd("finalize"); + return {}; + } catch (err: any) { + logger.error( + { err, uploadId: upload.id }, + "Error finalizing tus upload", + ); + return { + status_code: 403, + body: err.message, + }; + } + }, +}); + +server.on(EVENTS.POST_RECEIVE, async (req: any, upload: any) => { + try { + await updateTusUploadOffset(upload.id, upload.offset); + } catch (err) { + logger.error({ err }, "Failed to update tus upload offset"); + } +}); + +async function getUserAndAPIKey(req: any): Promise { + const signature = req.headers.get("x-medialit-signature"); + let user, apikey; + if (signature) { + const response = + await preSignedUrlService.getUserAndGroupFromPresignedUrl( + signature as string, + ); + if (!response) { + return { + status_code: 401, + body: PRESIGNED_URL_INVALID, + }; + } + + user = response.user; + apikey = response.apikey; + } else { + const apikeyFromHeader = req.headers.get("x-medialit-apikey"); + const apikeyFromDB: Apikey | null = + await getApiKeyUsingKeyId(apikeyFromHeader); + if (!apikeyFromDB) { + return { + status_code: 401, + body: UNAUTHORISED, + }; + } + user = await getUser(apikeyFromDB.userId.toString()); + if (!user) { + return { + status_code: 401, + body: UNAUTHORISED, + }; + } + apikey = apikeyFromHeader; + } + + return { user, apikey }; +} + +interface UserWithAPIKey { + user: User; + apikey: string; +} + +interface TusError { + status_code: number; + body: string; +} + +function isUser( + response: UserWithAPIKey | TusError, +): response is UserWithAPIKey { + return (response as UserWithAPIKey).user?.userId !== undefined; +} diff --git a/apps/api/src/tus/utils.ts b/apps/api/src/tus/utils.ts new file mode 100644 index 00000000..9678d6e6 --- /dev/null +++ b/apps/api/src/tus/utils.ts @@ -0,0 +1,15 @@ +import path from "path"; +import { tempFileDirForUploads } from "../config/constants"; + +export function removeTusFiles(uploadId: string) { + const tusFilePath = path.join( + `${tempFileDirForUploads}/tus-uploads`, + uploadId, + ); + require("fs").unlinkSync(tusFilePath); + const tusJSONFilePath = path.join( + `${tempFileDirForUploads}/tus-uploads`, + `${uploadId}.json`, + ); + require("fs").unlinkSync(tusJSONFilePath); +} diff --git a/examples/next-app-router/README.md b/examples/next-app-router/README.md index f61f3d36..b8c48fc4 100644 --- a/examples/next-app-router/README.md +++ b/examples/next-app-router/README.md @@ -1,5 +1,23 @@ This is a [Next.js](https://nextjs.org) project which demonstrates the usage of `medialit` package. +## Features + +This example app demonstrates two upload methods: + +1. **Standard Upload** - Traditional single-request upload, best for smaller files +2. **TUS Resumable Upload** - Multipart resumable uploads using the TUS protocol with: + - Real-time upload progress tracking + - Automatic retry on failure + - Cancel upload capability + - Ideal for larger files and unreliable connections + +Additional features: + +- Automatic thumbnail generation +- Media information retrieval +- File deletion +- Media listing with pagination + ## Getting Started First, add the MediaLit API key to `.env` file in the root directory: @@ -8,6 +26,12 @@ First, add the MediaLit API key to `.env` file in the root directory: MEDIALIT_API_KEY=your_api_key_here ``` +Optionally, if you're running a self-hosted instance, set the endpoint: + +```env +MEDIALIT_ENDPOINT=http://localhost:3001 +``` + Then, run the development server: ```bash diff --git a/examples/next-app-router/app/api/medialit/route.ts b/examples/next-app-router/app/api/medialit/route.ts index 6f9456ef..125cf3cd 100644 --- a/examples/next-app-router/app/api/medialit/route.ts +++ b/examples/next-app-router/app/api/medialit/route.ts @@ -37,8 +37,13 @@ export async function GET(request: NextRequest) { export async function POST() { try { - const presignedUrl = await client.getPresignedUploadUrl(); - return Response.json({ presignedUrl }); + const signature = await client.getSignature(); + const sp = new URLSearchParams(); + sp.append("signature", signature); + return Response.json({ + endpoint: client.endpoint, + signature, + }); } catch (error) { if (error instanceof Error) { console.log("Error getting presigned URL:", error); diff --git a/examples/next-app-router/app/page.tsx b/examples/next-app-router/app/page.tsx index a2c7df79..ad1a7e32 100644 --- a/examples/next-app-router/app/page.tsx +++ b/examples/next-app-router/app/page.tsx @@ -1,10 +1,12 @@ +"use client"; + import MediaUploadForm from "@/components/MediaUploadForm"; +import TusUploadForm from "@/components/TusUploadForm"; import MediaList from "@/components/MediaList"; - export default function Home() { return (
-
+

MediaLit Demo

@@ -12,14 +14,45 @@ export default function Home() { handle media files. Upload an image to see:

    -
  • Direct upload using presigned URLs
  • +
  • + Regular upload and resumable upload using TUS + protocol +
  • +
  • Real-time upload progress tracking
  • Automatic thumbnail generation
  • Media information retrieval
  • File deletion
  • Media listing with pagination
- + + {/* Upload Forms Grid */} +
+
+
+

+ Standard Upload +

+

+ Traditional single-request upload. Best for + smaller files. +

+ +
+
+

+ TUS Resumable Upload +

+

+ Multipart resumable uploads with progress + tracking. Ideal for larger files and unreliable + connections. +

+ +
+
+
+

Uploaded Media

diff --git a/examples/next-app-router/components/MediaList.tsx b/examples/next-app-router/components/MediaList.tsx index ef80093c..15836b9f 100644 --- a/examples/next-app-router/components/MediaList.tsx +++ b/examples/next-app-router/components/MediaList.tsx @@ -182,12 +182,42 @@ export default function MediaList() {
{selectedMedia.thumbnail ? ( - {selectedMedia.originalFileName} + selectedMedia.mimeType.startsWith( + "video", + ) ? ( + + ) : ( + {selectedMedia.originalFileName} + ) ) : ( (null); + const [uploading, setUploading] = useState(false); + const [uploadedMedia, setUploadedMedia] = useState(null); + const [error, setError] = useState(""); + const [caption, setCaption] = useState(""); + const [isPublic, setIsPublic] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadSpeed, setUploadSpeed] = useState(""); + const uploadRef = useRef(null); + + const handleUpload = async (e: React.FormEvent) => { + e.preventDefault(); + if (!file) return; + + setUploading(true); + setError(""); + setUploadProgress(0); + setUploadSpeed(""); + + try { + // Get presigned URL + const presignedUrlResponse = await fetch("/api/medialit", { + method: "POST", + }); + const { endpoint, signature, error } = + await presignedUrlResponse.json(); + + if (error || !signature) { + throw new Error(error || "Failed to get signature"); + } + + // Use the endpoint directly since we're sending signature in headers + const uploadUrl = `${endpoint}/media/create/resumable`; + + // Prepare metadata for tus (tus-js-client will encode values as base64) + const metadata = { + fileName: file.name, + mimeType: file.type, + access: isPublic ? "public" : "private", + caption: caption || "", + }; + + // Create tus upload + const upload = new Upload(file, { + endpoint: uploadUrl, + chunkSize: 1024000, + retryDelays: [0, 3000, 5000], + headers: { + "x-medialit-signature": signature, + }, + metadata, + onError: (error) => { + console.error("Tus upload error:", error); + setError( + error instanceof Error + ? error.message + : "Upload failed", + ); + setUploading(false); + }, + onProgress: (bytesUploaded, bytesTotal) => { + const percentage = (bytesUploaded / bytesTotal) * 100; + setUploadProgress(percentage); + }, + onSuccess: async () => { + console.log("Upload finished!"); + setUploading(false); + setUploadProgress(100); + + // Show success message - user can see the uploaded file in the media list below + setUploadedMedia({ + mediaId: "uploaded", + originalFileName: file.name, + mimeType: file.type, + size: file.size, + access: isPublic ? "public" : "private", + file: "", + caption: caption || "", + thumbnail: "", + }); + + // Reset form after 3 seconds + setTimeout(() => { + setFile(null); + setCaption(""); + setUploadedMedia(null); + }, 3000); + }, + }); + + uploadRef.current = upload; + upload.start(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "An error occurred during upload", + ); + setUploading(false); + } + }; + + const handleCancel = () => { + if (uploadRef.current) { + uploadRef.current.abort(); + uploadRef.current = null; + setUploading(false); + setUploadProgress(0); + } + }; + + return ( +
+
+
+ + setFile(e.target.files?.[0] || null)} + className="border rounded-md p-2" + /> +
+ +
+ + setCaption(e.target.value)} + className="border rounded-md p-2" + /> +
+ +
+ setIsPublic(e.target.checked)} + className="rounded" + /> + +
+ + + + {uploading && ( + + )} +
+ + {uploading && ( +
+
+
+
+

+ {uploadProgress.toFixed(1)}% uploaded +

+ {uploadSpeed && ( +

{uploadSpeed}

+ )} +
+ )} + + {error && ( +
+ {error} +
+ )} + + {uploadedMedia && ( +
+

+ ✅ Upload Complete! +

+ +
+

+ File name:{" "} + {uploadedMedia.originalFileName} +

+

+ Size:{" "} + {Math.round(uploadedMedia.size / 1024)} KB +

+

+ Type:{" "} + {uploadedMedia.mimeType} +

+

+ Access:{" "} + {uploadedMedia.access} +

+ {uploadedMedia.caption && ( +

+ Caption:{" "} + {uploadedMedia.caption} +

+ )} +
+ +

+ Your file has been uploaded successfully! Check the + media list below to see it with all details including + the thumbnail. +

+
+ )} +
+ ); +} diff --git a/examples/next-app-router/package.json b/examples/next-app-router/package.json index d8327f85..395cb0e5 100644 --- a/examples/next-app-router/package.json +++ b/examples/next-app-router/package.json @@ -13,7 +13,8 @@ "medialit": "workspace:^", "next": "15.3.1", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "tus-js-client": "^4.3.1" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/packages/medialit/src/index.ts b/packages/medialit/src/index.ts index da91d443..1ce27363 100644 --- a/packages/medialit/src/index.ts +++ b/packages/medialit/src/index.ts @@ -34,7 +34,7 @@ export type FileInput = string | Buffer | Readable; export class MediaLit { private apiKey: string; - private endpoint: string; + public endpoint: string; constructor(config?: MediaLitConfig) { this.checkBrowserEnvironment(); @@ -89,12 +89,13 @@ export class MediaLit { if (options.access) formData.append("access", options.access); if (options.caption) formData.append("caption", options.caption); if (options.group) formData.append("group", options.group); - formData.append("apikey", this.apiKey); + // formData.append("apikey", this.apiKey); const response = await fetch(`${this.endpoint}/media/create`, { method: "POST", headers: { ...formData.getHeaders(), + "x-medialit-apikey": this.apiKey, }, body: formData, }); @@ -107,33 +108,6 @@ export class MediaLit { return response.json(); } - async uploadWithPresignedUrl( - presignedUrl: string, - file: FileInput, - options: UploadOptions = {}, - ): Promise { - this.checkBrowserEnvironment(); - const { formData } = await this.createFormData(file); - - if (options.access) formData.append("access", options.access); - if (options.caption) formData.append("caption", options.caption); - if (options.group) formData.append("group", options.group); - - const response = await fetch(presignedUrl, { - method: "POST", - body: formData, - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error( - error.message || "Upload with presigned URL failed", - ); - } - - return response.json(); - } - async delete(mediaId: string): Promise { this.checkBrowserEnvironment(); if (!this.apiKey) { @@ -146,10 +120,8 @@ export class MediaLit { method: "DELETE", headers: { "Content-Type": "application/json", + "x-medialit-apikey": this.apiKey, }, - body: JSON.stringify({ - apikey: this.apiKey, - }), }, ); @@ -169,10 +141,8 @@ export class MediaLit { method: "POST", headers: { "Content-Type": "application/json", + "x-medialit-apikey": this.apiKey, }, - body: JSON.stringify({ - apikey: this.apiKey, - }), }); if (!response.ok) { @@ -207,10 +177,8 @@ export class MediaLit { method: "POST", headers: { "Content-Type": "application/json", + "x-medialit-apikey": this.apiKey, }, - body: JSON.stringify({ - apikey: this.apiKey, - }), }, ); @@ -222,23 +190,21 @@ export class MediaLit { return response.json(); } - async getPresignedUploadUrl( - options: { group?: string } = {}, - ): Promise { + async getSignature(options: { group?: string } = {}): Promise { this.checkBrowserEnvironment(); if (!this.apiKey) { throw new Error(API_KEY_REQUIRED); } const response = await fetch( - `${this.endpoint}/media/presigned/create`, + `${this.endpoint}/media/signature/create`, { method: "POST", headers: { "Content-Type": "application/json", + "x-medialit-apikey": this.apiKey, }, body: JSON.stringify({ - apikey: this.apiKey, ...(options.group ? { group: options.group } : {}), }), }, @@ -246,11 +212,11 @@ export class MediaLit { if (!response.ok) { const error = await response.json(); - throw new Error(error.message || "Failed to get presigned URL"); + throw new Error(error.message || "Failed to get signature"); } const result = await response.json(); - return result.message; + return result.signature; } async getCount(): Promise { @@ -263,10 +229,8 @@ export class MediaLit { method: "POST", headers: { "Content-Type": "application/json", + "x-medialit-apikey": this.apiKey, }, - body: JSON.stringify({ - apikey: this.apiKey, - }), }); if (!response.ok) { @@ -288,10 +252,8 @@ export class MediaLit { method: "POST", headers: { "Content-Type": "application/json", + "x-medialit-apikey": this.apiKey, }, - body: JSON.stringify({ - apikey: this.apiKey, - }), }); if (!response.ok) { @@ -312,10 +274,8 @@ export class MediaLit { method: "POST", headers: { "Content-Type": "application/json", + "x-medialit-apikey": this.apiKey, }, - body: JSON.stringify({ - apikey: this.apiKey, - }), }); if (!response.ok) { @@ -336,9 +296,9 @@ export class MediaLit { method: "POST", headers: { "Content-Type": "application/json", + "x-medialit-apikey": this.apiKey, }, body: JSON.stringify({ - apikey: this.apiKey, ...settings, }), }); @@ -348,4 +308,34 @@ export class MediaLit { throw new Error(error.message || "Failed to update media settings"); } } + + // async signedUpload( + // signature: string, + // file: FileInput, + // options: UploadOptions = {}, + // ): Promise { + // this.checkBrowserEnvironment(); + // const { formData } = await this.createFormData(file); + + // if (options.access) formData.append("access", options.access); + // if (options.caption) formData.append("caption", options.caption); + // if (options.group) formData.append("group", options.group); + + // const response = await fetch(this.endpoint, { + // method: "POST", + // body: formData, + // headers: { + // "x-medialit-signature": signature + // } + // }); + + // if (!response.ok) { + // const error = await response.json(); + // throw new Error( + // error.message || "Upload with signature failed", + // ); + // } + + // return response.json(); + // } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a8179dc..793cb0d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,12 @@ importers: '@medialit/utils': specifier: workspace:^0.1.0 version: link:../../packages/utils + '@tus/file-store': + specifier: ^2.0.0 + version: 2.0.0 + '@tus/server': + specifier: ^2.3.0 + version: 2.3.0 aws-sdk: specifier: ^2.1692.0 version: 2.1692.0 @@ -81,7 +87,7 @@ importers: specifier: ^16.4.7 version: 16.4.7 express: - specifier: ^4.18.2 + specifier: ^4.2.0 version: 4.21.2 express-fileupload: specifier: ^1.3.1 @@ -341,6 +347,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.1.0(react@19.1.0) + tus-js-client: + specifier: ^4.3.1 + version: 4.3.1 devDependencies: '@eslint/eslintrc': specifier: ^3 @@ -1135,6 +1144,9 @@ packages: cpu: [x64] os: [win32] + '@ioredis/commands@1.4.0': + resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -2046,6 +2058,10 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@redis/client@1.6.1': + resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} + engines: {node: '>=14'} + '@rollup/rollup-android-arm-eabi@4.40.0': resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==} cpu: [arm] @@ -2509,6 +2525,18 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@tus/file-store@2.0.0': + resolution: {integrity: sha512-LTh9L/RoWoo2TbBGPZOuhuyEIIqweoTekT77ZkIVkpYkLK8zTt++PRdY+VyJsLDbFMO9RzvKSBRmj1H8SPdDew==} + engines: {node: '>=20.19.0'} + + '@tus/server@2.3.0': + resolution: {integrity: sha512-7sj4Q3EPvMjS5z9JaNOZ8gvT6HZvDeg/RjEP0ebbfpAo1V095ivkzpKqV/mDIK/ioBwOKj+bOhTtNuueTjVCfw==} + engines: {node: '>=20.19.0'} + + '@tus/utils@0.6.0': + resolution: {integrity: sha512-GpMpAQfVdC4UDhpsZrRPjGpdXg+JW5MquqMqtObUVsORwLBV6XI67iTT5be+z98THdqb6dl3bTLIElIdgPeo2g==} + engines: {node: '>=20.19.0'} + '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} @@ -3318,6 +3346,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -3352,6 +3384,9 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combine-errors@3.0.3: + resolution: {integrity: sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -3437,6 +3472,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + custom-error-instance@2.1.1: + resolution: {integrity: sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -3522,6 +3560,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -4164,6 +4206,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -4423,6 +4469,10 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + ioredis@5.8.2: + resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} + engines: {node: '>=12.22.0'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -4599,6 +4649,10 @@ packages: resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} engines: {node: '>=0.10.0'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4831,6 +4885,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5047,9 +5104,33 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash._baseiteratee@4.7.0: + resolution: {integrity: sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==} + + lodash._basetostring@4.12.0: + resolution: {integrity: sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw==} + + lodash._baseuniq@4.6.0: + resolution: {integrity: sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A==} + + lodash._createset@4.0.3: + resolution: {integrity: sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA==} + + lodash._root@3.0.1: + resolution: {integrity: sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ==} + + lodash._stringtopath@4.8.0: + resolution: {integrity: sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ==} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} @@ -5077,6 +5158,12 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + lodash.uniqby@4.5.0: + resolution: {integrity: sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -5942,6 +6029,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-information@7.0.0: resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==} @@ -5981,6 +6071,9 @@ packages: engines: {node: '>=0.4.x'} deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead. + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -6095,6 +6188,14 @@ packages: recma-stringify@1.0.0: resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -6176,6 +6277,9 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-cwd@2.0.0: resolution: {integrity: sha512-ccu8zQTrzVr954472aUVPLEcB3YpKSYR3cg/3lo1okzobPBM+1INXBbBZlDbnI/hbEocnf8j0QVo43hQKrbchg==} engines: {node: '>=4'} @@ -6219,6 +6323,10 @@ packages: resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} engines: {node: '>=0.12'} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -6319,6 +6427,9 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.1: + resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -6467,6 +6578,7 @@ packages: source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -6500,6 +6612,11 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + srvx@0.8.16: + resolution: {integrity: sha512-hmcGW4CgroeSmzgF1Ihwgl+Ths0JqAJ7HwjP2X7e3JzY7u4IydLMcdnlqGQiQGUswz+PO9oh/KtCpOISIvs9QQ==} + engines: {node: '>=20.16.0'} + hasBin: true + sshpk@1.18.0: resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} engines: {node: '>=0.10.0'} @@ -6512,6 +6629,9 @@ packages: resolution: {integrity: sha512-KZiTzuV3CnSnSvgMRrARVCj+Ht7rMbauGDK0LdVFRGyenwdylpajAp4Q0i6SX8rEmbTpMMf6ryq2gb8pPq2WgQ==} engines: {node: '>=8'} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + static-extend@0.1.2: resolution: {integrity: sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==} engines: {node: '>=0.10.0'} @@ -6837,6 +6957,10 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tus-js-client@4.3.1: + resolution: {integrity: sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==} + engines: {node: '>=18'} + tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} @@ -6950,6 +7074,9 @@ packages: resolution: {integrity: sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==} deprecated: Please see https://github.com/lydell/urix#deprecated + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + url@0.10.3: resolution: {integrity: sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==} @@ -7136,6 +7263,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.7.1: resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==} engines: {node: '>= 14'} @@ -8214,6 +8344,9 @@ snapshots: '@img/sharp-win32-x64@0.34.1': optional: true + '@ioredis/commands@1.4.0': + optional: true + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -9336,6 +9469,13 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@redis/client@1.6.1': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + optional: true + '@rollup/rollup-android-arm-eabi@4.40.0': optional: true @@ -9868,6 +10008,30 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@tus/file-store@2.0.0': + dependencies: + '@tus/utils': 0.6.0 + debug: 4.4.0(supports-color@5.5.0) + optionalDependencies: + '@redis/client': 1.6.1 + transitivePeerDependencies: + - supports-color + + '@tus/server@2.3.0': + dependencies: + '@tus/utils': 0.6.0 + debug: 4.4.0(supports-color@5.5.0) + lodash.throttle: 4.1.1 + set-cookie-parser: 2.7.1 + srvx: 0.8.16 + optionalDependencies: + '@redis/client': 1.6.1 + ioredis: 5.8.2 + transitivePeerDependencies: + - supports-color + + '@tus/utils@0.6.0': {} + '@tybys/wasm-util@0.9.0': dependencies: tslib: 2.8.1 @@ -10819,6 +10983,9 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: + optional: true + co@4.6.0: {} collapse-white-space@2.1.0: {} @@ -10854,6 +11021,11 @@ snapshots: colorette@2.0.20: {} + combine-errors@3.0.3: + dependencies: + custom-error-instance: 2.1.1 + lodash.uniqby: 4.5.0 + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -10921,6 +11093,8 @@ snapshots: csstype@3.1.3: {} + custom-error-instance@2.1.1: {} + damerau-levenshtein@1.0.8: {} dashdash@1.14.1: @@ -11002,6 +11176,9 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: + optional: true + depd@2.0.0: {} dequal@2.0.3: {} @@ -12066,6 +12243,9 @@ snapshots: functions-have-names@1.2.3: {} + generic-pool@3.9.0: + optional: true + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -12367,6 +12547,21 @@ snapshots: dependencies: loose-envify: 1.4.0 + ioredis@5.8.2: + dependencies: + '@ioredis/commands': 1.4.0 + cluster-key-slot: 1.1.2 + debug: 4.4.0(supports-color@5.5.0) + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + optional: true + ipaddr.js@1.9.1: {} is-accessor-descriptor@1.0.1: @@ -12533,6 +12728,8 @@ snapshots: is-stream@1.1.0: {} + is-stream@2.0.1: {} + is-stream@3.0.0: {} is-string@1.1.1: @@ -12966,6 +13163,8 @@ snapshots: joycon@3.1.1: {} + js-base64@3.7.8: {} + js-tokens@4.0.0: {} js-yaml@3.14.1: @@ -13206,8 +13405,33 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash._baseiteratee@4.7.0: + dependencies: + lodash._stringtopath: 4.8.0 + + lodash._basetostring@4.12.0: {} + + lodash._baseuniq@4.6.0: + dependencies: + lodash._createset: 4.0.3 + lodash._root: 3.0.1 + + lodash._createset@4.0.3: {} + + lodash._root@3.0.1: {} + + lodash._stringtopath@4.8.0: + dependencies: + lodash._basetostring: 4.12.0 + + lodash.defaults@4.2.0: + optional: true + lodash.includes@4.3.0: {} + lodash.isarguments@3.1.0: + optional: true + lodash.isboolean@3.0.3: {} lodash.isinteger@4.0.4: {} @@ -13226,6 +13450,13 @@ snapshots: lodash.startcase@4.4.0: {} + lodash.throttle@4.1.1: {} + + lodash.uniqby@4.5.0: + dependencies: + lodash._baseiteratee: 4.7.0 + lodash._baseuniq: 4.6.0 + lodash@4.17.21: {} log-update@6.1.0: @@ -14332,6 +14563,12 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + property-information@7.0.0: {} proxy-addr@2.0.7: @@ -14364,6 +14601,8 @@ snapshots: querystring@0.2.0: {} + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -14519,6 +14758,14 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + redis-errors@1.2.0: + optional: true + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + optional: true + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -14659,6 +14906,8 @@ snapshots: require-main-filename@2.0.0: {} + requires-port@1.0.0: {} + resolve-cwd@2.0.0: dependencies: resolve-from: 3.0.0 @@ -14694,6 +14943,8 @@ snapshots: ret@0.1.15: {} + retry@0.12.0: {} + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -14831,6 +15082,8 @@ snapshots: set-blocking@2.0.0: {} + set-cookie-parser@2.7.1: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -15066,6 +15319,8 @@ snapshots: sprintf-js@1.0.3: {} + srvx@0.8.16: {} + sshpk@1.18.0: dependencies: asn1: 0.2.6 @@ -15084,6 +15339,9 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + standard-as-callback@2.1.0: + optional: true + static-extend@0.1.2: dependencies: define-property: 0.2.5 @@ -15471,6 +15729,16 @@ snapshots: dependencies: safe-buffer: 5.2.1 + tus-js-client@4.3.1: + dependencies: + buffer-from: 1.1.2 + combine-errors: 3.0.3 + is-stream: 2.0.1 + js-base64: 3.7.8 + lodash.throttle: 4.1.1 + proper-lockfile: 4.1.2 + url-parse: 1.5.10 + tweetnacl@0.14.5: {} type-check@0.3.2: @@ -15629,6 +15897,11 @@ snapshots: urix@0.1.0: {} + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + url@0.10.3: dependencies: punycode: 1.3.2 @@ -15861,6 +16134,9 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: + optional: true + yaml@2.7.1: {} yargs-parser@13.1.2: