diff --git a/src/controllers/dream.controller.ts b/src/controllers/dream.controller.ts index 00cfe1c2..f3cae5f5 100644 --- a/src/controllers/dream.controller.ts +++ b/src/controllers/dream.controller.ts @@ -85,6 +85,14 @@ import { transformDreamsWithSignedUrls, } from "utils/transform.util"; import { detectMediaTypeFromExtension } from "utils/media.util"; +import { + clearFilmstripVersion, + delThumbVersion, + getFilmstripVersion, + getThumbVersion, + setFilmstripVersion, + setThumbVersion, +} from "utils/uploadVersion.util"; /** * Handles get dreams @@ -372,17 +380,21 @@ export const handleCreateMultipartUploadDreamFile = async ( * filePath r2 generation */ if (type === DreamFileType.THUMBNAIL) { + const renderVersion = await setThumbVersion(dreamUUID); filePath = generateThumbnailPath({ userIdentifier, dreamUUID, extension: fileExtension, + renderVersion, }); } else if (type === DreamFileType.FILMSTRIP) { + const renderVersion = await setFilmstripVersion(dreamUUID); filePath = generateFilmstripPath({ userIdentifier, dreamUUID, extension: fileExtension, frameNumber: frameNumber!, + renderVersion, }); } else if (type === DreamFileType.DREAM) { filePath = generateDreamPath({ @@ -478,6 +490,7 @@ export const handleRefreshMultipartUploadUrl = async ( userIdentifier, dreamUUID, extension: fileExtension, + renderVersion: await getThumbVersion(dreamUUID), }); } else if (type === DreamFileType.FILMSTRIP) { filePath = generateFilmstripPath({ @@ -485,6 +498,7 @@ export const handleRefreshMultipartUploadUrl = async ( dreamUUID, extension: fileExtension, frameNumber: frameNumber!, + renderVersion: await getFilmstripVersion(dreamUUID), }); } else if (type === DreamFileType.DREAM) { filePath = generateDreamPath({ @@ -579,6 +593,7 @@ export const handleCompleteMultipartUpload = async ( userIdentifier, dreamUUID, extension: fileExtension, + renderVersion: await getThumbVersion(dreamUUID), }); /** @@ -587,12 +602,14 @@ export const handleCompleteMultipartUpload = async ( await dreamRepository.update(dream.id, { thumbnail: filePath, }); + await delThumbVersion(dreamUUID); } else if (type === DreamFileType.FILMSTRIP) { filePath = generateFilmstripPath({ userIdentifier, dreamUUID, extension: fileExtension, frameNumber: frameNumber!, + renderVersion: await getFilmstripVersion(dreamUUID), }); } else if (type === DreamFileType.DREAM && !processed) { filePath = generateDreamPath({ @@ -1030,6 +1047,8 @@ export const handleSetDreamStatusProcessing = async ( return handleNotFound(req as RequestType, res); } + await clearFilmstripVersion(dreamUUID); + const updatedDream = await dreamRepository.save({ ...dream, status: DreamStatusType.PROCESSING, @@ -1091,15 +1110,25 @@ export const handleSetDreamStatusProcessed = async ( */ const user = dream.user; + const filmstripVersion = filmstrip + ? await getFilmstripVersion(dreamUUID) + : undefined; const formatedFilmstrip: Frame[] | undefined = filmstrip?.map( (frame) => ({ frameNumber: Number(frame), - url: `${getUserIdentifier( - user, - )}/${dreamUUID}/filmstrip/frame-${frame}.${FILE_EXTENSIONS.JPG}`, + url: filmstripVersion + ? `${getUserIdentifier( + user, + )}/${dreamUUID}/filmstrip/${filmstripVersion}/frame-${frame}.${ + FILE_EXTENSIONS.JPG + }` + : `${getUserIdentifier( + user, + )}/${dreamUUID}/filmstrip/frame-${frame}.${FILE_EXTENSIONS.JPG}`, }) as Frame, ); + if (filmstripVersion) await clearFilmstripVersion(dreamUUID); const updateData: Partial = { status: DreamStatusType.PROCESSED, @@ -1486,8 +1515,13 @@ export const handleUpdateThumbnailDream = async ( const bucketName = env.R2_BUCKET_NAME; const fileMymeType = req.file?.mimetype; const fileExtension = MYME_TYPES_EXTENSIONS[fileMymeType ?? MYME_TYPES.MP4]; - const fileName = `${dreamUUID}.${fileExtension}`; - const filePath = `${getUserIdentifier(user)}/${dreamUUID}/${fileName}`; + const userIdentifier = getUserIdentifier(dream.user); + const filePath = generateThumbnailPath({ + userIdentifier, + dreamUUID, + extension: fileExtension, + renderVersion: Date.now(), + }); if (thumbnailBuffer) { const command = new PutObjectCommand({ diff --git a/src/controllers/keyframe.controller.ts b/src/controllers/keyframe.controller.ts index b2e40312..e75fe1aa 100644 --- a/src/controllers/keyframe.controller.ts +++ b/src/controllers/keyframe.controller.ts @@ -30,6 +30,11 @@ import { generateKeyframePath, getUploadPartSignedUrl, } from "utils/r2.util"; +import { + delKeyframeVersion, + getKeyframeVersion, + setKeyframeVersion, +} from "utils/uploadVersion.util"; import { CreateMultipartUploadFileRequest } from "types/keyframe.types"; import { keyframeRepository, userRepository } from "database/repositories"; import { @@ -219,10 +224,12 @@ export const handleInitKeyframeImageUpload = async ( /** * filePath r2 generation */ + const renderVersion = await setKeyframeVersion(keyframeUUID); const filePath = generateKeyframePath({ userIdentifier, keyframeUUID, extension: fileExtension, + renderVersion, }); const uploadId = await createMultipartUpload(filePath!); @@ -304,11 +311,13 @@ export const handleCompleteKeyframeImageUpload = async ( userIdentifier, keyframeUUID, extension: fileExtension, + renderVersion: await getKeyframeVersion(keyframeUUID), }); await keyframeRepository.update(keyframe.id, { image: filePath!, }); + await delKeyframeVersion(keyframeUUID); /** * completes multipart upload with path, upload id and parts diff --git a/src/controllers/playlist.controller.ts b/src/controllers/playlist.controller.ts index 6dec86f2..faa309d3 100644 --- a/src/controllers/playlist.controller.ts +++ b/src/controllers/playlist.controller.ts @@ -453,15 +453,15 @@ export const handleGetPlaylistReferences = async ( const unfilteredReferences = await playlistItemRepository.find({ where: Array.isArray(where) ? where.map((playlistWhere) => ({ - playlistItem: { uuid }, - type: PlaylistItemType.PLAYLIST, - playlist: playlistWhere, - })) + playlistItem: { uuid }, + type: PlaylistItemType.PLAYLIST, + playlist: playlistWhere, + })) : { - playlistItem: { uuid }, - type: PlaylistItemType.PLAYLIST, - playlist: where, - }, + playlistItem: { uuid }, + type: PlaylistItemType.PLAYLIST, + playlist: where, + }, select: { id: true, type: true, @@ -557,9 +557,9 @@ export const handleGetPlaylists = async ( const userCondition = { uuid: userUUID }; const where = searchILike ? [ - { user: userCondition, name: searchILike }, - { user: userCondition, displayedOwner: { name: searchILike } }, - ] + { user: userCondition, name: searchILike }, + { user: userCondition, displayedOwner: { name: searchILike } }, + ] : { user: userCondition }; try { @@ -847,7 +847,7 @@ export const handleUpdateThumbnailPlaylist = async ( const fileMymeType = req.file?.mimetype; const fileExtension = MYME_TYPES_EXTENSIONS[fileMymeType ?? MYME_TYPES.JPEG]; - const fileName = `${THUMBNAIL}.${fileExtension}`; + const fileName = `${THUMBNAIL}-${Date.now()}.${fileExtension}`; const filePath = `${getUserIdentifier(user)}/${PLAYLIST_PREFIX}-${ playlist.id }/${fileName}`; diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 72d37cbc..1bdc650d 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -142,15 +142,15 @@ export const handleGetUsers = async ( }; const whereSentence = search ? [ - { - ...baseConditions, - name: ILike(`%${search}%`), - }, - { - ...baseConditions, - email: ILike(`%${search}%`), - }, - ] + { + ...baseConditions, + name: ILike(`%${search}%`), + }, + { + ...baseConditions, + email: ILike(`%${search}%`), + }, + ] : (baseConditions as FindOptionsWhere); const [users, count] = await userRepository.findAndCount({ where: whereSentence, @@ -543,7 +543,7 @@ export const handleUpdateUserAvatar = async ( const fileMymeType = req.file?.mimetype; const fileExtension = MYME_TYPES_EXTENSIONS[fileMymeType ?? MYME_TYPES.JPEG]; - const fileName = `${AVATAR}.${fileExtension}`; + const fileName = `${AVATAR}-${Date.now()}.${fileExtension}`; const filePath = `${getUserIdentifier(user)}/${fileName}`; if (avatarBuffer) { diff --git a/src/utils/r2.util.ts b/src/utils/r2.util.ts index 005d6df8..52322055 100644 --- a/src/utils/r2.util.ts +++ b/src/utils/r2.util.ts @@ -140,24 +140,33 @@ export const generateThumbnailPath = ({ userIdentifier, dreamUUID, extension, + renderVersion, }: { userIdentifier: string; dreamUUID: string; extension: string; -}) => `${userIdentifier}/${dreamUUID}/thumbnails/${dreamUUID}.${extension}`; + renderVersion?: number; +}) => + renderVersion + ? `${userIdentifier}/${dreamUUID}/thumbnails/${dreamUUID}-${renderVersion}.${extension}` + : `${userIdentifier}/${dreamUUID}/thumbnails/${dreamUUID}.${extension}`; export const generateFilmstripPath = ({ userIdentifier, dreamUUID, extension, frameNumber, + renderVersion, }: { userIdentifier: string; dreamUUID: string; extension: string; frameNumber: number; + renderVersion?: number; }) => - `${userIdentifier}/${dreamUUID}/filmstrip/frame-${frameNumber}.${extension}`; + renderVersion + ? `${userIdentifier}/${dreamUUID}/filmstrip/${renderVersion}/frame-${frameNumber}.${extension}` + : `${userIdentifier}/${dreamUUID}/filmstrip/frame-${frameNumber}.${extension}`; export const generateDreamPath = ({ userIdentifier, @@ -178,12 +187,16 @@ export const generateKeyframePath = ({ userIdentifier, keyframeUUID, extension, + renderVersion, }: { userIdentifier: string; keyframeUUID: string; extension: string; + renderVersion?: number; }) => - `${userIdentifier}/keyframes/${keyframeUUID}/${keyframeUUID}.${extension}`; + renderVersion + ? `${userIdentifier}/keyframes/${keyframeUUID}/${keyframeUUID}-${renderVersion}.${extension}` + : `${userIdentifier}/keyframes/${keyframeUUID}/${keyframeUUID}.${extension}`; /** * Helper function to extract file extension from object key or file path diff --git a/src/utils/uploadVersion.util.ts b/src/utils/uploadVersion.util.ts new file mode 100644 index 00000000..735648ef --- /dev/null +++ b/src/utils/uploadVersion.util.ts @@ -0,0 +1,38 @@ +import { redisClient } from "clients/redis.client"; + +const TTL = 7200; // 2 hours — enough for any upload to complete + +const toNumber = (val: string | null): number | undefined => + val ? Number(val) : undefined; + +const makeVersionStore = (prefix: string, nx = false) => ({ + set: async (uuid: string): Promise => { + const key = `upload:version:${prefix}:${uuid}`; + const version = Date.now(); + if (nx) { + await redisClient.set(key, version, "EX", TTL, "NX"); + return toNumber(await redisClient.get(key)) ?? version; + } + await redisClient.set(key, version, "EX", TTL); + return version; + }, + get: (uuid: string) => + redisClient.get(`upload:version:${prefix}:${uuid}`).then(toNumber), + del: (uuid: string) => redisClient.del(`upload:version:${prefix}:${uuid}`), +}); + +const thumbStore = makeVersionStore("thumb"); +const filmstripStore = makeVersionStore("filmstrip", true); +const keyframeStore = makeVersionStore("keyframe"); + +export const setThumbVersion = thumbStore.set; +export const getThumbVersion = thumbStore.get; +export const delThumbVersion = thumbStore.del; + +export const setFilmstripVersion = filmstripStore.set; +export const getFilmstripVersion = filmstripStore.get; +export const clearFilmstripVersion = filmstripStore.del; + +export const setKeyframeVersion = keyframeStore.set; +export const getKeyframeVersion = keyframeStore.get; +export const delKeyframeVersion = keyframeStore.del; diff --git a/src/utils/workos.util.ts b/src/utils/workos.util.ts index 9480a048..5b5d1739 100644 --- a/src/utils/workos.util.ts +++ b/src/utils/workos.util.ts @@ -49,6 +49,13 @@ export const authenticateAndGetWorkOSSession = async (authToken: string) => { }); }; +// Deduplicates concurrent refresh attempts for the same expired session token. +// WorkOS refresh tokens are single-use, so only one refresh per token should be made. +const pendingRefreshes = new Map< + string, + Promise<{ authenticated: boolean; sealedSession?: string }> +>(); + // Common authentication logic export const authenticateWorkOS = async ( authToken: string | undefined, @@ -64,7 +71,14 @@ export const authenticateWorkOS = async ( let session = await authenticateAndGetWorkOSSession(authToken); if (!session) { - const refreshResult = await refreshWorkOSSession(authToken); + let refreshPromise = pendingRefreshes.get(authToken); + if (!refreshPromise) { + refreshPromise = refreshWorkOSSession(authToken).finally(() => { + pendingRefreshes.delete(authToken); + }); + pendingRefreshes.set(authToken, refreshPromise); + } + const refreshResult = await refreshPromise; if (!refreshResult.authenticated || !refreshResult.sealedSession) { return null; }