|
| 1 | +import { readFileSync, createReadStream, rmdirSync, existsSync } from "fs"; |
| 2 | +import path from "path"; |
| 3 | +import thumbnail from "@medialit/thumbnail"; |
| 4 | +import mongoose from "mongoose"; |
| 5 | +import { |
| 6 | + tempFileDirForUploads, |
| 7 | + imagePattern, |
| 8 | + imagePatternForThumbnailGeneration, |
| 9 | + videoPattern, |
| 10 | + USE_CLOUDFRONT, |
| 11 | +} from "../config/constants"; |
| 12 | +import imageUtils from "@medialit/images"; |
| 13 | +import { |
| 14 | + foldersExist, |
| 15 | + createFolders, |
| 16 | +} from "../media/utils/manage-files-on-disk"; |
| 17 | +import type { MediaWithUserId } from "../media/model"; |
| 18 | +import { putObject, UploadParams } from "../services/s3"; |
| 19 | +import logger from "../services/log"; |
| 20 | +import generateKey from "../media/utils/generate-key"; |
| 21 | +import { getMediaSettings } from "../media-settings/queries"; |
| 22 | +import generateFileName from "../media/utils/generate-file-name"; |
| 23 | +import { createMedia } from "../media/queries"; |
| 24 | +import getTags from "../media/utils/get-tags"; |
| 25 | +import { getTusUpload, markTusUploadComplete } from "./queries"; |
| 26 | +import * as presignedUrlService from "../presigning/service"; |
| 27 | + |
| 28 | +const generateAndUploadThumbnail = async ({ |
| 29 | + workingDirectory, |
| 30 | + key, |
| 31 | + mimetype, |
| 32 | + originalFilePath, |
| 33 | + tags, |
| 34 | +}: { |
| 35 | + workingDirectory: string; |
| 36 | + key: string; |
| 37 | + mimetype: string; |
| 38 | + originalFilePath: string; |
| 39 | + tags: string; |
| 40 | +}): Promise<boolean> => { |
| 41 | + const thumbPath = `${workingDirectory}/thumb.webp`; |
| 42 | + |
| 43 | + let isThumbGenerated = false; |
| 44 | + if (imagePatternForThumbnailGeneration.test(mimetype)) { |
| 45 | + await thumbnail.forImage(originalFilePath, thumbPath); |
| 46 | + isThumbGenerated = true; |
| 47 | + } |
| 48 | + if (videoPattern.test(mimetype)) { |
| 49 | + await thumbnail.forVideo(originalFilePath, thumbPath); |
| 50 | + isThumbGenerated = true; |
| 51 | + } |
| 52 | + |
| 53 | + if (isThumbGenerated) { |
| 54 | + await putObject({ |
| 55 | + Key: key, |
| 56 | + Body: createReadStream(thumbPath), |
| 57 | + ContentType: "image/webp", |
| 58 | + ACL: USE_CLOUDFRONT ? "private" : "public-read", |
| 59 | + Tagging: tags, |
| 60 | + }); |
| 61 | + } |
| 62 | + |
| 63 | + return isThumbGenerated; |
| 64 | +}; |
| 65 | + |
| 66 | +export default async function finalizeUpload(uploadId: string) { |
| 67 | + logger.info({ uploadId }, "Finalizing tus upload"); |
| 68 | + |
| 69 | + const tusUpload = await getTusUpload(uploadId); |
| 70 | + if (!tusUpload) { |
| 71 | + throw new Error(`Tus upload not found: ${uploadId}`); |
| 72 | + } |
| 73 | + |
| 74 | + if (tusUpload.isComplete) { |
| 75 | + logger.info({ uploadId }, "Upload already finalized"); |
| 76 | + return; |
| 77 | + } |
| 78 | + |
| 79 | + const { userId, apikey, metadata, uploadLength, tempFilePath, signature } = |
| 80 | + tusUpload; |
| 81 | + |
| 82 | + // Read the completed file from tus data store |
| 83 | + const tusFilePath = path.join( |
| 84 | + `${tempFileDirForUploads}/tus-uploads`, |
| 85 | + tempFilePath, |
| 86 | + ); |
| 87 | + |
| 88 | + if (!existsSync(tusFilePath)) { |
| 89 | + logger.error({ uploadId, tusFilePath }, "Tus file not found"); |
| 90 | + throw new Error(`Tus file not found: ${tusFilePath}`); |
| 91 | + } |
| 92 | + |
| 93 | + const mediaSettings = await getMediaSettings(userId, apikey); |
| 94 | + const useWebP = mediaSettings?.useWebP || false; |
| 95 | + const webpOutputQuality = mediaSettings?.webpOutputQuality || 0; |
| 96 | + |
| 97 | + // Generate unique media ID |
| 98 | + const fileName = generateFileName(metadata.filename); |
| 99 | + const temporaryFolderForWork = `${tempFileDirForUploads}/${fileName.name}`; |
| 100 | + if (!foldersExist([temporaryFolderForWork])) { |
| 101 | + createFolders([temporaryFolderForWork]); |
| 102 | + } |
| 103 | + |
| 104 | + let fileExtension = path.extname(metadata.filename).replace(".", ""); |
| 105 | + let mimeType = metadata.mimetype; |
| 106 | + if (useWebP && imagePattern.test(mimeType)) { |
| 107 | + fileExtension = "webp"; |
| 108 | + mimeType = "image/webp"; |
| 109 | + } |
| 110 | + |
| 111 | + const mainFilePath = `${temporaryFolderForWork}/main.${fileExtension}`; |
| 112 | + |
| 113 | + // Copy file from tus store to working directory |
| 114 | + const tusFileContent = readFileSync(tusFilePath); |
| 115 | + require("fs").writeFileSync(mainFilePath, tusFileContent); |
| 116 | + |
| 117 | + // Apply WebP conversion if needed |
| 118 | + if (useWebP && imagePattern.test(metadata.mimetype)) { |
| 119 | + await imageUtils.convertToWebp(mainFilePath, webpOutputQuality); |
| 120 | + } |
| 121 | + |
| 122 | + const uploadParams: UploadParams = { |
| 123 | + Key: generateKey({ |
| 124 | + mediaId: fileName.name, |
| 125 | + access: metadata.access === "public" ? "public" : "private", |
| 126 | + filename: `main.${fileExtension}`, |
| 127 | + }), |
| 128 | + Body: createReadStream(mainFilePath), |
| 129 | + ContentType: mimeType, |
| 130 | + ACL: USE_CLOUDFRONT |
| 131 | + ? "private" |
| 132 | + : metadata.access === "public" |
| 133 | + ? "public-read" |
| 134 | + : "private", |
| 135 | + }; |
| 136 | + const tags = getTags(userId, metadata.group); |
| 137 | + uploadParams.Tagging = tags; |
| 138 | + |
| 139 | + await putObject(uploadParams); |
| 140 | + |
| 141 | + let isThumbGenerated = false; |
| 142 | + try { |
| 143 | + isThumbGenerated = await generateAndUploadThumbnail({ |
| 144 | + workingDirectory: temporaryFolderForWork, |
| 145 | + mimetype: metadata.mimetype, |
| 146 | + originalFilePath: mainFilePath, |
| 147 | + key: generateKey({ |
| 148 | + mediaId: fileName.name, |
| 149 | + access: "public", |
| 150 | + filename: "thumb.webp", |
| 151 | + }), |
| 152 | + tags, |
| 153 | + }); |
| 154 | + } catch (err: any) { |
| 155 | + logger.error({ err }, err.message); |
| 156 | + } |
| 157 | + |
| 158 | + rmdirSync(temporaryFolderForWork, { recursive: true }); |
| 159 | + |
| 160 | + const mediaObject: MediaWithUserId = { |
| 161 | + fileName: `main.${fileExtension}`, |
| 162 | + mediaId: fileName.name, |
| 163 | + userId: new mongoose.Types.ObjectId(userId), |
| 164 | + apikey, |
| 165 | + originalFileName: metadata.filename, |
| 166 | + mimeType, |
| 167 | + size: uploadLength, |
| 168 | + thumbnailGenerated: isThumbGenerated, |
| 169 | + caption: metadata.caption, |
| 170 | + accessControl: metadata.access === "public" ? "public-read" : "private", |
| 171 | + group: metadata.group, |
| 172 | + }; |
| 173 | + const media = await createMedia(mediaObject); |
| 174 | + |
| 175 | + // Mark upload as complete |
| 176 | + await markTusUploadComplete(uploadId); |
| 177 | + |
| 178 | + // Cleanup presigned URL if used |
| 179 | + if (signature) { |
| 180 | + presignedUrlService.cleanup(userId, signature).catch((err: any) => { |
| 181 | + logger.error( |
| 182 | + { err }, |
| 183 | + `Error in cleaning up expired links for ${userId}`, |
| 184 | + ); |
| 185 | + }); |
| 186 | + } |
| 187 | + |
| 188 | + // Cleanup tus file |
| 189 | + try { |
| 190 | + if (existsSync(tusFilePath)) { |
| 191 | + require("fs").unlinkSync(tusFilePath); |
| 192 | + } |
| 193 | + } catch (err) { |
| 194 | + logger.error({ err }, "Error cleaning up tus file"); |
| 195 | + } |
| 196 | + |
| 197 | + logger.info( |
| 198 | + { uploadId, mediaId: media.mediaId }, |
| 199 | + "Tus upload finalized successfully", |
| 200 | + ); |
| 201 | + |
| 202 | + return media.mediaId; |
| 203 | +} |
0 commit comments