From 1fda64ec435dbba62548e12ce8c34e3dec69102b Mon Sep 17 00:00:00 2001 From: Mino484 Date: Mon, 17 Nov 2025 17:19:47 +0300 Subject: [PATCH] working v1 --- .babelrc | 5 + .dockerignore | 23 + .env.example | 108 ++ .github/workflows/docker-build-dev.yml | 67 + .github/workflows/docker-build.yml | 69 + .github/workflows/release-please.yml | 20 + .gitignore | 31 + CHANGELOG.md | 13 + Dockerfile | 32 + LICENSE | 674 +++++++ README.md | 319 ++++ backend/config/.env.development.example | 96 + backend/config/.env.production.example | 96 + backend/config/.env.test.example | 96 + backend/controllers/file-controller.ts | 633 +++++++ backend/controllers/folder-controller.ts | 285 +++ backend/controllers/user-controller.ts | 271 +++ backend/cookies/create-cookies.ts | 83 + .../db/connections/mongoose-server-utils.ts | 8 + backend/db/connections/mongoose.ts | 27 + backend/db/connections/s3.ts | 22 + backend/db/mongoDB/fileDB.ts | 375 ++++ backend/db/mongoDB/folderDB.ts | 251 +++ backend/db/mongoDB/thumbnailDB.ts | 28 + backend/db/mongoDB/userDB.ts | 15 + backend/enviroment/env.ts | 75 + backend/enviroment/get-env-variables.ts | 18 + backend/express-routers/file-router.ts | 214 +++ backend/express-routers/folder-router.ts | 107 ++ backend/express-routers/user-router.ts | 66 + backend/key/get-key.ts | 40 + backend/key/get-web-UI-key.ts | 52 + backend/middleware/auth.ts | 53 + backend/middleware/authFullUser.ts | 62 + backend/middleware/authLogout.ts | 53 + backend/middleware/authRefresh.ts | 99 + backend/middleware/authStreamVideo.ts | 103 ++ backend/middleware/emailAuth.ts | 0 backend/middleware/files/files-middleware.ts | 155 ++ .../middleware/folders/folder-middleware.ts | 98 + backend/middleware/tempAuth.ts | 70 + backend/middleware/tempAuthVideo.ts | 70 + backend/middleware/user/user-middleware.ts | 77 + backend/middleware/utils/middleware-utils.ts | 43 + backend/models/file-model.ts | 112 ++ backend/models/file-system-model.ts | 69 + backend/models/folder-model.ts | 57 + backend/models/thumbnail-model.ts | 60 + backend/models/user-model.ts | 464 +++++ backend/server/server-start.ts | 35 + backend/server/server.ts | 82 + .../chunk-service/actions/S3-actions.ts | 92 + .../actions/file-system-actions.ts | 72 + .../chunk-service/actions/helper-actions.ts | 11 + .../services/chunk-service/chunk-service.ts | 598 ++++++ backend/services/chunk-service/store-types.ts | 16 + .../chunk-service/utils/ChunkInterface.ts | 40 + .../chunk-service/utils/awaitStream.ts | 23 + .../utils/awaitUploadStreamFS.ts | 33 + .../utils/awaitUploadStreamS3.ts | 17 + .../utils/cachedSubscriptionStatuses.ts | 57 + .../utils/createImageThumbnail.ts | 133 ++ .../utils/createVideoThumbnail.ts | 188 ++ .../chunk-service/utils/fixEndChunkLength.ts | 5 + .../utils/fixStartChunkLength.ts | 6 + .../chunk-service/utils/getBusboyData.ts | 229 +++ .../chunk-service/utils/getFileData.ts | 215 +++ .../chunk-service/utils/getFileSize.ts | 19 + .../utils/getFolderUploadBusboyData.ts | 271 +++ .../chunk-service/utils/getPrevIVFS.ts | 19 + .../chunk-service/utils/getPrevIVS3.ts | 26 + .../chunk-service/utils/getPublicFileData.ts | 108 ++ .../chunk-service/utils/getThumbnailData.ts | 93 + .../chunk-service/utils/removeChunksFS.ts | 17 + .../chunk-service/utils/removeChunksS3.ts | 17 + .../chunk-service/utils/removeTempToken.ts | 11 + .../chunk-service/utils/storageHelper.ts | 29 + .../utils/tempCreateVideoThumbnail.ts | 140 ++ backend/services/file-service/file-service.ts | 345 ++++ .../services/folder-service/folder-service.ts | 184 ++ backend/services/user-service/user-service.ts | 245 +++ backend/tempStorage/tempStorage.ts | 3 + backend/tsconfig.json | 83 + backend/types/file-types.ts | 11 + backend/types/folder-types.ts | 6 + backend/utils/ConflictError.ts | 11 + backend/utils/ForbiddenError.ts | 11 + backend/utils/InternalServerError.ts | 10 + backend/utils/NotAuthorizedError.ts | 11 + backend/utils/NotEmailVerifiedError.ts | 13 + backend/utils/NotFoundError.ts | 11 + backend/utils/NotValidDataError.ts | 11 + .../utils/convertDriveFolderToMongoFolder.ts | 17 + .../convertDriveFoldersToMongoFolders.ts | 14 + backend/utils/convertDriveListToMongoList.ts | 14 + backend/utils/convertDriveToMongo.ts | 36 + backend/utils/createEmailTransporter.ts | 33 + backend/utils/createQuery.ts | 104 ++ backend/utils/createQueryGoogle.ts | 34 + backend/utils/createQueryGoogleFolder.ts | 28 + backend/utils/getFSStoragePath.ts | 5 + backend/utils/getKeyFromTerminal.ts | 17 + backend/utils/imageChecker.ts | 19 + backend/utils/mobileCheck.ts | 8 + backend/utils/sanitizeFilename.ts | 9 + backend/utils/sendPasswordResetEmail.ts | 57 + backend/utils/sendShareEmail.ts | 23 + backend/utils/sendVerificationEmail.ts | 58 + backend/utils/sortBySwitch.ts | 19 + backend/utils/sortBySwitchFolder.ts | 20 + backend/utils/sortGoogleMongoFolderList.ts | 54 + backend/utils/sortGoogleMongoList.ts | 55 + backend/utils/sortGoogleMongoQuickFiles.ts | 19 + backend/utils/streamToBuffer.ts | 10 + backend/utils/userUpdateCheck.ts | 46 + backend/utils/videoChecker.ts | 57 + docker-compose copy.yml | 35 + docker-compose.yml | 30 + eslint.config.mjs | 26 + index.html | 19 + jest.config.js | 198 ++ key/getKey.js | 38 + key/getNewKey.js | 22 + nodemon.json | 6 + package.json | 134 ++ postcss.config.js | 6 + public/images/icon.png | Bin 0 -> 6418 bytes serverUtils/backupDatabase.js | 56 + serverUtils/changeEncryptionPassword.js | 295 +++ serverUtils/cleanDatabase.js | 118 ++ serverUtils/createIndexes.js | 52 + serverUtils/createTempDirectory.js | 103 ++ serverUtils/createThumbnailBuffer.js | 82 + serverUtils/createVideoThumbnails.js | 79 + serverUtils/deleteDatabase.js | 81 + serverUtils/deleteTempDatabase.js | 79 + serverUtils/getEnvVaribables.js | 10 + serverUtils/migrateMyDrive4.js | 47 + serverUtils/mongoServerUtil.js | 5 + serverUtils/removeOldPersonalData.js | 79 + serverUtils/removeOldSubscriptionData.js | 204 +++ serverUtils/removeTokens.js | 44 + serverUtils/restoreDatabase.js | 55 + serverUtils/restoreFromTempDirectory.js | 105 ++ serverUtils/setupServer.js | 365 ++++ src/api/filesAPI.ts | 231 +++ src/api/foldersAPI.ts | 147 ++ src/api/userAPI.ts | 98 + src/app.tsx | 26 + src/axiosInterceptor/index.ts | 94 + .../AddNewDropdown/AddNewDropdown.tsx | 168 ++ src/components/ContextMenu/ContextMenu.tsx | 255 +++ src/components/Dataform/Dataform.tsx | 156 ++ src/components/DownloadPage/DownloadPage.tsx | 152 ++ .../FileInfoPopup/FileInfoPopup.tsx | 197 ++ src/components/FileItem/FileItem.tsx | 282 +++ src/components/Files/Files.tsx | 136 ++ src/components/FolderItem/FolderItem.tsx | 185 ++ src/components/Folders/Folders.tsx | 157 ++ src/components/Header/Header.tsx | 75 + src/components/Homepage/Homepage.tsx | 27 + src/components/LandingPage/LandingPage.css | 1344 ++++++++++++++ src/components/LandingPage/LandingPage.tsx | 573 ++++++ src/components/LeftSection/LeftSection.tsx | 189 ++ src/components/LoginPage/LoginPage.tsx | 379 ++++ src/components/MainSection/MainSection.tsx | 59 + src/components/MediaItem/MediaItem.tsx | 128 ++ src/components/Medias/Medias.tsx | 171 ++ src/components/MoverPopup/MoverPopup.tsx | 301 ++++ .../MultiSelectBar/MultiSelectBar.tsx | 207 +++ src/components/ParentBar/ParentBar.tsx | 91 + .../PhotoViewerPopup/PhotoViewerPopup.tsx | 378 ++++ src/components/QuickAccess/QuickAccess.tsx | 52 + .../QuickAccessItem/QuickAccessItem.tsx | 210 +++ .../ResetPasswordPage/ResetPasswordPage.tsx | 123 ++ src/components/RightSection/RightSection.tsx | 261 +++ src/components/SearchBar/SearchBar.tsx | 185 ++ .../SearchBarItem/SearchBarItem.tsx | 78 + .../SettingsPage/SettingsAccountSection.tsx | 166 ++ .../SettingsChangePasswordPopup.tsx | 158 ++ .../SettingsPage/SettingsGeneralSection.tsx | 165 ++ src/components/SettingsPage/SettingsPage.tsx | 141 ++ src/components/SharePopup/SharePopup.tsx | 258 +++ src/components/Spinner/Spinner.tsx | 1 + src/components/UploadItem/UploadItem.tsx | 67 + src/components/Uploader/Uploader.tsx | 61 + .../VerifyEmailPage/VerifyEmailPage.tsx | 37 + src/config/.env.development.example | 11 + src/config/.env.production.example | 16 + src/enviroment/envFrontEnd.js | 15 + src/hooks/actions.ts | 267 +++ src/hooks/contextMenu.ts | 79 + src/hooks/files.ts | 286 +++ src/hooks/folders.ts | 70 + src/hooks/infiniteScroll.ts | 43 + src/hooks/preferenceSetter.ts | 58 + src/hooks/store.ts | 6 + src/hooks/user.ts | 37 + src/hooks/utils.ts | 119 ++ src/icons/AccountIcon.tsx | 15 + src/icons/ActionsIcon.tsx | 24 + src/icons/AlertIcon.tsx | 15 + src/icons/ArrowBackIcon.tsx | 15 + src/icons/CalendarIcon.tsx | 17 + src/icons/CheckCircleIcon.tsx | 15 + src/icons/ChevronOutline.tsx | 20 + src/icons/ChevronSolid.tsx | 22 + src/icons/CircleLeftIcon.tsx | 15 + src/icons/CircleRightIcon.tsx | 15 + src/icons/ClockIcon.tsx | 17 + src/icons/CloseIcon.tsx | 15 + src/icons/CreateFolderIcon.tsx | 22 + src/icons/DownloadIcon.tsx | 26 + src/icons/FileDetailsIcon.tsx | 68 + src/icons/FolderIcon.tsx | 14 + src/icons/FolderUploadIcon.tsx | 15 + src/icons/HomeIconOutline.tsx | 15 + src/icons/HomeListIcon.tsx | 61 + src/icons/LockIcon.tsx | 17 + src/icons/MenuIcon.tsx | 15 + src/icons/MinimizeIcon.tsx | 12 + src/icons/MoveIcon.tsx | 24 + src/icons/MultiSelectIcon.tsx | 19 + src/icons/OneIcon.tsx | 15 + src/icons/PhotoIcon.tsx | 13 + src/icons/PlayIcon.tsx | 12 + src/icons/PublicIcon.tsx | 15 + src/icons/RenameIcon.tsx | 24 + src/icons/RestoreIcon.tsx | 20 + src/icons/SearchIcon.tsx | 30 + src/icons/SettingsIcon.tsx | 15 + src/icons/SettingsIconSolid.tsx | 24 + src/icons/ShareIcon.tsx | 26 + src/icons/SpacerIcon.tsx | 22 + src/icons/StorageIcon.tsx | 19 + src/icons/TrashIcon.tsx | 17 + src/icons/TuneIcon.tsx | 15 + src/icons/UploadFileIcon.tsx | 17 + src/popups/file.ts | 118 ++ src/popups/folder.ts | 44 + src/popups/user.ts | 11 + src/providers/AuthProvider.js | 19 + src/reducers/filter.ts | 25 + src/reducers/general.ts | 35 + src/reducers/leftSection.ts | 26 + src/reducers/selected.ts | 195 ++ src/reducers/uploader.ts | 60 + src/reducers/user.ts | 32 + src/routers/AppRouter.jsx | 45 + src/routers/PrivateRoute.jsx | 17 + src/routers/PublicRoute.jsx | 21 + src/store/configureStore.ts | 23 + src/styles/base/_base.scss | 50 + src/styles/components/_Spinner.scss | 125 ++ src/styles/components/_Swal.scss | 15 + src/styles/styles.scss | 49 + src/types/file.ts | 43 + src/types/folders.ts | 11 + src/types/user.ts | 5 + src/utils/InternalServerError.js | 10 + src/utils/NotAuthorizedError.js | 10 + src/utils/NotFoundError.js | 10 + src/utils/PWAUtils.ts | 6 + src/utils/cancelTokenManager.ts | 28 + src/utils/capitalize.ts | 9 + src/utils/convertDriveListToMongoList.js | 14 + src/utils/convertDriveToMongo.js | 23 + src/utils/createError.js | 12 + src/utils/createQuery.js | 41 + src/utils/files.ts | 59 + src/utils/getBackendURL.ts | 18 + src/utils/imageChecker.js | 31 + src/utils/mobileCheck.ts | 17 + src/utils/reduceQuickItemList.js | 15 + src/utils/sortBySwitch.js | 15 + src/utils/sortBySwitchFolder.js | 15 + src/utils/updateSettings.js | 9 + src/utils/videoChecker.js | 57 + tailwind.config.js | 42 + tests/controller/file-controller.test.js | 1600 +++++++++++++++++ tests/controller/folder.controller.test.js | 1165 ++++++++++++ tests/controller/user-controller.test.js | 422 +++++ tests/utils/db-setup.js | 49 + tests/utils/express-app.js | 49 + tests/utils/fileUtils.js | 15 + tsconfig.json | 25 + tsconfig.node.json | 11 + vite.config.ts | 68 + 288 files changed, 27337 insertions(+) create mode 100644 .babelrc create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/workflows/docker-build-dev.yml create mode 100644 .github/workflows/docker-build.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 backend/config/.env.development.example create mode 100644 backend/config/.env.production.example create mode 100644 backend/config/.env.test.example create mode 100644 backend/controllers/file-controller.ts create mode 100644 backend/controllers/folder-controller.ts create mode 100644 backend/controllers/user-controller.ts create mode 100644 backend/cookies/create-cookies.ts create mode 100644 backend/db/connections/mongoose-server-utils.ts create mode 100644 backend/db/connections/mongoose.ts create mode 100644 backend/db/connections/s3.ts create mode 100644 backend/db/mongoDB/fileDB.ts create mode 100644 backend/db/mongoDB/folderDB.ts create mode 100644 backend/db/mongoDB/thumbnailDB.ts create mode 100644 backend/db/mongoDB/userDB.ts create mode 100644 backend/enviroment/env.ts create mode 100644 backend/enviroment/get-env-variables.ts create mode 100644 backend/express-routers/file-router.ts create mode 100644 backend/express-routers/folder-router.ts create mode 100644 backend/express-routers/user-router.ts create mode 100644 backend/key/get-key.ts create mode 100644 backend/key/get-web-UI-key.ts create mode 100644 backend/middleware/auth.ts create mode 100644 backend/middleware/authFullUser.ts create mode 100644 backend/middleware/authLogout.ts create mode 100644 backend/middleware/authRefresh.ts create mode 100644 backend/middleware/authStreamVideo.ts create mode 100644 backend/middleware/emailAuth.ts create mode 100644 backend/middleware/files/files-middleware.ts create mode 100644 backend/middleware/folders/folder-middleware.ts create mode 100644 backend/middleware/tempAuth.ts create mode 100644 backend/middleware/tempAuthVideo.ts create mode 100644 backend/middleware/user/user-middleware.ts create mode 100644 backend/middleware/utils/middleware-utils.ts create mode 100644 backend/models/file-model.ts create mode 100644 backend/models/file-system-model.ts create mode 100644 backend/models/folder-model.ts create mode 100644 backend/models/thumbnail-model.ts create mode 100644 backend/models/user-model.ts create mode 100644 backend/server/server-start.ts create mode 100644 backend/server/server.ts create mode 100644 backend/services/chunk-service/actions/S3-actions.ts create mode 100644 backend/services/chunk-service/actions/file-system-actions.ts create mode 100644 backend/services/chunk-service/actions/helper-actions.ts create mode 100644 backend/services/chunk-service/chunk-service.ts create mode 100644 backend/services/chunk-service/store-types.ts create mode 100644 backend/services/chunk-service/utils/ChunkInterface.ts create mode 100644 backend/services/chunk-service/utils/awaitStream.ts create mode 100644 backend/services/chunk-service/utils/awaitUploadStreamFS.ts create mode 100644 backend/services/chunk-service/utils/awaitUploadStreamS3.ts create mode 100644 backend/services/chunk-service/utils/cachedSubscriptionStatuses.ts create mode 100644 backend/services/chunk-service/utils/createImageThumbnail.ts create mode 100644 backend/services/chunk-service/utils/createVideoThumbnail.ts create mode 100644 backend/services/chunk-service/utils/fixEndChunkLength.ts create mode 100644 backend/services/chunk-service/utils/fixStartChunkLength.ts create mode 100644 backend/services/chunk-service/utils/getBusboyData.ts create mode 100644 backend/services/chunk-service/utils/getFileData.ts create mode 100644 backend/services/chunk-service/utils/getFileSize.ts create mode 100644 backend/services/chunk-service/utils/getFolderUploadBusboyData.ts create mode 100644 backend/services/chunk-service/utils/getPrevIVFS.ts create mode 100644 backend/services/chunk-service/utils/getPrevIVS3.ts create mode 100644 backend/services/chunk-service/utils/getPublicFileData.ts create mode 100644 backend/services/chunk-service/utils/getThumbnailData.ts create mode 100644 backend/services/chunk-service/utils/removeChunksFS.ts create mode 100644 backend/services/chunk-service/utils/removeChunksS3.ts create mode 100644 backend/services/chunk-service/utils/removeTempToken.ts create mode 100644 backend/services/chunk-service/utils/storageHelper.ts create mode 100644 backend/services/chunk-service/utils/tempCreateVideoThumbnail.ts create mode 100644 backend/services/file-service/file-service.ts create mode 100644 backend/services/folder-service/folder-service.ts create mode 100644 backend/services/user-service/user-service.ts create mode 100644 backend/tempStorage/tempStorage.ts create mode 100644 backend/tsconfig.json create mode 100644 backend/types/file-types.ts create mode 100644 backend/types/folder-types.ts create mode 100644 backend/utils/ConflictError.ts create mode 100644 backend/utils/ForbiddenError.ts create mode 100644 backend/utils/InternalServerError.ts create mode 100644 backend/utils/NotAuthorizedError.ts create mode 100644 backend/utils/NotEmailVerifiedError.ts create mode 100644 backend/utils/NotFoundError.ts create mode 100644 backend/utils/NotValidDataError.ts create mode 100644 backend/utils/convertDriveFolderToMongoFolder.ts create mode 100644 backend/utils/convertDriveFoldersToMongoFolders.ts create mode 100644 backend/utils/convertDriveListToMongoList.ts create mode 100644 backend/utils/convertDriveToMongo.ts create mode 100644 backend/utils/createEmailTransporter.ts create mode 100644 backend/utils/createQuery.ts create mode 100644 backend/utils/createQueryGoogle.ts create mode 100644 backend/utils/createQueryGoogleFolder.ts create mode 100644 backend/utils/getFSStoragePath.ts create mode 100644 backend/utils/getKeyFromTerminal.ts create mode 100644 backend/utils/imageChecker.ts create mode 100644 backend/utils/mobileCheck.ts create mode 100644 backend/utils/sanitizeFilename.ts create mode 100644 backend/utils/sendPasswordResetEmail.ts create mode 100644 backend/utils/sendShareEmail.ts create mode 100644 backend/utils/sendVerificationEmail.ts create mode 100644 backend/utils/sortBySwitch.ts create mode 100644 backend/utils/sortBySwitchFolder.ts create mode 100644 backend/utils/sortGoogleMongoFolderList.ts create mode 100644 backend/utils/sortGoogleMongoList.ts create mode 100644 backend/utils/sortGoogleMongoQuickFiles.ts create mode 100644 backend/utils/streamToBuffer.ts create mode 100644 backend/utils/userUpdateCheck.ts create mode 100644 backend/utils/videoChecker.ts create mode 100644 docker-compose copy.yml create mode 100644 docker-compose.yml create mode 100644 eslint.config.mjs create mode 100644 index.html create mode 100644 jest.config.js create mode 100644 key/getKey.js create mode 100644 key/getNewKey.js create mode 100644 nodemon.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 public/images/icon.png create mode 100644 serverUtils/backupDatabase.js create mode 100644 serverUtils/changeEncryptionPassword.js create mode 100644 serverUtils/cleanDatabase.js create mode 100644 serverUtils/createIndexes.js create mode 100644 serverUtils/createTempDirectory.js create mode 100644 serverUtils/createThumbnailBuffer.js create mode 100644 serverUtils/createVideoThumbnails.js create mode 100644 serverUtils/deleteDatabase.js create mode 100644 serverUtils/deleteTempDatabase.js create mode 100644 serverUtils/getEnvVaribables.js create mode 100644 serverUtils/migrateMyDrive4.js create mode 100644 serverUtils/mongoServerUtil.js create mode 100644 serverUtils/removeOldPersonalData.js create mode 100644 serverUtils/removeOldSubscriptionData.js create mode 100644 serverUtils/removeTokens.js create mode 100644 serverUtils/restoreDatabase.js create mode 100644 serverUtils/restoreFromTempDirectory.js create mode 100644 serverUtils/setupServer.js create mode 100644 src/api/filesAPI.ts create mode 100644 src/api/foldersAPI.ts create mode 100644 src/api/userAPI.ts create mode 100644 src/app.tsx create mode 100644 src/axiosInterceptor/index.ts create mode 100644 src/components/AddNewDropdown/AddNewDropdown.tsx create mode 100644 src/components/ContextMenu/ContextMenu.tsx create mode 100644 src/components/Dataform/Dataform.tsx create mode 100644 src/components/DownloadPage/DownloadPage.tsx create mode 100644 src/components/FileInfoPopup/FileInfoPopup.tsx create mode 100644 src/components/FileItem/FileItem.tsx create mode 100644 src/components/Files/Files.tsx create mode 100644 src/components/FolderItem/FolderItem.tsx create mode 100644 src/components/Folders/Folders.tsx create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/Homepage/Homepage.tsx create mode 100644 src/components/LandingPage/LandingPage.css create mode 100644 src/components/LandingPage/LandingPage.tsx create mode 100644 src/components/LeftSection/LeftSection.tsx create mode 100644 src/components/LoginPage/LoginPage.tsx create mode 100644 src/components/MainSection/MainSection.tsx create mode 100644 src/components/MediaItem/MediaItem.tsx create mode 100644 src/components/Medias/Medias.tsx create mode 100644 src/components/MoverPopup/MoverPopup.tsx create mode 100644 src/components/MultiSelectBar/MultiSelectBar.tsx create mode 100644 src/components/ParentBar/ParentBar.tsx create mode 100644 src/components/PhotoViewerPopup/PhotoViewerPopup.tsx create mode 100644 src/components/QuickAccess/QuickAccess.tsx create mode 100644 src/components/QuickAccessItem/QuickAccessItem.tsx create mode 100644 src/components/ResetPasswordPage/ResetPasswordPage.tsx create mode 100644 src/components/RightSection/RightSection.tsx create mode 100644 src/components/SearchBar/SearchBar.tsx create mode 100644 src/components/SearchBarItem/SearchBarItem.tsx create mode 100644 src/components/SettingsPage/SettingsAccountSection.tsx create mode 100644 src/components/SettingsPage/SettingsChangePasswordPopup.tsx create mode 100644 src/components/SettingsPage/SettingsGeneralSection.tsx create mode 100644 src/components/SettingsPage/SettingsPage.tsx create mode 100644 src/components/SharePopup/SharePopup.tsx create mode 100644 src/components/Spinner/Spinner.tsx create mode 100644 src/components/UploadItem/UploadItem.tsx create mode 100644 src/components/Uploader/Uploader.tsx create mode 100644 src/components/VerifyEmailPage/VerifyEmailPage.tsx create mode 100644 src/config/.env.development.example create mode 100644 src/config/.env.production.example create mode 100644 src/enviroment/envFrontEnd.js create mode 100644 src/hooks/actions.ts create mode 100644 src/hooks/contextMenu.ts create mode 100644 src/hooks/files.ts create mode 100644 src/hooks/folders.ts create mode 100644 src/hooks/infiniteScroll.ts create mode 100644 src/hooks/preferenceSetter.ts create mode 100644 src/hooks/store.ts create mode 100644 src/hooks/user.ts create mode 100644 src/hooks/utils.ts create mode 100644 src/icons/AccountIcon.tsx create mode 100644 src/icons/ActionsIcon.tsx create mode 100644 src/icons/AlertIcon.tsx create mode 100644 src/icons/ArrowBackIcon.tsx create mode 100644 src/icons/CalendarIcon.tsx create mode 100644 src/icons/CheckCircleIcon.tsx create mode 100644 src/icons/ChevronOutline.tsx create mode 100644 src/icons/ChevronSolid.tsx create mode 100644 src/icons/CircleLeftIcon.tsx create mode 100644 src/icons/CircleRightIcon.tsx create mode 100644 src/icons/ClockIcon.tsx create mode 100644 src/icons/CloseIcon.tsx create mode 100644 src/icons/CreateFolderIcon.tsx create mode 100644 src/icons/DownloadIcon.tsx create mode 100644 src/icons/FileDetailsIcon.tsx create mode 100644 src/icons/FolderIcon.tsx create mode 100644 src/icons/FolderUploadIcon.tsx create mode 100644 src/icons/HomeIconOutline.tsx create mode 100644 src/icons/HomeListIcon.tsx create mode 100644 src/icons/LockIcon.tsx create mode 100644 src/icons/MenuIcon.tsx create mode 100644 src/icons/MinimizeIcon.tsx create mode 100644 src/icons/MoveIcon.tsx create mode 100644 src/icons/MultiSelectIcon.tsx create mode 100644 src/icons/OneIcon.tsx create mode 100644 src/icons/PhotoIcon.tsx create mode 100644 src/icons/PlayIcon.tsx create mode 100644 src/icons/PublicIcon.tsx create mode 100644 src/icons/RenameIcon.tsx create mode 100644 src/icons/RestoreIcon.tsx create mode 100644 src/icons/SearchIcon.tsx create mode 100644 src/icons/SettingsIcon.tsx create mode 100644 src/icons/SettingsIconSolid.tsx create mode 100644 src/icons/ShareIcon.tsx create mode 100644 src/icons/SpacerIcon.tsx create mode 100644 src/icons/StorageIcon.tsx create mode 100644 src/icons/TrashIcon.tsx create mode 100644 src/icons/TuneIcon.tsx create mode 100644 src/icons/UploadFileIcon.tsx create mode 100644 src/popups/file.ts create mode 100644 src/popups/folder.ts create mode 100644 src/popups/user.ts create mode 100644 src/providers/AuthProvider.js create mode 100644 src/reducers/filter.ts create mode 100644 src/reducers/general.ts create mode 100644 src/reducers/leftSection.ts create mode 100644 src/reducers/selected.ts create mode 100644 src/reducers/uploader.ts create mode 100644 src/reducers/user.ts create mode 100644 src/routers/AppRouter.jsx create mode 100644 src/routers/PrivateRoute.jsx create mode 100644 src/routers/PublicRoute.jsx create mode 100644 src/store/configureStore.ts create mode 100644 src/styles/base/_base.scss create mode 100644 src/styles/components/_Spinner.scss create mode 100644 src/styles/components/_Swal.scss create mode 100644 src/styles/styles.scss create mode 100644 src/types/file.ts create mode 100644 src/types/folders.ts create mode 100644 src/types/user.ts create mode 100644 src/utils/InternalServerError.js create mode 100644 src/utils/NotAuthorizedError.js create mode 100644 src/utils/NotFoundError.js create mode 100644 src/utils/PWAUtils.ts create mode 100644 src/utils/cancelTokenManager.ts create mode 100644 src/utils/capitalize.ts create mode 100644 src/utils/convertDriveListToMongoList.js create mode 100644 src/utils/convertDriveToMongo.js create mode 100644 src/utils/createError.js create mode 100644 src/utils/createQuery.js create mode 100644 src/utils/files.ts create mode 100644 src/utils/getBackendURL.ts create mode 100644 src/utils/imageChecker.js create mode 100644 src/utils/mobileCheck.ts create mode 100644 src/utils/reduceQuickItemList.js create mode 100644 src/utils/sortBySwitch.js create mode 100644 src/utils/sortBySwitchFolder.js create mode 100644 src/utils/updateSettings.js create mode 100644 src/utils/videoChecker.js create mode 100644 tailwind.config.js create mode 100644 tests/controller/file-controller.test.js create mode 100644 tests/controller/folder.controller.test.js create mode 100644 tests/controller/user-controller.test.js create mode 100644 tests/utils/db-setup.js create mode 100644 tests/utils/express-app.js create mode 100644 tests/utils/fileUtils.js create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..2d81d7e --- /dev/null +++ b/.babelrc @@ -0,0 +1,5 @@ + +{ + "presets": ["@babel/env", "@babel/react"], + "plugins": ["@babel/plugin-proposal-class-properties", "@babel/plugin-proposal-object-rest-spread"] +} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..db1431a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +.git/ +.gitignore +.dockerignore +docker-compose* +Dockerfile +makefile +htmlcov/ +coverage.xml +.coverage* + +.vscode/ +*.dat + +.DS_Store +node_modules +/build +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.idea \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..428ea2e --- /dev/null +++ b/.env.example @@ -0,0 +1,108 @@ +# If you are using Docker, set DOCKER=true +DOCKER=true + +# MongoDB URL: Connection string for your MongoDB database +# Note: if using the compose file provided, the connection string should be as follows: +MONGODB_URL=mongodb+srv://mohammadnahas484:Mohammad.93MongoDb@cluster0.olmiwzg.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0 + +# Database Type: Choose between "fs" and "s3", this specifies where the files will be stored. +# fs = Filesystem +# s3 = Amazon S3 +DB_TYPE=s3 + +# If using fs, +# File Storage Directory: The directory where the files will be stored. Must be exact path. +# PATH MUST END IN A SLASH +# Example: /home/kyle/mydrive/ (must end in a slash) +FS_DIRECTORY=/data/ + +# If using s3, +# S3 Data: The S3 bucket and key where the files will be stored. +S3_ID=28a0a4b5f6edd188aea87bfad57f99ab +S3_KEY=b28171e5c2f4c83f033ab055fe9a4527 +S3_BUCKET=mydrive + + + + +{ + "access_key_id": "28a0a4b5f6edd188aea87bfad57f99ab", + "secret_access_key": "b28171e5c2f4c83f033ab055fe9a4527", + "created_at": 1762780645, + "status": "running", + "url": "https://s3-16cd54cc.hosted.cumin.dev" +} + +# Encryiption Key (optional): The encryption key used to encrypt the files. +# DO NOT LOSE OR FORGET THIS KEY AS ALL DATA WILL BE LOST IF YOU LOSE IT. +# If you do not supply a key, the app will instead prompt you to type one into the terminal when you start the server. +KEY=encryptionkey + +# Access tokens, refresh, and cookie +# These should be randomly generated in a secure manner. +# If you lose these tokens, all users will be logged out. +# You can also change these if you want to force all users to be logged out. +# Each token should be a different string. +# Example: sa4hQqJwGFLC1LJk59 +PASSWORD_ACCESS=secretaccesspassword +PASSWORD_REFRESH=secretrefreshpassword +PASSWORD_COOKIE=secretcookiepassword + +# Video thumbnails (optional): If you want to enable video thumbnails, configure as so. +# Video thumbnail generation relies on ffmpeg, please ensure you have it installed. +# VIDEO_THUMBNAILS_ENABLED=true +VIDEO_THUMBNAILS_ENABLED=true + +# Video thumbnails continued (optional): +# Sometimes generating a video thumbnail will fail with the default method. +# If so you can choose to instead temporarily store the video in a directory, and generate a thumbnail from that. +# WARNING: The file will be temporarily stored in this directory UNENCRYPTED. +# Temp directory example: /Users/kyle/mydrive/temp/ (must end in a slash) +# Temp video thumbnail limit: The maximum size of a video thumbnail in bytes. +# Example: 5000000000 +TEMP_DIRECTORY=/temp/ +TEMP_VIDEO_THUMBNAIL_LIMIT=5000000000 + +# Block account creation (optional): If you want to block account creation, configure as so, but after you create an account. +# BLOCK_CREATE_ACCOUNT=true + +# Ports (optional): The ports to run the server on. +# HTTP_PORT: Default port is 3000 +# HTTPS_PORT: Default port is 8080 +HTTP_PORT= +HTTPS_PORT= + +# URL (optional): The URL to run the server on. +# Most likely not needed, this changes the ip address/url express listens on. +URL= + +# Email verifcation (optional): If you want to enable email verification configure as so. +# EMAIL_VERIFICATION=true +# Remote URL: This refers to the URL sent in the verification email: Example https://mydrive-storage.com +# Please navigate to the following link to verify your email address: {REMOTE_URL}/verify +# Should NOT end with a slash +EMAIL_VERIFICATION= +EMAIL_DOMAIN= +EMAIL_ADDRESS= +EMAIL_API_KEY= +EMAIL_HOST= +REMOTE_URL= + +# Marks cookie generation as secure (Optional) +# This is recommended and should be enabled if you are running the app on HTTPS. +# SECURE_COOKIES=true +SECURE_COOKIES= + + +# SSL (Optional): If you want to enable SSL, configure as so. +# SSL=true +# Place your SSL certificate files in the root directory of the project +# With the names: certificate.crt, certificate.key, and certificate.ca-bundle; +SSL= + +# HTTPS cert paths (optional): If you need to change the paths of the https certs +# You can do so with these env variables. +# By default myDrive looks for certificate.crt, certificate.ca-bundle and certificate.key on the root of the project +HTTPS_KEY_PATH= +HTTPS_CA_PATH= +HTTPS_CRT_PATH= \ No newline at end of file diff --git a/.github/workflows/docker-build-dev.yml b/.github/workflows/docker-build-dev.yml new file mode 100644 index 0000000..e2cdb10 --- /dev/null +++ b/.github/workflows/docker-build-dev.yml @@ -0,0 +1,67 @@ +name: Docker Build and Push (Development) + +on: + push: + branches: + - master + +jobs: + build-and-push-dev: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + security-events: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Docker Setup QEMU + uses: docker/setup-qemu-action@v3 + id: qemu + with: + platforms: amd64,arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log into ghcr.io registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Build Docker Metadata + id: docker-metadata + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/subnub/mydrive + docker.io/kylehoell/mydrive + flavor: | + latest=auto + tags: | + type=ref,event=branch + type=sha,commit=${{ github.sha }} + type=raw,value=dev,enable={{is_default_branch}} + + - name: Push Service Image to repo + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + provenance: mode=max + tags: ${{ steps.docker-metadata.outputs.tags }} + labels: ${{ steps.docker-metadata.outputs.labels }} + platforms: linux/amd64,linux/arm64 + cache-from: type=gha,scope=${{ github.workflow }} + cache-to: type=gha,mode=max,scope=${{ github.workflow }} diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..7ee7868 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,69 @@ +name: Docker Build and Push (Production) + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + security-events: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Docker Setup QEMU + uses: docker/setup-qemu-action@v3 + id: qemu + with: + platforms: amd64,arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log into ghcr.io registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Build Docker Metadata + id: docker-metadata + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/subnub/mydrive + docker.io/kylehoell/mydrive + flavor: | + latest=auto + tags: | + type=ref,event=tag + type=sha,commit=${{ github.sha }} + type=semver,pattern={{version}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Push Service Image to repo + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + provenance: mode=max + tags: ${{ steps.docker-metadata.outputs.tags }} + labels: ${{ steps.docker-metadata.outputs.labels }} + platforms: linux/amd64,linux/arm64 + cache-from: type=gha,scope=${{ github.workflow }} + cache-to: type=gha,mode=max,scope=${{ github.workflow }} diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..ebcf4b1 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,20 @@ +name: 'Release Please' + +on: + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + release-type: node \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9179c2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +node_modules/ +public/dist/ +config/dev.env +config/prod.env +config/test.env +config/.env.development +config/.env.test +config/.env.production +config/.env.test +.env +.env.development +.env.test +.env.production +.well-known/ +.eslintrc.js +.eslintrc.json +changeEncrytionPassword/ +certificate.ca-bundle +certificate.crt +certificate.key +package-lock.json +._* +.DS_Store +dist/ +rds-combined-ca-bundle.pem +docker-variables.env +dist-frontend/ +dist-backend/ +stats.html +data/ +github_images/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..100a68a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## 4.0.2 (2025-03-01) + + +### Features + +* add docker ([#77](https://github.com/subnub/myDrive/issues/77)) ([22939cf](https://github.com/subnub/myDrive/commit/22939cf21dc2df8281c588206098f4aaf5472b19)) + + +### Miscellaneous Chores + +* release 4.0.2 ([c145b75](https://github.com/subnub/myDrive/commit/c145b7526b185b57214a946858fcff41ccd67d9e)) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..02a7baa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM node:20-alpine AS builder + +# Install build dependencies +RUN apk add --no-cache python3 make g++ ffmpeg && \ + ln -sf python3 /usr/bin/python + +WORKDIR /usr/app-production +COPY package*.json ./ + +RUN npm install + +COPY . . +RUN npm run build + +# Remove dev dependencies +RUN npm prune --production + +FROM node:20-alpine + +ENV FS_DIRECTORY=/data/ +ENV TEMP_DIRECTORY=/temp/ + +# Install runtime dependencies +RUN apk add --no-cache ffmpeg + +WORKDIR /usr/app-production +COPY --from=builder /usr/app-production . + +EXPOSE 8080 +EXPOSE 3000 + +CMD ["npm", "run", "start"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc383a3 --- /dev/null +++ b/README.md @@ -0,0 +1,319 @@ +# ![MyDrive Homepage](https://github.com/subnub/myDrive/blob/master/github_images/homepage.png?raw=true) + + +
+ GitHub Repo stars + Issues + License + Contributors +
+ +
+

☁️ MyDrive

+ Open Source cloud file storage server (Similar To Google Drive) +

Host myDrive on your own server or trusted platform and then access myDrive through your web browser. MyDrive uses mongoDB to store file/folder metadata, and supports multiple databases to store the file chunks, such as Amazon S3, or the Filesystem.

+ + Website + | + Live demo +
+ +## 🔍 Index + +- [Features](#features) +- [Tech stack](#tech-stack) +- [Running](#running) + - [Docker](#docker) + - [Non-Docker](#non-docker) +- [Common installation issues](#common-installation-issues) +- [Screenshots](#screenshots) +- [Video](#video) +- [Live demo](#live-demo) +- [Feature requests/bug reports](#bugs) +- [Updating from a previous version of myDrive](#updating) +- [Known issues and future improvments](#known-issues) + + + +## ⭐️ Features + +- Upload Files +- Download Files +- Upload Folders +- Download Folders (Automatically converts to zip) +- Multiple DB Support (Amazon S3, Filesystem) +- Photo, Video Viewer and Media Gallery +- Generated Photo And Video Thumbnails +- File Sharing +- PWA Support +- AES256 Encryption +- Service Worker +- Mobile Support +- Docker +- Email Verification +- JWT (Access and Refresh Tokens) + + + +## 👨‍🔬 Tech Stack + +- React +- Typescript +- Node.js +- Express +- MongoDB +- Vite +- Jest + + + +## Running + + + +### 🐳 Docker + +> [!IMPORTANT] +> Requirements +> - Docker +> - MongoDB (optional, comes with `docker-compose.yml`) + +#### **Docker Compose** + +1. Make folder for docker-compose.yml and env file. +2. Copy [`docker-compose.yml`](./docker-compose.yml) and [`.env.example`](./.env.example) to your directory. +3. Rename `.env.example` to `.env` and fill in / change the values. +4. Run the following command: + +```sh +docker compose up -d +``` +5. Access the app at `http://localhost:3000` + +--- + +#### **Docker Run** + +1. Pull the image + +```sh +docker pull kylehoell/mydrive:latest +``` + +2. Run the image + +Using `.env` file. Copy the `.env.example` file and fill in the values. + +```sh +docker run -d \ + -p 3000:3000 \ + --env-file ./.env \ + -v /path/example/mydrive/data/:/data/ \ + -v /path/example/mydrive/temp/:/temp/ \ + --name mydrive \ + kylehoell/mydrive:latest +``` + +Or directly pass in the environment variables + +```sh +docker run -d \ + -p 3000:3000 \ + -e MONGODB_URL=mongodb://127.0.0.1:27017/mydrive \ + -e DB_TYPE=fs \ + -e PASSWORD_ACCESS=secretaccesspassword \ + -e PASSWORD_REFRESH=secretrefreshpassword \ + -e PASSWORD_COOKIE=secretcookiepassword \ + -e KEY=encryptionkey \ + -e VIDEO_THUMBNAILS_ENABLED=true \ + -e TEMP_VIDEO_THUMBNAIL_LIMIT=5000000000 \ + -v /path/example/mydrive/data/:/data/ \ + -v /path/example/mydrive/temp/:/temp/ \ + --name mydrive \ + kylehoell/mydrive:latest +``` + +3. Access the app at `http://localhost:3000` + + + +### 💻 Non - Docker + +> [!IMPORTANT] +> Requirements +> - Node.js (20 Recommended) +> - MongoDB (Unless using a service like Atlas) +> - FFMPEG (Optional, used for video thumbnails) +> - build-essential package (If using linux) + +1. Install dependencies + +```sh +npm install +``` + +2. Create Environment Variables + +You can find enviroment variable examples under:
+[`backend/config`](backend/config) -> Backend Enviroment Variables +[`src/config`](src/config) -> Frontend Enviroment Variables + +Simply remove the .example from the end of the filename, and fill in the values. +> Note: In most cases you will only have to change FE enviroment variables for development purposes. + +3. Run the build command + +```sh +npm run build +``` + +4. Start the server + +```sh +npm run start +``` + + + +#### Possible installation issues + +Make issue + +```sh +npm error gyp ERR! stack Error: not found: make +``` + +This is because you do not have the build essentials installed which is required for Linux. You can install them by running the following command: + +```sh +sudo apt-get install build-essential +``` + +Memory issue + +```sh +Aborted (core dumped) +``` + +When running the `npm run build` command it may take more memory than node allows by default. You will get the above error in such a case. To fix this, you can run the following command instead when building: + +```sh +NODE_OPTIONS="--max-old-space-size=4096" npm run build +``` + +You can read more about this issue [here](https://stackoverflow.com/questions/38558989/node-js-heap-out-of-memory). + + + + + +## 📸 Screenshots + +Modern and colorful design +![MyDrive Design](https://github.com/subnub/myDrive/blob/master/github_images/homepage.png?raw=true) + +Upload Files +![MyDrive Upload](https://github.com/subnub/myDrive/blob/master/github_images/upload.png?raw=true) + +Download Files +![MyDrive Upload](https://github.com/subnub/myDrive/blob/master/github_images/download.png?raw=true) + +Image Viewer +![Image Viewer](https://github.com/subnub/myDrive/blob/master/github_images/image-viewer.png?raw=true) + +Video Viewer +![Video Viewer](https://github.com/subnub/myDrive/blob/master/github_images/video-viewer.png?raw=true) + +Media Gallery +![Search](https://github.com/subnub/myDrive/blob/master/github_images/media-viewer.png?raw=true) + +Share Files +![Share](https://github.com/subnub/myDrive/blob/master/github_images/share.png?raw=true) + +Search For Files/Folders +![Search](https://github.com/subnub/myDrive/blob/master/github_images/search.png?raw=true) + +Move File/Folders +![Move](https://github.com/subnub/myDrive/blob/master/github_images/move.png?raw=true) + +Multi-select +![Multi-select](https://github.com/subnub/myDrive/blob/master/github_images/multiselect.png?raw=true) + +Custom context menu +![Context menu](https://github.com/subnub/myDrive/blob/master/github_images/context.png?raw=true) + +Trash +![Trash](https://github.com/subnub/myDrive/blob/master/github_images/trash.png?raw=true) + + + +## 🎥 Video + +I created a short YouTube video, showing off myDrives design and features: + +[![myDrive 4 (open source Google Drive alternative) - UI and feature overview +](https://github.com/subnub/myDrive/blob/master/github_images/youtube-video.jpeg?raw=true)](https://www.youtube.com/watch?v=IqmTvAFBszg "myDrive 4 (open source Google Drive alternative) - UI and feature overview +") + + + +## 🕹️ Live demo + +[Demo](http://143.244.181.219:3000/) + +Note: Creating, deleting and other features are disabled in the demo. Also the service worker is not enabled in the demo, images thumbnails are not cached because of this. + +Also this is just a 512mb RAM droplet go easy on her. + + + +## 👾 Bug reports and feature requests + +Please only open issues for actual bugs, feature requests or discussions should happen in Discussions or via my email. + +Contact Email: kyle.hoell@gmail.com + + + +## ⬆️ Updating from a previous version of myDrive + +If you are upgrading from myDrive 3 there is some data migration and scripts you must run for myDrive 4 to work properly. + +> Run the migration script
+> Note: Make sure you have env variables set + +```sh +npm run migrate-to-mydrive4 +``` + +Also, if you are updating from myDrive 3, or if you did not have video thumbnails enabled and would like to enable them now you can do so by running the following command:
+Note: Make sure you have video thumbnails enabled in your env variables and FFMPEG installed. + +```sh +npm run create-video-thumbnails +``` + + + +## 🔮 Known issues and future improvments + +#### Issues + +- Video streaming does not always work, especially on Safari. +- PWA downloads does not work on iOS (This may be a current iOS limitation and not a myDrive issue). +- Upload folder will sometimes fail on complex folder structures. +- Generating video thumbnails with the default method will often fail, requiring the whole file to be downloaded to temporary storage and then the thumbnail generated from that. + +#### Future improvments + +- OIDC Support (Top priority) +- Option to disable encryption +- File sync from a local device +- An alternative to using mongoDB +- Dark mode +- Enhance service worker, currently only caches thumbnails. This includes potentially adding offline support. +- Typescript type cleanup +- Better error handling +- Logging +- More test coverage (currently only basic backend tests) +- Some tailwind classes still need some slight tweaking diff --git a/backend/config/.env.development.example b/backend/config/.env.development.example new file mode 100644 index 0000000..b5a32fe --- /dev/null +++ b/backend/config/.env.development.example @@ -0,0 +1,96 @@ +# Either remove the .example from the end of this filename. +# Or create a new file with the same name, but without the .example extension. + +# MongoDB URL: Connection string for your MongoDB database +# example: mongodb://localhost:27017/mydrive +MONGODB_URL= + +# Database Type: Choose between "fs" and "s3", this specifies where the files will be stored. +# fs = Filesystem +# s3 = Amazon S3 +DB_TYPE= + +# If using fs, +# File Storage Directory: The directory where the files will be stored. Must be exact path. +# PATH MUST END IN A SLASH +# Example: /Users/kyle/mydrive/ +FS_DIRECTORY= + +# If using s3, +# S3 Data: The S3 bucket and key where the files will be stored. +S3_ID= +S3_KEY= +S3_BUCKET= + +# Encryiption Key (optional): The encryption key used to encrypt the files. +# DO NOT LOSE OR FORGET THIS KEY AS ALL DATA WILL BE LOST IF YOU LOSE IT. +# If you do not supply a key, the app will instead prompt you to type one into the terminal when you start the server. +KEY= + +# Ports (optional): The ports to run the server on. +# HTTP_PORT: Default port is 3000 +# HTTPS_PORT: Default port is 8080 +HTTP_PORT= +HTTPS_PORT= + +# URL (optional): The URL to run the server on. +# Most likely not needed, this changes the ip address/url express listens on. +URL= + +# Email verifcation (optional): If you want to enable email verification configure as so. +# EMAIL_VERIFICATION=true +# Remote URL: This refers to the URL sent in the verification email: Example https://mydrive-storage.com +# Please navigate to the following link to verify your email address: {REMOTE_URL}/verify +# Should NOT end with a slash +EMAIL_VERIFICATION= +EMAIL_DOMAIN= +EMAIL_ADDRESS= +EMAIL_API_KEY= +EMAIL_HOST= +REMOTE_URL= + +# Access tokens, refresh, and cookie +# These should be randomly generated in a secure manner. +# If you lose these tokens, all users will be logged out. +# You can also change these if you want to force all users to be logged out. +# Each token should be a different string. +# Example: sa4hQqJwGFLC1LJk59 +PASSWORD_ACCESS= +PASSWORD_REFRESH= +PASSWORD_COOKIE= + +# Video thumbnails (optional): If you want to enable video thumbnails, configure as so. +# Video thumbnail generation relies on ffmpeg, please ensure you have it installed. +# VIDEO_THUMBNAILS_ENABLED=true +VIDEO_THUMBNAILS_ENABLED= + +# Video thumbnails continued (optional): +# Sometimes generating a video thumbnail will fail with the default method. +# If so you can choose to instead temporarily store the video in a directory, and generate a thumbnail from that. +# WARNING: The file will be temporarily stored in this directory UNENCRYPTED. +# Temp directory example: /Users/kyle/mydrive/temp/ +# Temp video thumbnail limit: The maximum size of a video thumbnail in bytes. +# Example: 5000000000 +TEMP_DIRECTORY= +TEMP_VIDEO_THUMBNAIL_LIMIT= + +# Marks cookie generation as secure (Optional) +# This is recommended and should be enabled if you are running the app on HTTPS. +# SECURE_COOKIES=true +SECURE_COOKIES= + +# SSL (Optional): If you want to enable SSL, configure as so. +# SSL=true +# Place your SSL certificate files in the root directory of the project +# With the names: certificate.crt, certificate.key, and certificate.ca-bundle; +SSL= + +# HTTPS cert paths (optional): If you need to change the paths of the https certs +# You can do so with these env variables. +# By default myDrive looks for certificate.crt, certificate.ca-bundle and certificate.key on the root of the project +HTTPS_KEY_PATH= +HTTPS_CA_PATH= +HTTPS_CRT_PATH= + +# Block account creation (optional): If you want to block account creation, configure as so, but after you create an account. +# BLOCK_CREATE_ACCOUNT=true \ No newline at end of file diff --git a/backend/config/.env.production.example b/backend/config/.env.production.example new file mode 100644 index 0000000..b5a32fe --- /dev/null +++ b/backend/config/.env.production.example @@ -0,0 +1,96 @@ +# Either remove the .example from the end of this filename. +# Or create a new file with the same name, but without the .example extension. + +# MongoDB URL: Connection string for your MongoDB database +# example: mongodb://localhost:27017/mydrive +MONGODB_URL= + +# Database Type: Choose between "fs" and "s3", this specifies where the files will be stored. +# fs = Filesystem +# s3 = Amazon S3 +DB_TYPE= + +# If using fs, +# File Storage Directory: The directory where the files will be stored. Must be exact path. +# PATH MUST END IN A SLASH +# Example: /Users/kyle/mydrive/ +FS_DIRECTORY= + +# If using s3, +# S3 Data: The S3 bucket and key where the files will be stored. +S3_ID= +S3_KEY= +S3_BUCKET= + +# Encryiption Key (optional): The encryption key used to encrypt the files. +# DO NOT LOSE OR FORGET THIS KEY AS ALL DATA WILL BE LOST IF YOU LOSE IT. +# If you do not supply a key, the app will instead prompt you to type one into the terminal when you start the server. +KEY= + +# Ports (optional): The ports to run the server on. +# HTTP_PORT: Default port is 3000 +# HTTPS_PORT: Default port is 8080 +HTTP_PORT= +HTTPS_PORT= + +# URL (optional): The URL to run the server on. +# Most likely not needed, this changes the ip address/url express listens on. +URL= + +# Email verifcation (optional): If you want to enable email verification configure as so. +# EMAIL_VERIFICATION=true +# Remote URL: This refers to the URL sent in the verification email: Example https://mydrive-storage.com +# Please navigate to the following link to verify your email address: {REMOTE_URL}/verify +# Should NOT end with a slash +EMAIL_VERIFICATION= +EMAIL_DOMAIN= +EMAIL_ADDRESS= +EMAIL_API_KEY= +EMAIL_HOST= +REMOTE_URL= + +# Access tokens, refresh, and cookie +# These should be randomly generated in a secure manner. +# If you lose these tokens, all users will be logged out. +# You can also change these if you want to force all users to be logged out. +# Each token should be a different string. +# Example: sa4hQqJwGFLC1LJk59 +PASSWORD_ACCESS= +PASSWORD_REFRESH= +PASSWORD_COOKIE= + +# Video thumbnails (optional): If you want to enable video thumbnails, configure as so. +# Video thumbnail generation relies on ffmpeg, please ensure you have it installed. +# VIDEO_THUMBNAILS_ENABLED=true +VIDEO_THUMBNAILS_ENABLED= + +# Video thumbnails continued (optional): +# Sometimes generating a video thumbnail will fail with the default method. +# If so you can choose to instead temporarily store the video in a directory, and generate a thumbnail from that. +# WARNING: The file will be temporarily stored in this directory UNENCRYPTED. +# Temp directory example: /Users/kyle/mydrive/temp/ +# Temp video thumbnail limit: The maximum size of a video thumbnail in bytes. +# Example: 5000000000 +TEMP_DIRECTORY= +TEMP_VIDEO_THUMBNAIL_LIMIT= + +# Marks cookie generation as secure (Optional) +# This is recommended and should be enabled if you are running the app on HTTPS. +# SECURE_COOKIES=true +SECURE_COOKIES= + +# SSL (Optional): If you want to enable SSL, configure as so. +# SSL=true +# Place your SSL certificate files in the root directory of the project +# With the names: certificate.crt, certificate.key, and certificate.ca-bundle; +SSL= + +# HTTPS cert paths (optional): If you need to change the paths of the https certs +# You can do so with these env variables. +# By default myDrive looks for certificate.crt, certificate.ca-bundle and certificate.key on the root of the project +HTTPS_KEY_PATH= +HTTPS_CA_PATH= +HTTPS_CRT_PATH= + +# Block account creation (optional): If you want to block account creation, configure as so, but after you create an account. +# BLOCK_CREATE_ACCOUNT=true \ No newline at end of file diff --git a/backend/config/.env.test.example b/backend/config/.env.test.example new file mode 100644 index 0000000..b5a32fe --- /dev/null +++ b/backend/config/.env.test.example @@ -0,0 +1,96 @@ +# Either remove the .example from the end of this filename. +# Or create a new file with the same name, but without the .example extension. + +# MongoDB URL: Connection string for your MongoDB database +# example: mongodb://localhost:27017/mydrive +MONGODB_URL= + +# Database Type: Choose between "fs" and "s3", this specifies where the files will be stored. +# fs = Filesystem +# s3 = Amazon S3 +DB_TYPE= + +# If using fs, +# File Storage Directory: The directory where the files will be stored. Must be exact path. +# PATH MUST END IN A SLASH +# Example: /Users/kyle/mydrive/ +FS_DIRECTORY= + +# If using s3, +# S3 Data: The S3 bucket and key where the files will be stored. +S3_ID= +S3_KEY= +S3_BUCKET= + +# Encryiption Key (optional): The encryption key used to encrypt the files. +# DO NOT LOSE OR FORGET THIS KEY AS ALL DATA WILL BE LOST IF YOU LOSE IT. +# If you do not supply a key, the app will instead prompt you to type one into the terminal when you start the server. +KEY= + +# Ports (optional): The ports to run the server on. +# HTTP_PORT: Default port is 3000 +# HTTPS_PORT: Default port is 8080 +HTTP_PORT= +HTTPS_PORT= + +# URL (optional): The URL to run the server on. +# Most likely not needed, this changes the ip address/url express listens on. +URL= + +# Email verifcation (optional): If you want to enable email verification configure as so. +# EMAIL_VERIFICATION=true +# Remote URL: This refers to the URL sent in the verification email: Example https://mydrive-storage.com +# Please navigate to the following link to verify your email address: {REMOTE_URL}/verify +# Should NOT end with a slash +EMAIL_VERIFICATION= +EMAIL_DOMAIN= +EMAIL_ADDRESS= +EMAIL_API_KEY= +EMAIL_HOST= +REMOTE_URL= + +# Access tokens, refresh, and cookie +# These should be randomly generated in a secure manner. +# If you lose these tokens, all users will be logged out. +# You can also change these if you want to force all users to be logged out. +# Each token should be a different string. +# Example: sa4hQqJwGFLC1LJk59 +PASSWORD_ACCESS= +PASSWORD_REFRESH= +PASSWORD_COOKIE= + +# Video thumbnails (optional): If you want to enable video thumbnails, configure as so. +# Video thumbnail generation relies on ffmpeg, please ensure you have it installed. +# VIDEO_THUMBNAILS_ENABLED=true +VIDEO_THUMBNAILS_ENABLED= + +# Video thumbnails continued (optional): +# Sometimes generating a video thumbnail will fail with the default method. +# If so you can choose to instead temporarily store the video in a directory, and generate a thumbnail from that. +# WARNING: The file will be temporarily stored in this directory UNENCRYPTED. +# Temp directory example: /Users/kyle/mydrive/temp/ +# Temp video thumbnail limit: The maximum size of a video thumbnail in bytes. +# Example: 5000000000 +TEMP_DIRECTORY= +TEMP_VIDEO_THUMBNAIL_LIMIT= + +# Marks cookie generation as secure (Optional) +# This is recommended and should be enabled if you are running the app on HTTPS. +# SECURE_COOKIES=true +SECURE_COOKIES= + +# SSL (Optional): If you want to enable SSL, configure as so. +# SSL=true +# Place your SSL certificate files in the root directory of the project +# With the names: certificate.crt, certificate.key, and certificate.ca-bundle; +SSL= + +# HTTPS cert paths (optional): If you need to change the paths of the https certs +# You can do so with these env variables. +# By default myDrive looks for certificate.crt, certificate.ca-bundle and certificate.key on the root of the project +HTTPS_KEY_PATH= +HTTPS_CA_PATH= +HTTPS_CRT_PATH= + +# Block account creation (optional): If you want to block account creation, configure as so, but after you create an account. +# BLOCK_CREATE_ACCOUNT=true \ No newline at end of file diff --git a/backend/controllers/file-controller.ts b/backend/controllers/file-controller.ts new file mode 100644 index 0000000..5edbc86 --- /dev/null +++ b/backend/controllers/file-controller.ts @@ -0,0 +1,633 @@ +import { NextFunction, Request, Response } from "express"; +import FileService from "../services/file-service/file-service"; +import User, { UserInterface } from "../models/user-model"; +import { + createStreamVideoCookie, + removeStreamVideoCookie, +} from "../cookies/create-cookies"; +import ChunkService from "../services/chunk-service/chunk-service"; +import streamToBuffer from "../utils/streamToBuffer"; +import NotAuthorizedError from "../utils/NotAuthorizedError"; +import { FileListQueryType } from "../types/file-types"; +import fs from "fs"; + +const fileService = new FileService(); +type userAccessType = { + _id: string; + emailVerified: boolean; + email: string; + s3Enabled: boolean; +}; + +export interface RequestTypeFullUser extends Request { + user?: UserInterface; + encryptedToken?: string; + accessTokenStreamVideo?: string; +} + +interface RequestType extends Request { + user?: userAccessType; + encryptedToken?: string; +} + +class FileController { + chunkService; + + constructor() { + this.chunkService = new ChunkService(); + } + + getThumbnail = async ( + req: RequestTypeFullUser, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + try { + const user = req.user; + const id = req.params.id; + + await this.chunkService.getThumbnail(user, id, res); + } catch (e: unknown) { + next(e); + } + }; + + getFullThumbnail = async ( + req: RequestTypeFullUser, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + try { + const user = req.user; + const fileID = req.params.id; + + await this.chunkService.getFullThumbnail(user, fileID, res); + } catch (e: unknown) { + next(e); + } + }; + + uploadFile = async ( + req: RequestTypeFullUser, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const user = req.user; + const busboy = req.busboy; + + const file = await this.chunkService.uploadFile(user, busboy, req); + + res.send(file); + } catch (e: unknown) { + next(e); + } + }; + + getPublicDownload = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + try { + const ID = req.params.id; + const tempToken = req.params.tempToken; + + await this.chunkService.getPublicDownload(ID, tempToken, res); + } catch (e: unknown) { + next(e); + } + }; + + removeLink = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) { + return; + } + + try { + const id = req.params.id; + const userID = req.user._id; + + const file = await fileService.removeLink(userID, id); + + res.send(file); + } catch (e) { + next(e); + } + }; + + makePublic = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) { + return; + } + + try { + const fileID = req.params.id; + const userID = req.user._id; + + const { file, token } = await fileService.makePublic(userID, fileID); + + res.send({ file, token }); + } catch (e) { + next(e); + } + }; + + getPublicInfo = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + try { + const id = req.params.id; + const tempToken = req.params.tempToken; + + const file = await fileService.getPublicInfo(id, tempToken); + + res.send(file); + } catch (e) { + next(e); + } + }; + + makeOneTimePublic = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const id = req.params.id; + const userID = req.user._id; + + const { file, token } = await fileService.makeOneTimePublic(userID, id); + + res.send({ file, token }); + } catch (e) { + next(e); + } + }; + + getFileInfo = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) { + return; + } + + try { + const fileID = req.params.id; + const userID = req.user._id; + + const file = await fileService.getFileInfo(userID, fileID); + + res.send(file); + } catch (e) { + next(e); + } + }; + + getQuickList = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const user = req.user; + const limit = Number.parseInt(req.query.limit as string) || 20; + + const quickList = await fileService.getQuickList(user, limit); + + res.send(quickList); + } catch (e) { + next(e); + } + }; + + getList = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const query = req.query; + + const search = (query.search as string) || undefined; + const parent = (query.parent as string) || "/"; + const limit = Number.parseInt(query.limit as string) || 50; + const sortBy = (query.sortBy as string) || "date_desc"; + const startAtDate = (query.startAtDate as string) || undefined; + const startAtName = (query.startAtName as string) || undefined; + const trashMode = query.trashMode === "true"; + const mediaMode = query.mediaMode === "true"; + const mediaFilter = (query.mediaFilter as string) || "all"; + + const queryData: FileListQueryType = { + userID, + search, + parent, + startAtDate, + startAtName, + trashMode, + mediaMode, + sortBy, + mediaFilter, + }; + + const fileList = await fileService.getList(queryData, sortBy, limit); + + res.send(fileList); + } catch (e) { + next(e); + } + }; + + getDownloadToken = async ( + req: RequestTypeFullUser, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const user = req.user; + + const tempToken = await fileService.getDownloadToken(user); + + res.send({ tempToken }); + } catch (e) { + next(e); + } + }; + + getAccessTokenStreamVideo = async ( + req: RequestTypeFullUser, + res: Response, + next: NextFunction + ) => { + if (!req.user) return; + + try { + const user = req.user; + + const currentUUID = req.headers.uuid as string; + + const streamVideoAccessToken = await user.generateAuthTokenStreamVideo( + currentUUID + ); + + createStreamVideoCookie(res, streamVideoAccessToken); + + res.send(); + } catch (e) { + next(e); + } + }; + + removeStreamVideoAccessToken = async ( + req: RequestTypeFullUser, + res: Response, + next: NextFunction + ) => { + if (!req.user) return; + + try { + const userID = req.user._id; + + const accessTokenStreamVideo = req.accessTokenStreamVideo!; + + if (!accessTokenStreamVideo) { + throw new NotAuthorizedError("No Access Token"); + } + + await User.updateOne( + { _id: userID }, + { $pull: { tempTokens: { token: accessTokenStreamVideo } } } + ); + + removeStreamVideoCookie(res); + + res.send(); + } catch (e) { + next(e); + } + }; + + removeTempToken = async ( + req: RequestTypeFullUser, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const user = req.user; + const tempToken = req.params.tempToken; + const currentUUID = req.params.uuid; + + await fileService.removeTempToken(user, tempToken, currentUUID); + + res.send(); + } catch (e) { + next(e); + } + }; + + streamVideo = async ( + req: RequestTypeFullUser, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const user = req.user; + const fileID = req.params.id; + const headers = req.headers; + + await this.chunkService.streamVideo(user, fileID, headers, res); + } catch (e: unknown) { + next(e); + } + }; + + streamVideoTest = async ( + req: RequestTypeFullUser, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const headers = req.headers; + console.log("headers", headers.range); + const fileSize = 26867866; + const range = headers.range!; + const parts = range.replace(/bytes=/, "").split("-"); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; + const chunksize = end - start + 1; + + const readStream = fs.createReadStream( + "/Users/kylehoell/Developer/myDrive-4/upgrade/old/video.mp4", + { start, end } + ); + + const head = { + "Content-Range": "bytes " + start + "-" + end + "/" + fileSize, + "Accept-Ranges": "bytes", + "Content-Length": chunksize, + "Content-Type": "video/mp4", + }; + + res.writeHead(206, head); + + readStream.on("data", (data) => { + res.write(data); + }); + + readStream.on("end", () => { + res.end(); + }); + } catch (e: unknown) { + next(e); + } + }; + + downloadFile = async ( + req: RequestTypeFullUser, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const user = req.user; + const fileID = req.params.id; + + await this.chunkService.downloadFile(user, fileID, res); + } catch (e: unknown) { + next(e); + } + }; + + getSuggestedList = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const searchQuery = req.query.search as string; + const trashMode = req.query.trashMode === "true"; + const mediaMode = req.query.mediaMode === "true"; + + const { fileList, folderList } = await fileService.getSuggestedList( + userID, + searchQuery, + trashMode, + mediaMode + ); + + return res.send({ folderList, fileList }); + } catch (e) { + next(e); + } + }; + + renameFile = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) { + return; + } + + try { + const fileID = req.body.id; + const title = req.body.title; + const userID = req.user._id; + + const file = await fileService.renameFile(userID, fileID, title); + + res.send(file); + } catch (e) { + next(e); + } + }; + + moveFile = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) { + return; + } + + try { + const fileID = req.body.id as string; + const userID = req.user._id as string; + const parentID = (req.body.parentID as string) || "/"; + + const file = await fileService.moveFile(userID, fileID, parentID); + + res.send(file); + } catch (e) { + next(e); + } + }; + + moveMultiFile = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const items = req.body.items; + const parentID = (req.body.parentID as string) || "/"; + + await fileService.moveMultiFiles(userID, items, parentID); + + res.send(); + } catch (e) { + next(e); + } + }; + + trashFile = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const fileID = req.body.id; + + const trashedFile = await fileService.trashFile(userID, fileID); + + res.send(trashedFile.toObject()); + } catch (e) { + next(e); + } + }; + + restoreFile = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const fileID = req.body.id; + + const file = await fileService.restoreFile(userID, fileID); + + res.send(file); + } catch (e) { + next(e); + } + }; + + deleteFile = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const fileID = req.body.id; + + await this.chunkService.deleteFile(userID, fileID); + + res.send(); + } catch (e) { + next(e); + } + }; + + deleteMulti = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const items = req.body.items; + + await this.chunkService.deleteMulti(userID, items); + + res.send(); + } catch (e) { + next(e); + } + }; + + trashMulti = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const items = req.body.items; + + await fileService.trashMulti(userID, items); + + res.send(); + } catch (e) { + next(e); + } + }; + + restoreMulti = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const items = req.body.items; + + await fileService.restoreMulti(userID, items); + + res.send(); + } catch (e) { + next(e); + } + }; +} + +export default FileController; diff --git a/backend/controllers/folder-controller.ts b/backend/controllers/folder-controller.ts new file mode 100644 index 0000000..9a2b8fe --- /dev/null +++ b/backend/controllers/folder-controller.ts @@ -0,0 +1,285 @@ +import FolderService from "../services/folder-service/folder-service"; +import { NextFunction, Request, Response } from "express"; +import ChunkService from "../services/chunk-service/chunk-service"; +import { FolderListQueryType } from "../types/folder-types"; +import { UserInterface } from "../models/user-model"; + +const folderService = new FolderService(); + +type userAccessType = { + _id: string; + emailVerified: boolean; + email: string; + s3Enabled: boolean; +}; + +interface RequestType extends Request { + user?: userAccessType; + encryptedToken?: string; +} + +interface RequestTypeFullUser extends Request { + user?: UserInterface; + encryptedToken?: string; + accessTokenStreamVideo?: string; +} + +const chunkService = new ChunkService(); + +class FolderController { + constructor() {} + + createFolder = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const name = req.body.name; + const parent = req.body.parent || "/"; + + const folder = await folderService.createFolder(userID, name, parent); + + res.status(201).send(folder); + } catch (e) { + next(e); + } + }; + + deleteFolder = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const folderID = req.body.id; + + await chunkService.deleteFolder(userID, folderID); + + res.send(); + } catch (e) { + next(e); + } + }; + + uploadFolder = async ( + req: RequestTypeFullUser, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const user = req.user; + const busboy = req.busboy; + + await chunkService.uploadFolder(user, busboy, req); + + res.send(); + } catch (e) { + next(e); + } + }; + + deleteAll = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + + await chunkService.deleteAll(userID); + + res.send(); + } catch (e) { + next(e); + } + }; + + getInfo = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const folderID = req.params.id; + + const folder = await folderService.getFolderInfo(userID, folderID); + + res.send(folder); + } catch (e) { + next(e); + } + }; + + getFolderList = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const user = req.user; + const query = req.query; + + const search = (query.search as string) || undefined; + const parent = (query.parent as string) || "/"; + const sortBy = (query.sortBy as string) || "date_desc"; + const trashMode = query.trashMode === "true"; + + const queryData: FolderListQueryType = { + userID: user._id.toString(), + search, + parent, + trashMode, + }; + + const folderList = await folderService.getFolderList(queryData, sortBy); + + res.send(folderList); + } catch (e) { + next(e); + } + }; + + moveFolder = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const folderID = req.body.id; + const parentID = req.body.parentID; + + const folder = await folderService.moveFolder(userID, folderID, parentID); + + res.send(folder); + } catch (e) { + next(e); + } + }; + + trashFolder = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const folderID = req.body.id; + + await folderService.trashFolder(userID, folderID); + + res.send(); + } catch (e) { + next(e); + } + }; + + restoreFolder = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const folderID = req.body.id; + + await folderService.restoreFolder(userID, folderID); + + res.send(); + } catch (e) { + next(e); + } + }; + + renameFolder = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const folderID = req.body.id; + const title = req.body.title; + + await folderService.renameFolder(userID, folderID, title); + + res.send(); + } catch (e) { + next(e); + } + }; + + downloadZip = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const folderIDs = (req.query.folderIDs as string[]) || []; + const fileIDs = (req.query.fileIDs as string[]) || []; + + await chunkService.downloadZip(userID, folderIDs, fileIDs, res); + } catch (e) { + next(e); + } + }; + + getMoveFolderList = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const parent = (req.query.parent as string) || undefined; + const search = (req.query.search as string) || undefined; + const folderIDs = (req.query.folderIDs as string[]) || []; + + const folderList = await folderService.getMoveFolderList( + userID, + parent, + search, + folderIDs + ); + + res.send(folderList); + } catch (e) { + next(e); + } + }; +} + +export default FolderController; diff --git a/backend/controllers/user-controller.ts b/backend/controllers/user-controller.ts new file mode 100644 index 0000000..4620355 --- /dev/null +++ b/backend/controllers/user-controller.ts @@ -0,0 +1,271 @@ +import env from "../enviroment/env"; +import UserService from "../services/user-service/user-service"; +import { NextFunction, Request, Response } from "express"; +import { UserInterface } from "../models/user-model"; +import { + createLoginCookie, + createLogoutCookie, +} from "../cookies/create-cookies"; +import NotFoundError from "../utils/NotFoundError"; +import InternalServerError from "../utils/InternalServerError"; + +const UserProvider = new UserService(); + +type userAccessType = { + _id: string; + emailVerified: boolean; + email: string; + admin: boolean; + botChecked: boolean; + username: string; +}; + +interface RequestTypeRefresh extends Request { + user?: UserInterface; + encryptedToken?: string; +} + +interface RequestType extends Request { + user?: userAccessType; + encryptedToken?: string; +} + +class UserController { + constructor() {} + + getUser = async (req: RequestType, res: Response, next: NextFunction) => { + try { + const user = req.user!; + + res.send(user); + } catch (e) { + next(e); + } + }; + + login = async (req: RequestType, res: Response, next: NextFunction) => { + try { + const body = req.body; + + const currentUUID = req.headers.uuid as string; + + const { user, accessToken, refreshToken } = await UserProvider.login( + body, + currentUUID + ); + + createLoginCookie(res, accessToken, refreshToken); + + res.status(200).send({ user }); + } catch (e) { + next(e); + } + }; + + getToken = async ( + req: RequestTypeRefresh, + res: Response, + next: NextFunction + ) => { + try { + const user = req.user; + + if (!user) throw new NotFoundError("User Not Found"); + + const currentUUID = req.headers.uuid as string; + + const { accessToken, refreshToken } = await user.generateAuthToken( + currentUUID + ); + + if (!accessToken || !refreshToken) { + throw new InternalServerError("User/Access/Refresh Token Missing"); + } + + createLoginCookie(res, accessToken, refreshToken); + + res.status(201).send(); + } catch (e) { + next(e); + } + }; + + logout = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const refreshToken = req.cookies["refresh-token"]; + + await UserProvider.logout(userID, refreshToken); + + createLogoutCookie(res); + + res.send(); + } catch (e) { + createLogoutCookie(res); + next(e); + } + }; + + logoutAll = async (req: RequestType, res: Response, next: NextFunction) => { + if (!req.user) return; + + try { + const userID = req.user._id; + + await UserProvider.logoutAll(userID); + + createLogoutCookie(res); + + res.send(); + } catch (e) { + next(e); + } + }; + + createUser = async (req: RequestType, res: Response, next: NextFunction) => { + try { + const currentUUID = req.headers.uuid as string; + + const { user, accessToken, refreshToken, emailSent } = + await UserProvider.create(req.body, currentUUID); + + createLoginCookie(res, accessToken, refreshToken); + + res.status(201).send({ user, emailSent }); + } catch (e) { + next(e); + } + }; + + changePassword = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const oldPassword = req.body.oldPassword; + const newPassword = req.body.newPassword; + const oldRefreshToken = req.cookies["refresh-token"]; + + const currentUUID = req.headers.uuid as string; + + const { accessToken, refreshToken } = await UserProvider.changePassword( + userID, + oldPassword, + newPassword, + oldRefreshToken, + currentUUID + ); + + createLoginCookie(res, accessToken, refreshToken); + + res.send(); + } catch (e) { + next(e); + } + }; + + getUserDetailed = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + + const userDetailed = await UserProvider.getUserDetailed(userID); + + res.send(userDetailed); + } catch (e) { + next(e); + } + }; + + verifyEmail = async (req: RequestType, res: Response, next: NextFunction) => { + try { + const verifyToken = req.body.emailToken; + + const currentUUID = req.headers.uuid as string; + + const user = await UserProvider.verifyEmail(verifyToken); + + const { accessToken, refreshToken } = await user.generateAuthToken( + currentUUID + ); + + createLoginCookie(res, accessToken, refreshToken); + + res.send(); + } catch (e) { + next(e); + } + }; + + resendVerifyEmail = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + + await UserProvider.resendVerifyEmail(userID); + + res.send(); + } catch (e) { + next(e); + } + }; + + sendPasswordReset = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + try { + const email = req.body.email; + + await UserProvider.sendPasswordReset(email); + + res.send(); + } catch (e) { + next(e); + } + }; + + resetPassword = async ( + req: RequestType, + res: Response, + next: NextFunction + ) => { + try { + const verifyToken = req.body.passwordToken; + const newPassword = req.body.password; + + await UserProvider.resetPassword(newPassword, verifyToken); + + res.send(); + } catch (e) { + next(e); + } + }; +} + +export default UserController; diff --git a/backend/cookies/create-cookies.ts b/backend/cookies/create-cookies.ts new file mode 100644 index 0000000..7e75d5e --- /dev/null +++ b/backend/cookies/create-cookies.ts @@ -0,0 +1,83 @@ +import { Response } from "express"; +import env from "../enviroment/env"; + +const maxAgeAccess = 60 * 1000 * 20; +//const maxAgeAccess = 1000; +const maxAgeRefresh = 60 * 1000 * 60 * 24 * 30; +//const maxAgeRefresh = 1000; +const maxAgeStreamVideo = 60 * 1000 * 60 * 24; + +const secureCookies = env.secureCookies + ? env.secureCookies === "true" + ? true + : false + : false; + +export const createLoginCookie = ( + res: Response, + accessToken: string, + refreshToken: string +) => { + res.cookie("access-token", accessToken, { + httpOnly: true, + maxAge: maxAgeAccess, + sameSite: "strict", + secure: secureCookies, + }); + + res.cookie("refresh-token", refreshToken, { + httpOnly: true, + maxAge: maxAgeRefresh, + sameSite: "strict", + secure: secureCookies, + }); +}; + +export const createLogoutCookie = (res: Response) => { + res.cookie( + "access-token", + {}, + { + httpOnly: true, + maxAge: 0, + sameSite: "strict", + secure: secureCookies, + } + ); + + res.cookie( + "refresh-token", + {}, + { + httpOnly: true, + maxAge: 0, + sameSite: "strict", + secure: secureCookies, + } + ); +}; + +export const createStreamVideoCookie = ( + res: Response, + streamVideoAccessToken: string +) => { + res.cookie("video-access-token", streamVideoAccessToken, { + httpOnly: true, + maxAge: maxAgeStreamVideo, + sameSite: "strict", + secure: secureCookies, + }); +}; + +export const removeStreamVideoCookie = (res: Response) => { + res.cookie( + "video-access-token", + {}, + { + httpOnly: true, + maxAge: 0, + sameSite: "strict", + secure: secureCookies, + } + ); +}; diff --git a/backend/db/connections/mongoose-server-utils.ts b/backend/db/connections/mongoose-server-utils.ts new file mode 100644 index 0000000..e2a676c --- /dev/null +++ b/backend/db/connections/mongoose-server-utils.ts @@ -0,0 +1,8 @@ +import mongoose from "mongoose"; +import env from "../../enviroment/env"; + +mongoose.connect(env.mongoURL!, { + socketTimeoutMS: 30000000, +}); + +export default mongoose; diff --git a/backend/db/connections/mongoose.ts b/backend/db/connections/mongoose.ts new file mode 100644 index 0000000..3cac0c7 --- /dev/null +++ b/backend/db/connections/mongoose.ts @@ -0,0 +1,27 @@ +import mongoose from "mongoose"; +import env from "../../enviroment/env"; +import fs from "fs"; + +const DBUrl = env.mongoURL as string; + +if (env.useDocumentDB === "true") { + console.log("Using DocumentDB"); + + if (env.documentDBBundle === "true") { + const fileBuffer = fs.readFileSync("./rds-combined-ca-bundle.pem"); + const mongooseCertificateConnect = mongoose as any; + + mongooseCertificateConnect.connect(DBUrl, { + useCreateIndex: true, + useUnifiedTopology: true, + sslValidate: true, + sslCA: fileBuffer, + }); + } else { + mongoose.connect(DBUrl, {}); + } +} else { + mongoose.connect(DBUrl, {}); +} + +export default mongoose; diff --git a/backend/db/connections/s3.ts b/backend/db/connections/s3.ts new file mode 100644 index 0000000..ec14dac --- /dev/null +++ b/backend/db/connections/s3.ts @@ -0,0 +1,22 @@ +import AWS from "aws-sdk"; +import env from "../../enviroment/env"; + +AWS.config.update({ + accessKeyId: env.s3ID, + secretAccessKey: env.s3Key, + +}); + + +const s3 = new AWS.S3({ + endpoint: "https://s3-16cd54cc.hosted.cumin.dev", // Example for DigitalOcean Spaces + s3ForcePathStyle: true, //s Often needed for S3-compatible services +}); + + + + + + +export default s3; +module.exports = s3; diff --git a/backend/db/mongoDB/fileDB.ts b/backend/db/mongoDB/fileDB.ts new file mode 100644 index 0000000..0c9850f --- /dev/null +++ b/backend/db/mongoDB/fileDB.ts @@ -0,0 +1,375 @@ +import mongoose from "../connections/mongoose"; +import { ObjectId } from "mongodb"; +import File from "../../models/file-model"; +import { UserInterface } from "../../models/user-model"; +import { createFileQuery } from "../../utils/createQuery"; +import { FileListQueryType } from "../../types/file-types"; +import sortBySwitch from "../../utils/sortBySwitch"; + +class DbUtil { + constructor() {} + + // READ + + getPublicFile = async (fileID: string) => { + const file = await File.findOne({ _id: new ObjectId(fileID) }); + return file; + }; + + getPublicInfo = async (fileID: string, tempToken: string) => { + const file = await File.findOne({ + _id: new ObjectId(fileID), + "metadata.link": tempToken, + }); + return file; + }; + + getFileInfo = async (fileID: string, userID: string) => { + const file = await File.findOne({ + "metadata.owner": userID, + _id: new ObjectId(fileID), + }); + + return file; + }; + + getQuickList = async (userID: string, limit: number) => { + let query: any = { + "metadata.owner": userID, + "metadata.trashed": null, + "metadata.processingFile": null, + }; + + const fileList = await File.find(query) + .sort({ uploadDate: -1 }) + .limit(limit); + + return fileList; + }; + + getList = async ( + queryData: FileListQueryType, + sortBy: string, + limit: number + ) => { + const formattedSortBy = sortBySwitch(sortBy); + + const queryObj = createFileQuery(queryData); + + if (sortBy.includes("alp_")) { + const fileList = await File.find(queryObj) + .collation({ locale: "en", strength: 2 }) + .sort(formattedSortBy) + .limit(limit); + + return fileList; + } else { + const fileList = await File.find(queryObj) + .sort(formattedSortBy) + .limit(limit); + + return fileList; + } + }; + + getFileSearchList = async ( + userID: string, + searchQuery: RegExp, + trashMode: boolean, + mediaMode: boolean + ) => { + let query: any = { + "metadata.owner": userID, + filename: searchQuery, + "metadata.trashed": trashMode ? true : null, + "metadata.processingFile": null, + }; + + if (mediaMode) query = { ...query, "metadata.hasThumbnail": true }; + + const fileList = await File.find(query).limit(10); + + return fileList; + }; + + getFileListByIncludedParent = async ( + userID: string | mongoose.Types.ObjectId, + parentListString: string + ) => { + const fileList = await File.find({ + "metadata.owner": userID, + "metadata.parentList": { $regex: `.*${parentListString}.*` }, + }); + + return fileList; + }; + + getFileListByOwner = async (userID: string) => { + const fileList = await File.find({ "metadata.owner": userID }); + + return fileList; + }; + + getFileListByParent = async (userID: string, parent: string) => { + const fileList = await File.find({ + owner: userID, + "metadata.parent": parent, + }); + + return fileList; + }; + + // UPDATE + + updateFileUploadedFile = async ( + fileID: string, + userID: string, + parent: string, + parentList: string + ) => { + const file = await File.findOneAndUpdate( + { _id: new ObjectId(fileID), "metadata.owner": userID }, + { + $set: { + "metadata.parent": parent, + "metadata.parentList": parentList, + }, + $unset: { "metadata.processingFile": null }, + }, + { new: true } + ); + + return file; + }; + + updateFolderUploadedFile = async ( + fileID: string, + userID: string, + parent: string, + parentList: string + ) => { + const file = await File.findOneAndUpdate( + { _id: new ObjectId(fileID), "metadata.owner": userID }, + { + $set: { + "metadata.parent": parent, + "metadata.parentList": parentList, + }, + $unset: { "metadata.processingFile": null }, + }, + { new: true } + ); + + return file; + }; + + setThumbnail = async (fileID: string, thumbnailID: string) => { + const file = await File.findOneAndUpdate( + { _id: new ObjectId(fileID), "metadata.hasThumbnail": false }, + { + $set: { + "metadata.hasThumbnail": true, + "metadata.thumbnailID": thumbnailID, + }, + } + ); + + return file; + }; + + removeOneTimePublicLink = async ( + fileID: string | mongoose.Types.ObjectId + ) => { + const file = await File.findOneAndUpdate( + { _id: new ObjectId(fileID) }, + { + $unset: { "metadata.linkType": "", "metadata.link": "" }, + } + ); + + return file; + }; + + removeLink = async (fileID: string, userID: string) => { + const file = await File.findOneAndUpdate( + { _id: new ObjectId(fileID), "metadata.owner": userID }, + { $unset: { "metadata.linkType": "", "metadata.link": "" } }, + { new: true } + ); + + return file; + }; + + makePublic = async (fileID: string, userID: string, token: string) => { + const file = await File.findOne({ + _id: new ObjectId(fileID), + "metadata.owner": userID, + }); + + if (!file) return null; + + file.metadata.linkType = "public"; + file.metadata.link = token; + + await file.save(); + + return file; + }; + + makeOneTimePublic = async (fileID: string, userID: string, token: string) => { + const file = await File.findOne({ + _id: new ObjectId(fileID), + "metadata.owner": userID, + }); + + if (!file) return null; + + file.metadata.linkType = "one"; + file.metadata.link = token; + + await file.save(); + + return file; + }; + + trashFile = async ( + fileID: string, + parent: string, + parentList: string, + userID: string + ) => { + const file = await File.findOne({ + _id: new ObjectId(fileID), + "metadata.owner": userID, + }); + + if (!file) return null; + + file.metadata.trashed = true; + file.metadata.parent = parent; + file.metadata.parentList = parentList; + + await file.save(); + + return file; + }; + + restoreFile = async (fileID: string, userID: string) => { + const file = await File.findOne({ + _id: new ObjectId(fileID), + "metadata.owner": userID, + }); + + if (!file) return null; + + file.metadata.trashed = null; + + await file.save(); + + return file; + }; + + removeTempToken = async (user: UserInterface, tempToken: string) => { + user.tempTokens = user.tempTokens.filter((filterToken) => { + return filterToken.token !== tempToken; + }); + + return user; + }; + + trashFilesByParent = async (parentList: string, userID: string) => { + const result = await File.updateMany( + { + "metadata.owner": userID, + "metadata.parentList": { $regex: `.*${parentList}.*` }, // REGEX + }, + { + $set: { + "metadata.trashed": true, + }, + } + ); + return result; + }; + + restoreFilesByParent = async (parentList: string, userID: string) => { + const result = await File.updateMany( + { + "metadata.owner": userID, + "metadata.parentList": { $regex: `.*${parentList}.*` }, // REGEX + }, + { + $set: { + "metadata.trashed": null, + }, + } + ); + return result; + }; + + renameFile = async (fileID: string, userID: string, title: string) => { + const file = await File.findOne({ + _id: new ObjectId(fileID), + "metadata.owner": userID, + }); + + if (!file) return null; + + file.filename = title; + + await file.save(); + + return file; + }; + + moveFile = async ( + fileID: string | mongoose.Types.ObjectId, + userID: string, + parent: string, + parentList: string + ) => { + const file = await File.findOne({ + _id: new ObjectId(fileID), + "metadata.owner": userID, + }); + + if (!file) return null; + + file.metadata.parent = parent; + file.metadata.parentList = parentList; + + await file.save(); + + return file; + }; + + moveMultipleFiles = async ( + userID: string | mongoose.Types.ObjectId, + currentParent: string, + newParent: string, + newParentList: string + ) => { + await File.updateMany( + { "metadata.owner": userID, "metadata.parent": currentParent }, + { + $set: { + "metadata.parent": newParent, + "metadata.parentList": newParentList, + }, + } + ); + }; + + // DELETE + + deleteFile = async (fileID: string, userID: string) => { + const result = await File.deleteOne({ + _id: new ObjectId(fileID), + "metadata.owner": userID, + }); + return result; + }; +} + +export default DbUtil; +module.exports = DbUtil; diff --git a/backend/db/mongoDB/folderDB.ts b/backend/db/mongoDB/folderDB.ts new file mode 100644 index 0000000..0ef7f53 --- /dev/null +++ b/backend/db/mongoDB/folderDB.ts @@ -0,0 +1,251 @@ +import Folder, { FolderInterface } from "../../models/folder-model"; +import { ObjectId } from "mongodb"; +import { FolderListQueryType } from "../../types/folder-types"; +import { createFolderQuery } from "../../utils/createQuery"; +import sortBySwitch from "../../utils/sortBySwitchFolder"; + +class DbUtil { + constructor() {} + + // READ + + getFolderSearchList = async ( + userID: string, + searchQuery: RegExp, + trashMode: boolean + ) => { + let query: any = { + owner: userID, + name: searchQuery, + trashed: trashMode ? true : null, + }; + + const folderList = await Folder.find(query).limit(10); + + return folderList; + }; + + getFolderInfo = async (folderID: string, userID: string) => { + const folder = await Folder.findOne({ + owner: userID, + _id: new ObjectId(folderID), + }); + + return folder; + }; + + getFolderList = async (queryData: FolderListQueryType, sortBy: string) => { + const query = createFolderQuery(queryData); + + const sortByQuery = sortBySwitch(sortBy); + + const folderList = await Folder.find(query).sort(sortByQuery); + + return folderList; + }; + + getMoveFolderList = async ( + userID: string, + parent = "/", + search?: string, + folderIDs?: string[] + ) => { + let query: any = { + owner: userID, + }; + + // const idQuery = []; + + // if (currentParent && currentParent !== "/") { + // idQuery.push(currentParent); + // } + + // if (folderID) { + // query.parentList = { $ne: folderID }; + // idQuery.push(folderID); + // } + + if (folderIDs && folderIDs.length > 0) { + query._id = { $nin: folderIDs }; + query.parentList = { $nin: folderIDs }; + } + + // query._id = { $nin: idQuery }; + + if (!search || search === "") { + query.parent = parent; + } + + if (search && search !== "") { + query.name = new RegExp(search, "i"); + } + + query.trashed = null; + + const result = await Folder.find(query).sort({ createdAt: -1 }); + + return result; + }; + + getFolderListByIncludedParent = async (userID: string, parent: string) => { + const folderList = await Folder.find({ + owner: userID, + parentList: { + $in: parent, + }, + trashed: null, + }); + + return folderList; + }; + + findAllFoldersByParent = async (parentID: string, userID: string) => { + const folderList = await Folder.find({ + parentList: parentID, + owner: userID, + }); + + return folderList; + }; + + // UPDATE + + moveFolder = async ( + folderID: string, + userID: string, + parent: string, + parentList: string[] + ) => { + const folder = await Folder.findOne({ + _id: new ObjectId(folderID), + owner: userID, + }); + + if (!folder) return null; + + folder.parent = parent; + folder.parentList = parentList; + + await folder.save(); + + return folder; + }; + + renameFolder = async (folderID: string, userID: string, title: string) => { + const folder = await Folder.findOne({ + _id: new ObjectId(folderID), + owner: userID, + }); + + if (!folder) return null; + + folder.name = title; + + await folder.save(); + + return folder; + }; + + trashFoldersByParent = async (parentList: string[], userID: string) => { + const result = await Folder.updateMany( + { + owner: userID, + parentList: { $all: parentList }, + }, + { + $set: { + trashed: true, + }, + } + ); + return result; + }; + + restoreFolder = async (folderID: string, userID: string) => { + const folder = await Folder.findOne({ + _id: new ObjectId(folderID), + owner: userID, + }); + + if (!folder) return null; + + folder.trashed = null; + + await folder.save(); + + return folder; + }; + + restoreFoldersByParent = async (parentList: string[], userID: string) => { + const result = await Folder.updateMany( + { + owner: userID, + parentList: { $all: parentList }, + }, + { + $set: { + trashed: null, + }, + } + ); + return result; + }; + + trashFolder = async (folderID: string, userID: string) => { + const folder = await Folder.findOne({ + _id: new ObjectId(folderID), + owner: userID, + }); + + if (!folder) return null; + + folder.trashed = true; + + await folder.save(); + + return folder; + }; + + // CREATE + + createFolder = async (folderData: { + name: string; + parent: string; + parentList: string[]; + owner: string; + }) => { + const folder = new Folder(folderData); + + await folder.save(); + + return folder; + }; + + // DELETE + + deleteFolder = async (folderID: string, userID: string) => { + const result = await Folder.deleteOne({ + _id: new ObjectId(folderID), + owner: userID, + }); + return result; + }; + + deleteFoldersByParentList = async ( + parentList: (string | ObjectId)[], + userID: string + ) => { + const result = await Folder.deleteMany({ + owner: userID, + parentList: { $all: parentList }, + }); + return result; + }; + + deleteFoldersByOwner = async (userID: string) => { + const result = await Folder.deleteMany({ owner: userID }); + return result; + }; +} + +export default DbUtil; diff --git a/backend/db/mongoDB/thumbnailDB.ts b/backend/db/mongoDB/thumbnailDB.ts new file mode 100644 index 0000000..474d910 --- /dev/null +++ b/backend/db/mongoDB/thumbnailDB.ts @@ -0,0 +1,28 @@ +import Thumbnail from "../../models/thumbnail-model"; +import { ObjectId } from "mongodb"; + +class ThumbnailDB { + constructor() {} + + // READ + + getThumbnailInfo = async (userID: string, thumbnailID: string) => { + const thumbnail = await Thumbnail.findOne({ + _id: new ObjectId(thumbnailID), + owner: userID, + }); + return thumbnail; + }; + + // DELETE + + removeThumbnail = async (userID: string, thumbnailID: ObjectId) => { + const result = await Thumbnail.deleteOne({ + _id: thumbnailID, + owner: userID, + }); + return result; + }; +} + +export default ThumbnailDB; diff --git a/backend/db/mongoDB/userDB.ts b/backend/db/mongoDB/userDB.ts new file mode 100644 index 0000000..eae997e --- /dev/null +++ b/backend/db/mongoDB/userDB.ts @@ -0,0 +1,15 @@ +import { ObjectId } from "mongodb"; +import User from "../../models/user-model"; + +// READ + +class UserDB { + constructor() {} + + getUserInfo = async (userID: string) => { + const user = await User.findOne({ _id: new ObjectId(userID) }); + return user; + }; +} + +export default UserDB; diff --git a/backend/enviroment/env.ts b/backend/enviroment/env.ts new file mode 100644 index 0000000..65f80da --- /dev/null +++ b/backend/enviroment/env.ts @@ -0,0 +1,75 @@ +export default { + key: process.env.KEY, + newKey: process.env.NEW_KEY, + passwordAccess: process.env.PASSWORD_ACCESS, + passwordRefresh: process.env.PASSWORD_REFRESH, + passwordCookie: process.env.PASSWORD_COOKIE, + createAcctBlocked: process.env.BLOCK_CREATE_ACCOUNT === "true", + root: process.env.ROOT, + url: process.env.URL, + mongoURL: process.env.MONGODB_URL, + dbType: process.env.DB_TYPE, + fsDirectory: process.env.FS_DIRECTORY, + s3ID: process.env.S3_ID, + s3Key: process.env.S3_KEY, + s3Bucket: process.env.S3_BUCKET, + useDocumentDB: process.env.USE_DOCUMENT_DB, + documentDBBundle: process.env.DOCUMENT_DB_BUNDLE, + sendgridKey: process.env.SENDGRID_KEY, + sendgridEmail: process.env.SENDGRID_EMAIL, + remoteURL: process.env.REMOTE_URL, + secureCookies: process.env.SECURE_COOKIES, + tempDirectory: process.env.TEMP_DIRECTORY, + emailVerification: process.env.EMAIL_VERIFICATION, + emailDomain: process.env.EMAIL_DOMAIN, + emailAPIKey: process.env.EMAIL_API_KEY, + emailHost: process.env.EMAIL_HOST, + emailPort: process.env.EMAIL_PORT, + emailAddress: process.env.EMAIL_ADDRESS, + videoThumbnailsEnabled: process.env.VIDEO_THUMBNAILS_ENABLED === "true", + tempVideoThumbnailLimit: process.env.TEMP_VIDEO_THUMBNAIL_LIMIT + ? +process.env.TEMP_VIDEO_THUMBNAIL_LIMIT + : 0, + docker: process.env.DOCKER === "true", + httpsKeyPath: process.env.HTTPS_KEY_PATH, + httpsCaPath: process.env.HTTPS_CA_PATH, + httpsCrtPath: process.env.HTTPS_CRT_PATH, +}; + +module.exports = { + key: process.env.KEY, + newKey: process.env.NEW_KEY, + passwordAccess: process.env.PASSWORD_ACCESS, + passwordRefresh: process.env.PASSWORD_REFRESH, + passwordCookie: process.env.PASSWORD_COOKIE, + createAcctBlocked: process.env.BLOCK_CREATE_ACCOUNT === "true", + root: process.env.ROOT, + url: process.env.URL, + mongoURL: process.env.MONGODB_URL, + dbType: process.env.DB_TYPE, + fsDirectory: process.env.FS_DIRECTORY, + s3ID: process.env.S3_ID, + s3Key: process.env.S3_KEY, + s3Bucket: process.env.S3_BUCKET, + useDocumentDB: process.env.USE_DOCUMENT_DB, + documentDBBundle: process.env.DOCUMENT_DB_BUNDLE, + sendgridKey: process.env.SENDGRID_KEY, + sendgridEmail: process.env.SENDGRID_EMAIL, + remoteURL: process.env.REMOTE_URL, + secureCookies: process.env.SECURE_COOKIES, + tempDirectory: process.env.TEMP_DIRECTORY, + emailVerification: process.env.EMAIL_VERIFICATION, + emailDomain: process.env.EMAIL_DOMAIN, + emailAPIKey: process.env.EMAIL_API_KEY, + emailHost: process.env.EMAIL_HOST, + emailPort: process.env.EMAIL_PORT, + emailAddress: process.env.EMAIL_ADDRESS, + videoThumbnailsEnabled: process.env.VIDEO_THUMBNAILS_ENABLED === "true", + tempVideoThumbnailLimit: process.env.TEMP_VIDEO_THUMBNAIL_LIMIT + ? +process.env.TEMP_VIDEO_THUMBNAIL_LIMIT + : 0, + docker: process.env.DOCKER === "true", + httpsKeyPath: process.env.HTTPS_KEY_PATH, + httpsCaPath: process.env.HTTPS_CA_PATH, + httpsCrtPath: process.env.HTTPS_CRT_PATH, +}; diff --git a/backend/enviroment/get-env-variables.ts b/backend/enviroment/get-env-variables.ts new file mode 100644 index 0000000..a7bd924 --- /dev/null +++ b/backend/enviroment/get-env-variables.ts @@ -0,0 +1,18 @@ +import path from "path"; + +const getEnvVariables = () => { + const configPath = path.join(__dirname, "..", "..", "backend", "config"); + + const processType = process.env.NODE_ENV; + + if (processType === "production" || processType === undefined) { + require("dotenv").config({ path: configPath + "/.env.production" }); + } else if (processType === "development") { + require("dotenv").config({ path: configPath + "/.env.development" }); + } else if (processType === "test") { + require("dotenv").config({ path: configPath + "/.env.test" }); + } +}; + +export default getEnvVariables; +module.exports = getEnvVariables; diff --git a/backend/express-routers/file-router.ts b/backend/express-routers/file-router.ts new file mode 100644 index 0000000..f693408 --- /dev/null +++ b/backend/express-routers/file-router.ts @@ -0,0 +1,214 @@ +import { Router } from "express"; +import auth from "../middleware/auth"; +import FileController from "../controllers/file-controller"; +import authFullUser from "../middleware/authFullUser"; +import authStreamVideo from "../middleware/authStreamVideo"; +import { + deleteFileValidationRules, + deleteMultiValidationRules, + downloadFileValidationRules, + getFileInfoValidationRules, + getListValidationRules, + getPublicDownloadValidationRules, + getQuickListValidationRules, + getSuggestedListValidationRules, + getThumbnailValidationRules, + makePrivateValidationRules, + makePublicValidationRules, + moveFileValidationRules, + moveMultiValidationRules, + removeVideoStreamTokenValidationRules, + renameFileValidationRules, + restoreFileValidationRules, + restoreMultiValidationRules, + streamVideoValidationRules, + trashFileValidationRules, + trashMultiValidationRules, +} from "../middleware/files/files-middleware"; + +const fileController = new FileController(); + +const router = Router(); + +// GET + +router.get( + "/file-service/thumbnail/:id", + authFullUser, + getThumbnailValidationRules, + fileController.getThumbnail +); + +router.get( + "/file-service/full-thumbnail/:id", + authFullUser, + getThumbnailValidationRules, + fileController.getFullThumbnail +); + +router.get( + "/file-service/public/download/:id/:tempToken", + getPublicDownloadValidationRules, + fileController.getPublicDownload +); + +router.get( + "/file-service/public/info/:id/:tempToken", + getPublicDownloadValidationRules, + fileController.getPublicInfo +); + +router.get( + "/file-service/info/:id", + auth, + getFileInfoValidationRules, + fileController.getFileInfo +); + +router.get( + "/file-service/quick-list", + auth, + getQuickListValidationRules, + fileController.getQuickList +); + +router.get( + "/file-service/list", + auth, + getListValidationRules, + fileController.getList +); + +router.get( + "/file-service/download/access-token-stream-video", + authFullUser, + fileController.getAccessTokenStreamVideo +); + +router.get( + "/file-service/stream-video/:id", + authStreamVideo, + streamVideoValidationRules, + fileController.streamVideo +); + +router.delete( + "/file-service/remove-stream-video-token", + authStreamVideo, + fileController.removeStreamVideoAccessToken +); + +router.get( + "/file-service/download/:id", + authFullUser, + downloadFileValidationRules, + fileController.downloadFile +); + +router.get( + "/file-service/suggested-list", + auth, + getSuggestedListValidationRules, + fileController.getSuggestedList +); + +// PATCH + +router.patch( + "/file-service/make-public/:id", + authFullUser, + makePublicValidationRules, + fileController.makePublic +); + +router.patch( + "/file-service/make-one/:id", + auth, + makePublicValidationRules, + fileController.makeOneTimePublic +); + +router.patch( + "/file-service/rename", + auth, + renameFileValidationRules, + fileController.renameFile +); + +router.patch( + "/file-service/move", + auth, + moveFileValidationRules, + fileController.moveFile +); + +router.patch( + "/file-service/move-multi", + auth, + moveMultiValidationRules, + fileController.moveMultiFile +); + +router.patch( + "/file-service/trash", + auth, + trashFileValidationRules, + fileController.trashFile +); + +router.patch( + "/file-service/trash-multi", + auth, + trashMultiValidationRules, + fileController.trashMulti +); + +router.patch( + "/file-service/restore", + auth, + restoreFileValidationRules, + fileController.restoreFile +); + +router.patch( + "/file-service/restore-multi", + auth, + restoreMultiValidationRules, + fileController.restoreMulti +); + +router.patch( + "/file-service/remove-link/:id", + auth, + makePrivateValidationRules, + fileController.removeLink +); + +// DELETE + +router.delete( + "/file-service/remove/token-video/:id", + auth, + removeVideoStreamTokenValidationRules, + fileController.removeTempToken +); + +router.delete( + "/file-service/remove", + auth, + deleteFileValidationRules, + fileController.deleteFile +); + +router.delete( + "/file-service/remove-multi", + auth, + deleteMultiValidationRules, + fileController.deleteMulti +); + +// POST + +router.post("/file-service/upload", authFullUser, fileController.uploadFile); + +export default router; diff --git a/backend/express-routers/folder-router.ts b/backend/express-routers/folder-router.ts new file mode 100644 index 0000000..7a1553d --- /dev/null +++ b/backend/express-routers/folder-router.ts @@ -0,0 +1,107 @@ +import { Router } from "express"; +import auth from "../middleware/auth"; +import FolderController from "../controllers/folder-controller"; +import { + createFolderValidationRules, + deleteFolderValidationRules, + downloadZipValidationRules, + getFolderInfoValidationRules, + getFolderListValidationRules, + moveFolderListValidationRules, + moveFolderValidationRules, + renameFolderValidationRules, + restoreFolderValidationRules, + trashFolderValidationRules, +} from "../middleware/folders/folder-middleware"; +import authFullUser from "../middleware/authFullUser"; + +const folderController = new FolderController(); +const router = Router(); + +// GET + +router.get( + "/folder-service/info/:id", + auth, + getFolderInfoValidationRules, + folderController.getInfo +); + +router.get( + "/folder-service/list", + auth, + getFolderListValidationRules, + folderController.getFolderList +); + +router.get( + "/folder-service/move-folder-list", + auth, + moveFolderListValidationRules, + folderController.getMoveFolderList +); + +router.get( + "/folder-service/download-zip", + auth, + downloadZipValidationRules, + folderController.downloadZip +); + +// PATCH + +router.patch( + "/folder-service/rename", + auth, + renameFolderValidationRules, + folderController.renameFolder +); + +router.patch( + "/folder-service/move", + auth, + moveFolderValidationRules, + folderController.moveFolder +); + +router.patch( + "/folder-service/trash", + auth, + trashFolderValidationRules, + folderController.trashFolder +); + +router.patch( + "/folder-service/restore", + auth, + restoreFolderValidationRules, + folderController.restoreFolder +); + +// DELETE + +router.delete( + "/folder-service/remove", + auth, + deleteFolderValidationRules, + folderController.deleteFolder +); + +router.delete("/folder-service/remove-all", auth, folderController.deleteAll); + +// POST + +router.post( + "/folder-service/create", + auth, + createFolderValidationRules, + folderController.createFolder +); + +router.post( + "/folder-service/upload", + authFullUser, + folderController.uploadFolder +); + +export default router; diff --git a/backend/express-routers/user-router.ts b/backend/express-routers/user-router.ts new file mode 100644 index 0000000..f5453eb --- /dev/null +++ b/backend/express-routers/user-router.ts @@ -0,0 +1,66 @@ +import { Router } from "express"; +import auth from "../middleware/auth"; +import UserController from "../controllers/user-controller"; +import authRefresh from "../middleware/authRefresh"; +import authLogout from "../middleware/authLogout"; +import { + changePasswordValidationRules, + createAccountValidationRules, + loginAccountValidationRules, +} from "../middleware/user/user-middleware"; + +const userController = new UserController(); + +const router = Router(); + +// GET + +router.get("/user-service/user", auth, userController.getUser); + +router.get("/user-service/user-detailed", auth, userController.getUserDetailed); + +// POST + +router.post( + "/user-service/login", + loginAccountValidationRules, + userController.login +); + +router.post("/user-service/logout", authLogout, userController.logout); + +router.post("/user-service/logout-all", authLogout, userController.logoutAll); + +router.post( + "/user-service/create", + createAccountValidationRules, + userController.createUser +); + +router.post("/user-service/get-token", authRefresh, userController.getToken); + +// PATCH + +router.patch( + "/user-service/change-password", + auth, + changePasswordValidationRules, + userController.changePassword +); + +router.patch( + "/user-service/resend-verify-email", + auth, + userController.resendVerifyEmail +); + +router.patch("/user-service/verify-email", userController.verifyEmail); + +router.patch("/user-service/reset-password", userController.resetPassword); + +router.patch( + "/user-service/send-password-reset", + userController.sendPasswordReset +); + +export default router; diff --git a/backend/key/get-key.ts b/backend/key/get-key.ts new file mode 100644 index 0000000..942db78 --- /dev/null +++ b/backend/key/get-key.ts @@ -0,0 +1,40 @@ +import env from "../enviroment/env"; +import crypto from "crypto"; +import getKeyFromTerminal from "../utils/getKeyFromTerminal"; + +const getKey = async () => { + console.log("getkey") + if ( + process.env.KEY || + process.env.NODE_ENV === "development" || + process.env.NODE_ENV === "test" + ) { + const password = process.env.KEY; + if (!password) { + console.log(`Key is required for ${process.env.NODE_ENV} server`); + throw new Error(`Key is required for ${process.env.NODE_ENV} server`); + } + + env.key = crypto.createHash("md5").update(password).digest("hex"); + } else if (process.env.NODE_ENV === "production" && !process.env.KEY) { + const terminalPassword = await getKeyFromTerminal(); + + if (!terminalPassword || !terminalPassword.length) { + console.log( + "Terminal key is required for production server, or create a .env file with KEY" + ); + throw new Error( + "Terminal key is required for production server, or create a .env file with KEY" + ); + } + + const password = crypto + .createHash("md5") + .update(terminalPassword) + .digest("hex"); + + env.key = password; + } +}; + +export default getKey; diff --git a/backend/key/get-web-UI-key.ts b/backend/key/get-web-UI-key.ts new file mode 100644 index 0000000..f54cdbd --- /dev/null +++ b/backend/key/get-web-UI-key.ts @@ -0,0 +1,52 @@ +import express, {Request, Response} from "express"; +import http from "http"; +import path from "path"; +import bodyParser from "body-parser"; +const app = express(); + +const getWebUIKey = () => { + + const publicPath = path.join(__dirname, "..", "..", "webUI"); + + + return new Promise((resolve, reject) => { + + app.use(express.static(publicPath)); + app.use(express.json()); + app.use(bodyParser.json({limit: "50mb"})); + app.use(bodyParser.urlencoded({limit: "50mb", extended: true, parameterLimit:50000})) + + app.post("/submit", (req: Request, res: Response) => { + + const password = req.body.password; + + if (password && password.length > 0) { + + console.log("Got WebUI key"); + res.send(); + server.close(); + resolve(password); + } + }) + + app.get("*", (req: Request, res: Response) => { + + res.sendFile(path.join(publicPath, "index.html")); + + }) + + const port = process.env.HTTP_PORT || process.env.PORT || "3000"; + const url = process.env.DOCKER ? undefined : "localhost"; + + const server = http.createServer(app) as any; + + server.listen(port, () => { + + console.log(`\nPlease navigate to http://localhost:${port} to enter encryption key\n`) + + }); + + }) +} + +export default getWebUIKey; \ No newline at end of file diff --git a/backend/middleware/auth.ts b/backend/middleware/auth.ts new file mode 100644 index 0000000..7c0821c --- /dev/null +++ b/backend/middleware/auth.ts @@ -0,0 +1,53 @@ +import jwt from "jsonwebtoken"; +import env from "../enviroment/env"; +import { Request, Response, NextFunction } from "express"; + +interface RequestType extends Request { + user?: userAccessType; + token?: string; + encryptedToken?: string; +} + +type jwtType = { + iv: Buffer; + user: userAccessType; +}; + +type userAccessType = { + _id: string; + emailVerified: boolean; + email: string; + admin: boolean; + botChecked: boolean; + username: string; +}; + +const auth = async (req: RequestType, res: Response, next: NextFunction) => { + try { + const accessToken = req.cookies["access-token"]; + + if (!accessToken) throw new Error("No Access Token"); + + const decoded = jwt.verify(accessToken, env.passwordAccess!) as jwtType; + + const user = decoded.user; + + if (!user) throw new Error("No User"); + + req.user = user; + + next(); + } catch (e: unknown) { + if ( + e instanceof Error && + e.message !== "No Access Token" && + e.message !== "No User" + ) { + console.log("\nAuthorization Middleware Error:", e.message); + } + + res.status(401).send("Error Authenticating"); + } +}; + +export default auth; diff --git a/backend/middleware/authFullUser.ts b/backend/middleware/authFullUser.ts new file mode 100644 index 0000000..42824b1 --- /dev/null +++ b/backend/middleware/authFullUser.ts @@ -0,0 +1,62 @@ +import jwt from "jsonwebtoken"; +import User, { UserInterface } from "../models/user-model"; +import env from "../enviroment/env"; +import { Request, Response, NextFunction } from "express"; + +interface RequestType extends Request { + user?: UserInterface; + token?: string; + encryptedToken?: string; +} + +type jwtType = { + iv: Buffer; + user: userAccessType; +}; + +type userAccessType = { + _id: string; + emailVerified: boolean; + email: string; + admin: boolean; + botChecked: boolean; + username: string; +}; + +const authFullUser = async ( + req: RequestType, + res: Response, + next: NextFunction +) => { + try { + const accessToken = req.cookies["access-token"]; + + if (!accessToken) throw new Error("No Access Token"); + + const decoded = jwt.verify(accessToken, env.passwordAccess!) as jwtType; + + const user = decoded.user; + + if (!user) throw new Error("No User"); + + const fullUser = await User.findById(user._id); + + if (!fullUser) throw new Error("No User"); + + req.user = fullUser; + + next(); + } catch (e) { + if ( + e instanceof Error && + e.message !== "No Access Token" && + e.message !== "No User" && + e.message !== "Email Not Verified" + ) + console.log("\nAuthorization Full User Middleware Error:", e.message); + + res.status(401).send("Error Authenticating"); + } +}; + +export default authFullUser; diff --git a/backend/middleware/authLogout.ts b/backend/middleware/authLogout.ts new file mode 100644 index 0000000..8ccf076 --- /dev/null +++ b/backend/middleware/authLogout.ts @@ -0,0 +1,53 @@ +import jwt from "jsonwebtoken"; +import env from "../enviroment/env"; +import { Request, Response, NextFunction } from "express"; +import { createLogoutCookie } from "../cookies/create-cookies"; + +interface RequestType extends Request { + user?: userAccessType; + token?: string; + encryptedToken?: string; +} + +type jwtType = { + iv: Buffer; + user: userAccessType; +}; + +type userAccessType = { + _id: string; + emailVerified: boolean; + email: string; + botChecked: boolean; +}; + +const auth = async (req: RequestType, res: Response, next: NextFunction) => { + try { + const accessToken = req.cookies["access-token"]; + + if (!accessToken) throw new Error("No Access Token"); + + const decoded = jwt.verify(accessToken, env.passwordAccess!) as jwtType; + + const user = decoded.user; + + if (!user) throw new Error("No User"); + + req.user = user; + + next(); + } catch (e) { + if ( + e instanceof Error && + e.message !== "No Access Token" && + e.message !== "No User" + ) { + console.log("\nAuthorization Logout Middleware Error:", e.message); + } + + createLogoutCookie(res); + return res.status(401).send("Error Authenticating"); + } +}; + +export default auth; diff --git a/backend/middleware/authRefresh.ts b/backend/middleware/authRefresh.ts new file mode 100644 index 0000000..8e2e74a --- /dev/null +++ b/backend/middleware/authRefresh.ts @@ -0,0 +1,99 @@ +import jwt from "jsonwebtoken"; +import User, { UserInterface } from "../models/user-model"; +import env from "../enviroment/env"; +import { Request, Response, NextFunction } from "express"; +import { ObjectId } from "mongodb"; +import mongoose from "mongoose"; + +interface RequestType extends Request { + user?: UserInterface; + token?: string; + encryptedToken?: string; +} + +type jwtType = { + iv: Buffer; + _id: string; + time: number; +}; + +const removeOldTokens = async ( + userID: mongoose.Types.ObjectId, + uuid: string | undefined, + oldTime: number +) => { + try { + const minusTime = oldTime - 1000 * 60 * 60; + //const minusTime = oldTime - (1000); + + uuid = uuid ? uuid : "unknown"; + + if (uuid === "unknown") return; + + await User.updateOne( + { _id: userID }, + { $pull: { tokens: { uuid, time: { $lt: minusTime } } } } + ); + } catch (e) { + console.log("cannot remove old tokens", e); + } +}; + +const authRefresh = async ( + req: RequestType, + res: Response, + next: NextFunction +) => { + try { + const refreshToken = req.cookies["refresh-token"]; + const currentUUID = req.headers.uuid as string; + + if (!refreshToken) throw new Error("No Refresh Token"); + + const decoded = jwt.verify(refreshToken, env.passwordRefresh!) as jwtType; + + const time = decoded.time; + + const user = await User.findById(new ObjectId(decoded._id)); + + if (!user) throw new Error("No User"); + + const encrpytionKey = user.getEncryptionKey(); + const encryptedToken = user.encryptToken( + refreshToken, + encrpytionKey, + decoded.iv + ); + + let tokenFound = false; + + for (let i = 0; i < user.tokens.length; i++) { + const currentEncryptedToken = user.tokens[i].token; + + if (currentEncryptedToken === encryptedToken) { + tokenFound = true; + removeOldTokens(user._id, currentUUID, time); + break; + } + } + + if (!tokenFound) throw new Error("Refresh Token Not Found"); + + req.user = user; + + next(); + } catch (e) { + if ( + e instanceof Error && + e.message !== "No Refresh Token" && + e.message !== "No User" && + e.message !== "Refresh Token Not Found" + ) { + console.log("\nAuthorization Refresh Middleware Error:", e.message); + } + + res.status(401).send("Error Refreshing Token"); + } +}; + +export default authRefresh; diff --git a/backend/middleware/authStreamVideo.ts b/backend/middleware/authStreamVideo.ts new file mode 100644 index 0000000..3845746 --- /dev/null +++ b/backend/middleware/authStreamVideo.ts @@ -0,0 +1,103 @@ +import jwt from "jsonwebtoken"; +import User, { UserInterface } from "../models/user-model"; +import env from "../enviroment/env"; +import { Request, Response, NextFunction } from "express"; +import { ObjectId } from "mongodb"; +import mongoose from "mongoose"; + +interface RequestType extends Request { + user?: UserInterface; + token?: string; + encryptedToken?: string; + accessTokenStreamVideo?: string; +} + +type jwtType = { + iv: Buffer; + _id: string; + time: number; +}; + +const removeOldTokens = async ( + userID: mongoose.Types.ObjectId, + uuid: string | undefined, + oldTime: number +) => { + try { + const minusTime = oldTime - 60 * 1000 * 60 * 24; + + uuid = uuid ? uuid : "unknown"; + + if (uuid === "unknown") return; + + await User.updateOne( + { _id: userID }, + { $pull: { tempTokens: { uuid, time: { $lt: minusTime } } } } + ); + } catch (e) { + console.log("cannot remove old tokens", e); + } +}; + +const authStreamVideo = async ( + req: RequestType, + res: Response, + next: NextFunction +) => { + try { + const accessTokenStreamVideo = req.cookies["video-access-token"]; + const currentUUID = req.headers.uuid as string; + + if (!accessTokenStreamVideo) throw new Error("No Access Token"); + + const decoded = jwt.verify( + accessTokenStreamVideo, + env.passwordAccess! + ) as jwtType; + + const time = decoded.time; + + const user = await User.findById(new ObjectId(decoded._id)); + + if (!user) throw new Error("No User"); + + const encrpytionKey = user.getEncryptionKey(); + const encryptedToken = user.encryptToken( + accessTokenStreamVideo, + encrpytionKey, + decoded.iv + ); + + let tokenFound = false; + + for (let i = 0; i < user.tempTokens.length; i++) { + const currentEncryptedToken = user.tempTokens[i].token; + + if (currentEncryptedToken === encryptedToken) { + tokenFound = true; + removeOldTokens(user._id, currentUUID, time); + break; + } + } + + if (!tokenFound) throw new Error("Access Token Not Found"); + + req.user = user; + req.accessTokenStreamVideo = encryptedToken; + + next(); + } catch (e) { + if ( + e instanceof Error && + e.message !== "No Access Token" && + e.message !== "No User" && + e.message !== "Access Token Not Found" + ) { + console.log("\nAuthorization Stream Video Middleware Error:", e.message); + } + + res.status(401).send("Error Authenticating"); + } +}; + +export default authStreamVideo; diff --git a/backend/middleware/emailAuth.ts b/backend/middleware/emailAuth.ts new file mode 100644 index 0000000..e69de29 diff --git a/backend/middleware/files/files-middleware.ts b/backend/middleware/files/files-middleware.ts new file mode 100644 index 0000000..db9d119 --- /dev/null +++ b/backend/middleware/files/files-middleware.ts @@ -0,0 +1,155 @@ +import { body, param, query, validationResult } from "express-validator"; +import { Request, Response, NextFunction } from "express"; +import { middlewareValidationFunction } from "../utils/middleware-utils"; + +// GET + +export const getThumbnailValidationRules = [ + param("id").isString().withMessage("ID must be a string"), + middlewareValidationFunction, +]; + +export const getPublicDownloadValidationRules = [ + param("id").isString().withMessage("ID must be a string"), + param("tempToken").isString().withMessage("Temp Token must be a string"), + middlewareValidationFunction, +]; + +export const getFileInfoValidationRules = [ + param("id").isString().withMessage("ID must be a string"), + middlewareValidationFunction, +]; + +export const getQuickListValidationRules = [ + query("limit").optional().isNumeric().withMessage("Limit must be a number"), + middlewareValidationFunction, +]; + +export const getListValidationRules = [ + query("search").optional().isString().withMessage("Search must be a string"), + query("parent").optional().isString().withMessage("Parent must be a string"), + query("startAtDate") + .optional() + .isString() + .withMessage("Start At Date must be a string"), + query("startAtName") + .optional() + .isString() + .withMessage("Start At Name must be a string"), + query("trashMode") + .optional() + .isBoolean() + .withMessage("Trash Mode must be a boolean"), + query("mediaFilter") + .optional() + .isString() + .withMessage("Media Filter must be a string"), + query("mediaMode") + .optional() + .isBoolean() + .withMessage("Media Mode must be a boolean"), + query("sortBy").optional().isString().withMessage("Sort By must be a string"), + middlewareValidationFunction, +]; + +export const getSuggestedListValidationRules = [ + query("search") + .optional() + .isString() + .isLength({ min: 1, max: 255 }) + .withMessage("Search must be a string"), + query("trashMode") + .optional() + .isBoolean() + .withMessage("Trash Mode must be a boolean"), + query("mediaMode") + .optional() + .isBoolean() + .withMessage("Media Mode must be a boolean"), + middlewareValidationFunction, +]; + +export const streamVideoValidationRules = [ + param("id").isString().withMessage("ID must be a string"), + middlewareValidationFunction, +]; + +export const downloadFileValidationRules = [ + param("id").isString().withMessage("ID must be a string"), + middlewareValidationFunction, +]; + +// PATCH + +export const renameFileValidationRules = [ + body("id").isString().withMessage("ID must be a string"), + body("title") + .exists() + .withMessage("Title is required") + .isString() + .withMessage("Title must be a string") + .isLength({ min: 1, max: 256 }) + .withMessage( + "Title must be at least 1 character and at most 256 characters" + ), + middlewareValidationFunction, +]; + +export const moveFileValidationRules = [ + body("id").isString().withMessage("FileID must be a string"), + body("parentID").isString().optional().withMessage("Parent must be a string"), + middlewareValidationFunction, +]; + +export const moveMultiValidationRules = [ + body("items").isArray().withMessage("Items must be an array"), + body("parentID").isString().optional().withMessage("Parent must be a string"), + middlewareValidationFunction, +]; + +export const trashFileValidationRules = [ + body("id").isString().withMessage("ID must be a string"), + middlewareValidationFunction, +]; + +export const trashMultiValidationRules = [ + body("items").isArray().withMessage("Items must be an array"), + middlewareValidationFunction, +]; + +export const restoreFileValidationRules = [ + body("id").isString().withMessage("ID must be a string"), + middlewareValidationFunction, +]; + +export const restoreMultiValidationRules = [ + body("items").isArray().withMessage("Items must be an array"), + middlewareValidationFunction, +]; + +export const makePublicValidationRules = [ + param("id").isString().withMessage("ID must be a string"), + middlewareValidationFunction, +]; + +export const makePrivateValidationRules = [ + param("id").isString().withMessage("ID must be a string"), + middlewareValidationFunction, +]; + +export const removeVideoStreamTokenValidationRules = [ + param("id").isString().withMessage("ID must be a string"), + middlewareValidationFunction, +]; + +// DELETE + +export const deleteFileValidationRules = [ + body("id").isString().withMessage("ID must be a string"), + middlewareValidationFunction, +]; + +export const deleteMultiValidationRules = [ + body("items").isArray().withMessage("Items must be an array"), + middlewareValidationFunction, +]; diff --git a/backend/middleware/folders/folder-middleware.ts b/backend/middleware/folders/folder-middleware.ts new file mode 100644 index 0000000..9e1f69f --- /dev/null +++ b/backend/middleware/folders/folder-middleware.ts @@ -0,0 +1,98 @@ +import { body, param, query, validationResult } from "express-validator"; +import { Request, Response, NextFunction } from "express"; +import { middlewareValidationFunction } from "../utils/middleware-utils"; + +// GET + +export const moveFolderListValidationRules = [ + query("parent").optional().isString().withMessage("Parent must be a string"), + query("search").optional().isString().withMessage("Search must be a string"), + query("folderIDs") + .optional() + .isArray() + .withMessage("FolderIDs must be an array of strings"), + middlewareValidationFunction, +]; + +export const getFolderInfoValidationRules = [ + param("id").isString().withMessage("ID must be a string"), + middlewareValidationFunction, +]; + +export const getFolderListValidationRules = [ + query("search").optional().isString().withMessage("Search must be a string"), + query("parent").optional().isString().withMessage("Parent must be a string"), + query("sortBy").optional().isString().withMessage("Sort By must be a string"), + query("trashMode") + .optional() + .isBoolean() + .withMessage("Trash Mode must be a boolean"), + middlewareValidationFunction, +]; + +export const downloadZipValidationRules = [ + query("folderIDs") + .optional() + .isArray() + .withMessage("FolderIDs must be an array of strings"), + query("fileIDs") + .optional() + .isArray() + .withMessage("FileIDs must be an array of strings"), + middlewareValidationFunction, +]; + +// PATCH + +export const renameFolderValidationRules = [ + body("id").isString().withMessage("ID must be a string"), + body("title") + .exists() + .withMessage("Title is required") + .isString() + .withMessage("Title must be a string") + .isLength({ min: 1, max: 256 }) + .withMessage( + "Title must be at least 1 character and at most 256 characters" + ), + middlewareValidationFunction, +]; + +export const moveFolderValidationRules = [ + body("parentID").isString().withMessage("Parent must be a string"), + body("id").isString().withMessage("ID must be a string"), + middlewareValidationFunction, +]; + +export const trashFolderValidationRules = [ + body("id").isString().withMessage("ID must be a string"), + middlewareValidationFunction, +]; + +export const restoreFolderValidationRules = [ + body("id").isString().withMessage("ID must be a string"), + middlewareValidationFunction, +]; + +// DELETE + +export const deleteFolderValidationRules = [ + body("id").isString().withMessage("ID must be a string"), + middlewareValidationFunction, +]; + +// POST + +export const createFolderValidationRules = [ + body("name") + .exists() + .withMessage("Name is required") + .isString() + .withMessage("Name must be a string") + .isLength({ min: 1, max: 256 }) + .withMessage( + "Name must be at least 1 character and at most 256 characters" + ), + body("parent").optional().isString().withMessage("Parent must be a string"), + middlewareValidationFunction, +]; diff --git a/backend/middleware/tempAuth.ts b/backend/middleware/tempAuth.ts new file mode 100644 index 0000000..bedac9d --- /dev/null +++ b/backend/middleware/tempAuth.ts @@ -0,0 +1,70 @@ +// import jwt from "jsonwebtoken"; +// import User, {UserInterface} from "../models/user"; +// import env from "../enviroment/env"; +// import {Request, Response, NextFunction} from "express"; + + +// interface RequestType extends Request { +// user?: UserInterface, +// auth?: boolean, +// encryptedTempToken?: string, +// } + +// type jwtType = { +// iv: Buffer, +// _id: string, +// } + +// const tempAuth = async(req: RequestType, res: Response, next: NextFunction) => { + +// try { + +// const token = req.params.tempToken; + +// const decoded = jwt.verify(token, env.passwordAccess!) as jwtType; + +// const iv = decoded.iv; + +// const user = await User.findOne({_id: decoded._id}) as UserInterface; +// const encrpytionKey = user.getEncryptionKey(); + +// const encryptedToken = user.encryptToken(token, encrpytionKey, iv); + +// let tokenFound = false; +// for (let i = 0; i < user.tempTokens.length; i++) { + +// const currentToken = user.tempTokens[i].token; + +// if (currentToken === encryptedToken) { +// tokenFound = true; +// break; +// } +// } + +// if (!user || !tokenFound) { + +// throw new Error("User Not Found") + +// } else { + +// user.tempTokens = user.tempTokens.filter((filterToken) => { + +// return filterToken.token !== encryptedToken +// }) + +// await user.save(); + +// req.user = user; +// req.auth = true; +// req.encryptedTempToken = encryptedToken; + +// next(); +// } + +// } catch (e) { +// console.log(e); +// res.status(401).send(); +// } +// } + +// export default tempAuth; \ No newline at end of file diff --git a/backend/middleware/tempAuthVideo.ts b/backend/middleware/tempAuthVideo.ts new file mode 100644 index 0000000..a68e3d7 --- /dev/null +++ b/backend/middleware/tempAuthVideo.ts @@ -0,0 +1,70 @@ +// import jwt from "jsonwebtoken"; +// import User, {UserInterface} from "../models/user"; +// import env from "../enviroment/env"; +// import {Request, Response, NextFunction} from "express"; + + +// interface RequestType extends Request { +// user?: UserInterface, +// auth?: boolean, +// encryptedTempToken?: string, +// } + +// type jwtType = { +// iv: Buffer, +// _id: string, +// cookie: string +// } + +// const tempAuthVideo = async(req: RequestType, res: Response, next: NextFunction) => { + +// try { + +// const token = req.params.tempToken; + +// const decoded = jwt.verify(token, env.passwordAccess!) as jwtType; + +// const iv = decoded.iv; + +// if (req.params.uuid !== decoded.cookie) { + +// throw new Error("Cookie mismatch") +// } + +// const user = await User.findOne({_id: decoded._id}) as UserInterface; +// const encrpytionKey = user.getEncryptionKey(); + +// const encryptedToken = user.encryptToken(token, encrpytionKey, iv); + +// let tokenFound = false; +// for (let i = 0; i < user.tempTokens.length; i++) { + +// const currentToken = user.tempTokens[i].token; + +// if (currentToken === encryptedToken) { +// tokenFound = true; +// break; +// } +// } + +// if (!user || !tokenFound) { + +// throw new Error("User not found"); + +// } else { + +// await user.save(); + +// req.user = user; +// req.auth = true; +// req.encryptedTempToken = encryptedToken; +// next(); +// } + +// } catch (e) { +// console.log(e); +// res.status(401).send(); +// } +// } + +// export default tempAuthVideo; \ No newline at end of file diff --git a/backend/middleware/user/user-middleware.ts b/backend/middleware/user/user-middleware.ts new file mode 100644 index 0000000..3f2e73f --- /dev/null +++ b/backend/middleware/user/user-middleware.ts @@ -0,0 +1,77 @@ +import { body, param, query, validationResult } from "express-validator"; +import { Request, Response, NextFunction } from "express"; +import { middlewareValidationFunction } from "../utils/middleware-utils"; + +// PATCH + +export const changePasswordValidationRules = [ + body("oldPassword") + .exists() + .withMessage("Old password is required") + .isString() + .withMessage("Old password must be a string") + .isLength({ min: 6, max: 256 }) + .withMessage( + "Old password must be at least 6 characters and at most 256 characters" + ), + body("newPassword") + .exists() + .withMessage("New password is required") + .isString() + .withMessage("New password must be a string") + .isLength({ min: 6, max: 256 }) + .withMessage( + "New password must be at least 6 characters and at most 256 characters" + ), + middlewareValidationFunction, +]; + +// POST + +export const createAccountValidationRules = [ + body("email") + .exists() + .withMessage("Email is required") + .isString() + .withMessage("Email must be a string") + .isLength({ min: 3, max: 320 }) + .withMessage( + "Email must be at least 3 characters and at most 320 characters" + ) + .isEmail() + .withMessage("Email is invalid"), + body("password") + .exists() + .withMessage("Password is required") + .isString() + .withMessage("Password must be a string") + .isLength({ min: 6, max: 256 }) + .withMessage( + "Password must be at least 6 characters and at most 256 characters" + ), + middlewareValidationFunction, +]; + +export const loginAccountValidationRules = [ + body("email") + .exists() + .withMessage("Email is required") + .isString() + .withMessage("Email must be a string") + .isLength({ min: 3, max: 320 }) + .withMessage( + "Email must be at least 3 characters and at most 320 characters" + ) + .isEmail() + .withMessage("Email is invalid"), + body("password") + .exists() + .withMessage("Password is required") + .isString() + .withMessage("Password must be a string") + .isLength({ min: 6, max: 256 }) + .withMessage( + "Password must be at least 6 characters and at most 256 characters" + ), + middlewareValidationFunction, +]; diff --git a/backend/middleware/utils/middleware-utils.ts b/backend/middleware/utils/middleware-utils.ts new file mode 100644 index 0000000..55c7cea --- /dev/null +++ b/backend/middleware/utils/middleware-utils.ts @@ -0,0 +1,43 @@ +import e, { NextFunction } from "express"; +import { validationResult } from "express-validator"; +import { Request, Response } from "express"; +import NotAuthorizedError from "../../utils/NotAuthorizedError"; +import NotFoundError from "../../utils/NotFoundError"; +import InternalServerError from "../../utils/InternalServerError"; +import ForbiddenError from "../../utils/ForbiddenError"; +import NotValidDataError from "../../utils/NotValidDataError"; +import ConflictError from "../../utils/ConflictError"; + +export const middlewareValidationFunction = ( + req: Request, + res: Response, + next: NextFunction +) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + next(); +}; + +export const middlewareErrorHandler = ( + error: Error, + _req: Request, + res: Response, + _next: NextFunction +) => { + console.log("Express route error: ", error); + + if ( + error instanceof NotAuthorizedError || + error instanceof ForbiddenError || + error instanceof NotFoundError || + error instanceof InternalServerError || + error instanceof NotValidDataError || + error instanceof ConflictError + ) { + return res.status(error.code).send(error.message); + } + + res.status(500).send("Server error"); +}; diff --git a/backend/models/file-model.ts b/backend/models/file-model.ts new file mode 100644 index 0000000..33ca3c5 --- /dev/null +++ b/backend/models/file-model.ts @@ -0,0 +1,112 @@ +import mongoose, { Document } from "mongoose"; +import { Binary, ObjectId } from "mongodb"; + +const fileSchema = new mongoose.Schema({ + length: { + type: Number, + required: true, + }, + chunkSize: { + type: Number, + }, + uploadDate: { + type: Date, + required: true, + }, + filename: { + type: String, + required: true, + validate(value: any) { + if (!value || value.length === 0 || value.length >= 256) { + throw new Error( + "Filename is required and length must be greater than 0 and 256 characters max" + ); + } + }, + }, + metadata: { + type: { + owner: { + type: String, + required: true, + }, + parent: { + type: String, + required: true, + }, + parentList: { + type: String, + required: true, + }, + hasThumbnail: { + type: Boolean, + required: true, + }, + isVideo: { + type: Boolean, + required: true, + }, + thumbnailID: String, + size: { + type: Number, + required: true, + }, + IV: { + type: Buffer, + required: true, + }, + linkType: String, + link: String, + filePath: String, + s3ID: String, + personalFile: Boolean, + trashed: Boolean, + processingFile: Boolean, + }, + required: true, + }, +}); + +export interface FileMetadateInterface { + owner: string; + parent: string; + parentList: string; + hasThumbnail: boolean; + isVideo: boolean; + thumbnailID?: string; + size: number; + IV: Buffer; + linkType?: "one" | "public"; + link?: string; + filePath?: string; + s3ID?: string; + personalFile?: boolean; + trashed?: boolean | null; + processingFile?: boolean; +} + +export interface FileInterface + extends mongoose.Document { + length: number; + chunkSize: number; + uploadDate: string; + filename: string; + lastErrorObject: { updatedExisting: any }; + metadata: FileMetadateInterface; +} + +fileSchema.index( + { "metadata.owner": 1, "metadata.parent": 1, filename: 1 }, + { collation: { locale: "en", strength: 2 }, background: true } +); +fileSchema.index( + { "metadata.owner": 1, "metadata.parent": 1, uploadDate: 1 }, + { background: true } +); +fileSchema.index({ "metadata.trashed": 1 }, { background: true }); +fileSchema.index({ "metadata.hasThumbnail": 1 }, { background: true }); + +const File = mongoose.model("fs.files", fileSchema); + +export default File; +module.exports = File; diff --git a/backend/models/file-system-model.ts b/backend/models/file-system-model.ts new file mode 100644 index 0000000..aaeebd6 --- /dev/null +++ b/backend/models/file-system-model.ts @@ -0,0 +1,69 @@ +import mongoose, {Document} from "mongoose"; + +const fileSystemSchema = new mongoose.Schema({ + + name: { + type: String, + required: true, + }, + owner: { + type: String, + required: true + }, + path: { + type: String, + required: true + }, + parent: { + type: String, + required: true, + }, + parentList: { + type: Array, + required: true + }, + hasThumbnail: { + type: Boolean, + required: true + }, + thumbnailID: { + type: String + }, + originalSize: { + type: Number, + required: true + }, + size: { + type: Number, + required: true + }, + isVideo: { + type: Boolean, + required: true + }, + IV: { + type: Buffer, + required: true + } + +}, { + timestamps: true +}) + +export interface FileSystemInterface extends Document { + name: string, + owner: string, + path: string, + parent: string, + parentList: string[], + hasThumbnail: boolean, + thumbnailID?: string, + originalSize: number, + size: number, + isVideo: boolean, + IV: Buffer +} + +const FileSystem = mongoose.model("FileSystem", fileSystemSchema); + +export default FileSystem; \ No newline at end of file diff --git a/backend/models/folder-model.ts b/backend/models/folder-model.ts new file mode 100644 index 0000000..db7d6e7 --- /dev/null +++ b/backend/models/folder-model.ts @@ -0,0 +1,57 @@ +import mongoose, { Document } from "mongoose"; + +const folderSchema = new mongoose.Schema( + { + name: { + type: String, + required: true, + validate(value: any) { + if (!value || value.length === 0 || value.length >= 256) { + throw new Error( + "Name is required and length must be greater than 0 and 256 characters max" + ); + } + }, + }, + parent: { + type: String, + required: true, + }, + owner: { + type: String, + required: true, + }, + parentList: { + type: Array, + required: true, + }, + personalFolder: Boolean, + trashed: Boolean, + }, + { + timestamps: true, + } +); + +export interface FolderInterface + extends mongoose.Document { + name: string; + parent: string; + owner: string; + createdAt: Date; + updatedAt: Date; + parentList: string[]; + _doc?: any; + personalFolder?: boolean; + trashed: boolean | null; +} + +folderSchema.index({ createdAt: 1 }, { background: true }); +folderSchema.index({ owner: 1 }, { background: true }); +folderSchema.index({ trashed: 1 }, { background: true }); +folderSchema.index({ parent: 1 }, { background: true }); +folderSchema.index({ name: 1 }, { background: true }); + +const Folder = mongoose.model("Folder", folderSchema); + +export default Folder; diff --git a/backend/models/thumbnail-model.ts b/backend/models/thumbnail-model.ts new file mode 100644 index 0000000..a7cf2c7 --- /dev/null +++ b/backend/models/thumbnail-model.ts @@ -0,0 +1,60 @@ +import mongoose, { Document } from "mongoose"; + +const thumbnailSchema = new mongoose.Schema( + { + name: { + type: String, + required: true, + validate(value: any) { + if (!value || value.length === 0 || value.length >= 256) { + throw new Error( + "Name is required and length must be greater than 0 and 256 characters max" + ); + } + }, + }, + owner: { + type: String, + required: true, + }, + + data: { + type: Buffer, + }, + path: { + type: String, + }, + + IV: { + type: Buffer, + }, + s3ID: String, + personalFile: String, + }, + { + timestamps: true, + } +); + +export interface ThumbnailInterface extends Document { + _id: any; + name: string; + owner: string; + data?: any; + path?: string; + IV: Buffer; + s3ID?: string; + personalFile?: boolean; + createdAt: Date; + updatedAt: Date; +} + +thumbnailSchema.index({ owner: 1 }); + +const Thumbnail = mongoose.model( + "Thumbnail", + thumbnailSchema +); + +export default Thumbnail; +module.exports = Thumbnail; diff --git a/backend/models/user-model.ts b/backend/models/user-model.ts new file mode 100644 index 0000000..2881e59 --- /dev/null +++ b/backend/models/user-model.ts @@ -0,0 +1,464 @@ +import mongoose, { Document } from "mongoose"; +import validator from "validator"; +import crypto from "crypto"; +import bcrypt from "bcryptjs"; +import jwt from "jsonwebtoken"; +import env from "../enviroment/env"; +import NotAuthorizedError from "../utils/NotAuthorizedError"; + +const userSchema = new mongoose.Schema( + { + email: { + type: String, + required: true, + trim: true, + unique: true, + lowercase: true, + validate(value: any): any { + if (!validator.isEmail(value)) { + throw new Error("Email is invalid"); + } else if (value.length > 320) { + throw new Error("Email length must be less than 320 characters"); + } else if (value.length < 3) { + throw new Error("Email length must be at least 3 characters"); + } + }, + }, + password: { + type: String, + trim: true, + required: true, + validate(value: any): any { + if (value.length < 6) { + throw new Error("Password length must be at least 6 characters"); + } else if (value.length > 256) { + throw new Error("Password length must be less than 256 characters"); + } + }, + }, + tokens: [ + { + token: { + type: String, + required: true, + }, + uuid: { + type: String, + required: true, + }, + time: { + type: Number, + required: true, + }, + }, + ], + tempTokens: [ + { + token: { + type: String, + required: true, + }, + uuid: { + type: String, + required: true, + }, + time: { + type: Number, + required: true, + }, + }, + ], + privateKey: { + type: String, + }, + publicKey: { + type: String, + }, + emailVerified: { + type: Boolean, + }, + emailToken: { + type: String, + }, + passwordResetToken: { + type: String, + }, + passwordLastModified: Number, + }, + { + timestamps: true, + } +); + +export interface UserInterface extends Document { + _id: mongoose.Types.ObjectId; + email: string; + password: string; + tokens: any[]; + tempTokens: any[]; + privateKey?: string; + publicKey?: string; + token?: string; + emailVerified?: boolean; + emailToken?: string; + passwordResetToken?: string; + passwordLastModified?: number; + + getEncryptionKey: () => Buffer | undefined; + generateTempAuthToken: () => Promise; + encryptToken: (tempToken: any, key: any, publicKey: any) => any; + decryptToken: (encryptedToken: any, key: any, publicKey: any) => any; + findByCreds: (email: string, password: string) => Promise; + generateAuthToken: ( + uuid: string | undefined + ) => Promise<{ accessToken: string; refreshToken: string }>; + generateAuthTokenStreamVideo: (uuid: string | undefined) => Promise; + generateEncryptionKeys: () => Promise; + changeEncryptionKey: (randomKey: Buffer) => Promise; + generateEmailVerifyToken: () => Promise; + generatePasswordResetToken: () => Promise; +} + +const maxAgeAccess = 60 * 1000 * 20 + 1000 * 60; +const maxAgeRefresh = 60 * 1000 * 60 * 24 * 30 + 1000 * 60; + +const maxAgeAccessStreamVideo = 60 * 1000 * 60 * 24; + +userSchema.pre("save", async function (this: any, next: any) { + const user = this; + + if (user.isModified("password")) { + user.password = await bcrypt.hash(user.password, 8); + } + + next(); +}); + +userSchema.statics.findByCreds = async (email: string, password: string) => { + const user = await User.findOne({ email }); + + if (!user) { + throw new NotAuthorizedError("User not found"); + } + + const isMatch = await bcrypt.compare(password, user.password); + + if (!isMatch) { + throw new NotAuthorizedError("Incorrect password"); + } + + return user; +}; + +userSchema.methods.toJSON = function () { + const user = this; + + const userObject = user.toObject(); + + delete userObject.password; + delete userObject.tokens; + delete userObject.tempTokens; + delete userObject.privateKey; + delete userObject.publicKey; + + if (env.emailVerification !== "true") { + delete userObject.emailVerified; + } else { + userObject.emailVerified = user.emailVerified || false; + } + + return userObject; +}; + +userSchema.methods.generateAuthTokenStreamVideo = async function ( + uuid: string | undefined +) { + const iv = crypto.randomBytes(16); + + const user = this; + + const date = new Date(); + const time = date.getTime(); + + let accessTokenStreamVideo = jwt.sign( + { _id: user._id.toString(), iv, time }, + env.passwordAccess!, + { expiresIn: maxAgeAccessStreamVideo.toString() } + ); + + const encryptionKey = user.getEncryptionKey(); + + const encryptedToken = user.encryptToken( + accessTokenStreamVideo, + encryptionKey, + iv + ); + + uuid = uuid ? uuid : "unknown"; + + await User.updateOne( + { _id: user._id }, + { $push: { tempTokens: { token: encryptedToken, uuid, time } } } + ); + + return accessTokenStreamVideo; +}; + +userSchema.methods.generateAuthToken = async function ( + uuid: string | undefined +) { + const iv = crypto.randomBytes(16); + + const user = this; + + const date = new Date(); + const time = date.getTime(); + + const userObj = { + _id: user._id, + emailVerified: user.emailVerified, + email: user.email, + }; + + let accessToken = jwt.sign({ user: userObj, iv }, env.passwordAccess!, { + expiresIn: maxAgeAccess.toString(), + }); + let refreshToken = jwt.sign( + { _id: user._id.toString(), iv, time }, + env.passwordRefresh!, + { expiresIn: maxAgeRefresh.toString() } + ); + + const encryptionKey = user.getEncryptionKey(); + + const encryptedToken = user.encryptToken(refreshToken, encryptionKey, iv); + + uuid = uuid ? uuid : "unknown"; + + await User.updateOne( + { _id: user._id }, + { $push: { tokens: { token: encryptedToken, uuid, time } } } + ); + + return { accessToken, refreshToken }; +}; + +userSchema.methods.encryptToken = function ( + token: string, + key: string, + iv: any +) { + iv = Buffer.from(iv, "hex"); + + const TOKEN_CIPHER_KEY = crypto.createHash("sha256").update(key).digest(); + const cipher = crypto.createCipheriv("aes-256-cbc", TOKEN_CIPHER_KEY, iv); + const encryptedText = cipher.update(token); + + return Buffer.concat([encryptedText, cipher.final()]).toString("hex"); +}; + +userSchema.methods.decryptToken = function ( + encryptedToken: any, + key: string, + iv: any +) { + encryptedToken = Buffer.from(encryptedToken, "hex"); + iv = Buffer.from(iv, "hex"); + + const TOKEN_CIPHER_KEY = crypto.createHash("sha256").update(key).digest(); + const decipher = crypto.createDecipheriv("aes-256-cbc", TOKEN_CIPHER_KEY, iv); + + const tokenDecrypted = decipher.update(encryptedToken); + + return Buffer.concat([tokenDecrypted, decipher.final()]).toString(); +}; + +userSchema.methods.generateEncryptionKeys = async function () { + const user = this; + const userPassword = user.password; + const masterPassword = env.key!; + + const randomKey = crypto.randomBytes(32); + + const iv = crypto.randomBytes(16); + const USER_CIPHER_KEY = crypto + .createHash("sha256") + .update(userPassword) + .digest(); + const cipher = crypto.createCipheriv("aes-256-cbc", USER_CIPHER_KEY, iv); + let encryptedText = cipher.update(randomKey); + encryptedText = Buffer.concat([encryptedText, cipher.final()]); + + const MASTER_CIPHER_KEY = crypto + .createHash("sha256") + .update(masterPassword) + .digest(); + const masterCipher = crypto.createCipheriv( + "aes-256-cbc", + MASTER_CIPHER_KEY, + iv + ); + const masterEncryptedText = masterCipher.update(encryptedText); + + user.privateKey = Buffer.concat([ + masterEncryptedText, + masterCipher.final(), + ]).toString("hex"); + user.publicKey = iv.toString("hex"); + + await user.save(); +}; + +userSchema.methods.getEncryptionKey = function () { + try { + const user = this; + const userPassword = user.password; + const masterEncryptedText = user.privateKey; + const masterPassword = env.key!; + const iv = Buffer.from(user.publicKey, "hex"); + + const USER_CIPHER_KEY = crypto + .createHash("sha256") + .update(userPassword) + .digest(); + const MASTER_CIPHER_KEY = crypto + .createHash("sha256") + .update(masterPassword) + .digest(); + + const unhexMasterText = Buffer.from(masterEncryptedText, "hex"); + const masterDecipher = crypto.createDecipheriv( + "aes-256-cbc", + MASTER_CIPHER_KEY, + iv + ); + let masterDecrypted = masterDecipher.update(unhexMasterText); + masterDecrypted = Buffer.concat([masterDecrypted, masterDecipher.final()]); + + let decipher = crypto.createDecipheriv("aes-256-cbc", USER_CIPHER_KEY, iv); + let decrypted = decipher.update(masterDecrypted); + decrypted = Buffer.concat([decrypted, decipher.final()]); + + return decrypted; + } catch (e) { + console.log("Get Encryption Key Error", e); + return undefined; + } +}; + +userSchema.methods.changeEncryptionKey = async function (randomKey: Buffer) { + const user = this; + const userPassword = user.password; + const masterPassword = env.key!; + + const iv = crypto.randomBytes(16); + const USER_CIPHER_KEY = crypto + .createHash("sha256") + .update(userPassword) + .digest(); + const cipher = crypto.createCipheriv("aes-256-cbc", USER_CIPHER_KEY, iv); + let encryptedText = cipher.update(randomKey); + encryptedText = Buffer.concat([encryptedText, cipher.final()]); + + const MASTER_CIPHER_KEY = crypto + .createHash("sha256") + .update(masterPassword) + .digest(); + const masterCipher = crypto.createCipheriv( + "aes-256-cbc", + MASTER_CIPHER_KEY, + iv + ); + const masterEncryptedText = masterCipher.update(encryptedText); + + user.privateKey = Buffer.concat([ + masterEncryptedText, + masterCipher.final(), + ]).toString("hex"); + user.publicKey = iv.toString("hex"); + + await user.save(); +}; + +userSchema.methods.generateTempAuthToken = async function () { + const iv = crypto.randomBytes(16); + + const user = this as UserInterface; + const token = jwt.sign( + { _id: user._id.toString(), iv }, + env.passwordAccess!, + { expiresIn: "3000ms" } + ); + + const encryptionKey = user.getEncryptionKey(); + const encryptedToken = user.encryptToken(token, encryptionKey, iv); + + user.tempTokens = user.tempTokens.concat({ token: encryptedToken }); + + await user.save(); + return token; +}; + +userSchema.methods.generateEmailVerifyToken = async function () { + const iv = crypto.randomBytes(16); + + const user = this as UserInterface; + const token = jwt.sign( + { _id: user._id.toString(), iv }, + env.passwordAccess!, + { expiresIn: "1d" } + ); + + const encryptionKey = user.getEncryptionKey(); + const encryptedToken = user.encryptToken(token, encryptionKey, iv); + + user.emailToken = encryptedToken; + + await user.save(); + return token; +}; + +userSchema.methods.generatePasswordResetToken = async function () { + const iv = crypto.randomBytes(16); + + const user = this as UserInterface; + const token = jwt.sign( + { _id: user._id.toString(), iv }, + env.passwordAccess!, + { expiresIn: "1h" } + ); + + const encryptionKey = user.getEncryptionKey(); + const encryptedToken = user.encryptToken(token, encryptionKey, iv); + + user.passwordResetToken = encryptedToken; + + await user.save(); + return token; +}; + +userSchema.methods.generateTempAuthTokenVideo = async function ( + cookie: string +) { + const iv = crypto.randomBytes(16); + + const user = this; + const token = jwt.sign( + { _id: user._id.toString(), cookie, iv }, + env.passwordAccess!, + { expiresIn: "5h" } + ); + + const encryptionKey = user.getEncryptionKey(); + const encryptedToken = user.encryptToken(token, encryptionKey, iv); + + user.tempTokens = user.tempTokens.concat({ token: encryptedToken }); + + await user.save(); + return token; +}; + +const User = mongoose.model("User", userSchema); + +export default User; +module.exports = User; diff --git a/backend/server/server-start.ts b/backend/server/server-start.ts new file mode 100644 index 0000000..94b64e2 --- /dev/null +++ b/backend/server/server-start.ts @@ -0,0 +1,35 @@ +import getEnvVariables from "../enviroment/get-env-variables"; +getEnvVariables(); +import getKey from "../key/get-key"; +import servers from "./server"; + +const { server, serverHttps } = servers; + +const serverStart = async () => { + await getKey(); + + console.log("ENV", process.env.NODE_ENV); + + const httpPort = process.env.HTTP_PORT || process.env.PORT || 3000; + const httpsPort = process.env.HTTPS_PORT || 8080 + + if (process.env.NODE_ENV === "production" && process.env.SSL === "true") { + server.listen(httpPort, process.env.URL, () => { + console.log("Http Server Running On Port:", httpPort); + }); + + serverHttps.listen(httpsPort, function () { + console.log("Https Server Running On Port:", httpsPort); + }); + } else if (process.env.NODE_ENV === "production") { + server.listen(httpPort, process.env.URL, () => { + console.log("Http Server (No-SSL) Running On Port:", httpPort); + }); + } else { + server.listen(httpPort, process.env.URL, () => { + console.log("\nDevelopment Backend Server Running On :", httpPort); + }); + } +}; + +serverStart(); diff --git a/backend/server/server.ts b/backend/server/server.ts new file mode 100644 index 0000000..49d31f5 --- /dev/null +++ b/backend/server/server.ts @@ -0,0 +1,82 @@ +import express, { Request, Response } from "express"; +import path from "path"; +import userRouter from "../express-routers/user-router"; +import fileRouter from "../express-routers/file-router"; +import folderRouter from "../express-routers/folder-router"; +import bodyParser from "body-parser"; +import https from "https"; +import fs from "fs"; +import helmet from "helmet"; +import busboy from "connect-busboy"; +import compression from "compression"; +import http from "http"; +import cookieParser from "cookie-parser"; +import env from "../enviroment/env"; +import { middlewareErrorHandler } from "../middleware/utils/middleware-utils"; +import cors from "cors"; +// import requestIp from "request-ip"; + +const app = express(); +const publicPath = path.join(__dirname, "..", "..", "dist-frontend"); + +let server: any; +let serverHttps: any; + +if (process.env.SSL === "true") { + const certPath = env.httpsCrtPath || "certificate.crt" + const caPath = env.httpsCaPath || "certificate.ca-bundle" + const keyPath = env.httpsKeyPath || "certificate.key" + const cert = fs.readFileSync(certPath); + const ca = fs.readFileSync(caPath); + const key = fs.readFileSync(keyPath); + + const options = { + cert, + ca, + key, + }; + + serverHttps = https.createServer(options, app); +} + +server = http.createServer(app); + +require("../db/connections/mongoose"); + +app.use(cors()); +app.use(cookieParser(env.passwordCookie)); +app.use(helmet()); +app.use(compression()); +app.use(express.json()); +app.use(express.static(publicPath, { index: false })); +app.use(bodyParser.json({ limit: "50mb" })); +app.use( + bodyParser.urlencoded({ + limit: "50mb", + extended: true, + parameterLimit: 50000, + }) +); +// app.use(requestIp.mw()); + +app.use( + busboy({ + highWaterMark: 2 * 1024 * 1024, + }) +); + +app.use(userRouter, fileRouter, folderRouter); + +app.use(middlewareErrorHandler); + +//const nodeMode = process.env.NODE_ENV ? "Production" : "Development/Testing"; + +//console.log("Node Enviroment Mode:", nodeMode); + +if (process.env.NODE_ENV === "production") { + app.get("*", (_: Request, res: Response) => { + res.sendFile(path.join(publicPath, "index.html")); + }); +} + +export default { server, serverHttps }; diff --git a/backend/services/chunk-service/actions/S3-actions.ts b/backend/services/chunk-service/actions/S3-actions.ts new file mode 100644 index 0000000..4d3465a --- /dev/null +++ b/backend/services/chunk-service/actions/S3-actions.ts @@ -0,0 +1,92 @@ +import s3 from "../../../db/connections/s3"; +import env from "../../../enviroment/env"; +import { GenericParams, IStorageActions } from "../store-types"; +import internal, { EventEmitter } from "stream"; +import { PassThrough } from "stream"; +import stream from "stream"; + +class S3Actions implements IStorageActions { + getAuth() { + return { s3Storage: s3, bucket: env.s3Bucket! }; + } + + createReadStream(params: GenericParams): internal.Readable { + if (!params.Key) throw new Error("S3 not configured"); + const { s3Storage, bucket } = this.getAuth(); + const s3ReadableStream = s3Storage + .getObject({ Key: params.Key, Bucket: bucket }) + .createReadStream(); + return s3ReadableStream; + } + + createReadStreamWithRange( + params: GenericParams, + start: number, + end: number + ): internal.Readable { + if (!params.Key) throw new Error("S3 not configured"); + const range = `bytes=${start}-${end}`; + const { s3Storage, bucket } = this.getAuth(); + const s3ReadableStream = s3Storage + .getObject({ Key: params.Key, Bucket: bucket, Range: range }) + .createReadStream(); + return s3ReadableStream; + } + + removeChunks(params: GenericParams) { + return new Promise((resolve, reject) => { + if (!params.Key) { + reject("S3 not configured"); + return; + } + const { s3Storage, bucket } = this.getAuth(); + s3Storage.deleteObject({ Key: params.Key, Bucket: bucket }, (err) => { + if (err) { + reject("Error removing file"); + return; + } + resolve(); + }); + }); + } + getPrevIV(params: GenericParams, start: number) { + return new Promise((resolve, reject) => { + if (!params.Key) throw new Error("S3 not configured"); + const { s3Storage, bucket } = this.getAuth(); + const range = `bytes=${start}-${start + 15}`; + + const stream = s3Storage + .getObject({ Key: params.Key, Bucket: bucket, Range: range }) + .createReadStream(); + + stream.on("data", (data) => { + resolve(data); + }); + }); + } + createWriteStream = ( + params: GenericParams, + readStream: NodeJS.ReadableStream, + randomID: string + ) => { + const passThrough = new stream.PassThrough(); + const emitter = new EventEmitter(); + + const { s3Storage, bucket } = this.getAuth(); + + s3Storage.upload( + { Bucket: bucket, Body: passThrough, Key: randomID }, + (err) => { + if (err) { + emitter.emit("error", err); + return; + } + emitter.emit("finish"); + } + ); + + return { writeStream: passThrough, emitter }; + }; +} + +export { S3Actions }; diff --git a/backend/services/chunk-service/actions/file-system-actions.ts b/backend/services/chunk-service/actions/file-system-actions.ts new file mode 100644 index 0000000..619d3fa --- /dev/null +++ b/backend/services/chunk-service/actions/file-system-actions.ts @@ -0,0 +1,72 @@ +import fs from "fs"; +import { UserInterface } from "../../../models/user-model"; +import { GenericParams, IStorageActions } from "../store-types"; +import env from "../../../enviroment/env"; +import { getFSStoragePath } from "../../../utils/getFSStoragePath"; + +class FilesystemActions implements IStorageActions { + async getAuth() { + return {}; + } + + createReadStream(params: GenericParams): NodeJS.ReadableStream { + if (!params.filePath) throw new Error("File path not configured"); + const fsReadableStream = fs.createReadStream(params.filePath); + return fsReadableStream; + } + createReadStreamWithRange(params: GenericParams, start: number, end: number) { + if (!params.filePath) throw new Error("File path not configured"); + const fsReadableStream = fs.createReadStream(params.filePath, { + start, + end, + }); + return fsReadableStream; + } + removeChunks(params: GenericParams) { + return new Promise((resolve, reject) => { + if (!params.filePath) { + reject("File path not configured"); + return; + } + fs.unlink(params.filePath, (err) => { + if (err) { + reject("Error removing file"); + return; + } + + resolve(); + }); + }); + } + getPrevIV(params: GenericParams, start: number) { + return new Promise((resolve, reject) => { + if (!params.filePath) throw new Error("File path not configured"); + const stream = fs.createReadStream(params.filePath, { + start, + end: start + 15, + }); + + stream.on("data", (data) => { + resolve(data); + }); + }); + } + uploadFile = (params: GenericParams, stream: NodeJS.ReadableStream) => { + return new Promise((resolve, reject) => { + resolve(); + }); + }; + createWriteStream = ( + params: GenericParams, + stream: NodeJS.ReadableStream, + randomID: string + ) => { + const path = `${getFSStoragePath()}${randomID}`; + return { + writeStream: fs.createWriteStream(path), + emitter: null, + }; + }; +} + +export { FilesystemActions }; diff --git a/backend/services/chunk-service/actions/helper-actions.ts b/backend/services/chunk-service/actions/helper-actions.ts new file mode 100644 index 0000000..5493d3d --- /dev/null +++ b/backend/services/chunk-service/actions/helper-actions.ts @@ -0,0 +1,11 @@ +import { S3Actions } from "../actions/S3-actions"; +import { FilesystemActions } from "../actions/file-system-actions"; +import env from "../../../enviroment/env"; + +export const getStorageActions = () => { + if (env.dbType === "s3") { + return new S3Actions(); + } else { + return new FilesystemActions(); + } +}; diff --git a/backend/services/chunk-service/chunk-service.ts b/backend/services/chunk-service/chunk-service.ts new file mode 100644 index 0000000..257e347 --- /dev/null +++ b/backend/services/chunk-service/chunk-service.ts @@ -0,0 +1,598 @@ +import { Response, Request, NextFunction } from "express"; +import { UserInterface } from "../../models/user-model"; +import NotAuthorizedError from "../../utils/NotAuthorizedError"; +import NotFoundError from "../../utils/NotFoundError"; +import crypto from "crypto"; +import uploadFileToStorage from "./utils/getBusboyData"; +import videoChecker from "../../utils/videoChecker"; +import uuid from "uuid"; +import { FileInterface, FileMetadateInterface } from "../../models/file-model"; +import FileDB from "../../db/mongoDB/fileDB"; +import FolderDB from "../../db/mongoDB/folderDB"; +import env from "../../enviroment/env"; +import fixStartChunkLength from "./utils/fixStartChunkLength"; +import { FolderInterface } from "../../models/folder-model"; +import ForbiddenError from "../../utils/ForbiddenError"; +import { S3Actions } from "./actions/S3-actions"; +import { FilesystemActions } from "./actions/file-system-actions"; +import { createGenericParams } from "./utils/storageHelper"; +import { Readable } from "stream"; +import ThumbnailDB from "../../db/mongoDB/thumbnailDB"; +import UserDB from "../../db/mongoDB/userDB"; +import fixEndChunkLength from "./utils/fixEndChunkLength"; +import archiver from "archiver"; +import async from "async"; +import getFolderBusboyData from "./utils/getFolderUploadBusboyData"; +import { getStorageActions } from "./actions/helper-actions"; +import getThumbnailData from "./utils/getThumbnailData"; +import getFileData from "./utils/getFileData"; +import getPublicFileData from "./utils/getPublicFileData"; + +const fileDB = new FileDB(); +const folderDB = new FolderDB(); +const thumbnailDB = new ThumbnailDB(); +const userDB = new UserDB(); + +const storageActions = getStorageActions(); + +class StorageService { + constructor() {} + + uploadFile = async (user: UserInterface, busboy: any, req: Request) => { + const { parent, file } = await uploadFileToStorage(busboy, user, req); + + const parentList = []; + + if (parent !== "/") { + const parentFolder = await folderDB.getFolderInfo( + parent, + user._id.toString() + ); + if (!parentFolder) throw new NotFoundError("Parent Folder Not Found"); + parentList.push(...parentFolder.parentList, parentFolder._id); + } else { + parentList.push("/"); + } + + await fileDB.updateFileUploadedFile( + file._id!.toString(), + user._id.toString(), + parent, + parentList.toString() + ); + + return file; + }; + + uploadFolder = async (user: UserInterface, busboy: any, req: Request) => { + const { fileDataMap, parent } = await getFolderBusboyData( + busboy, + user, + req + ); + + const keys = Object.keys(fileDataMap); + + const folderPathsToCreate: Record = {}; + + const parentList = []; + + if (parent !== "/") { + const parentFolder = await folderDB.getFolderInfo( + parent, + user._id.toString() + ); + if (!parentFolder) throw new NotFoundError("Parent Folder Not Found"); + parentList.push(...parentFolder.parentList, parentFolder._id.toString()); + } else { + parentList.push("/"); + } + + const parentName = fileDataMap[keys[0]].path.split("/")[0]; + + const rootFolder = await folderDB.createFolder({ + name: parentName, + parent: parentList[parentList.length - 1], + owner: user._id.toString(), + parentList: parentList, + }); + + parentList.push(rootFolder._id.toString()); + + for (const key of keys) { + const pathSplit = fileDataMap[key].path.split("/"); + const path = pathSplit.slice(1, pathSplit.length - 1).join("/"); + + if (path && !folderPathsToCreate[path]) { + folderPathsToCreate[path] = true; + } + } + + const sortedFolderPaths = Object.keys(folderPathsToCreate).sort(); + + const foldersCreated: Record = {}; + for (const folderPath of sortedFolderPaths) { + const tempParentList = []; + const subFolders = folderPath.split("/"); + + for (let i = 0; i < subFolders.length; i++) { + const parentDirectory = subFolders.slice(0, i).join("/"); + if (tempParentList.length === 0) { + tempParentList.push(...parentList); + } + + if (parentDirectory && foldersCreated[parentDirectory]) { + tempParentList.push( + foldersCreated[parentDirectory]._id!.toString() + ); + } + + const folderToCreate = subFolders[i]; + const tmpPath = (parentDirectory) + ? [parentDirectory, folderToCreate].join("/") + : folderToCreate; + + if (foldersCreated[tmpPath]) { + continue; + } + + const folder = await folderDB.createFolder({ + name: folderToCreate, + parent: tempParentList[tempParentList.length - 1], + owner: user._id.toString(), + parentList: tempParentList, + }); + + foldersCreated[tmpPath] = folder; + } + } + + for (const key of keys) { + const currentFile = fileDataMap[key]; + const parentSplit = currentFile.path.split("/"); + const parentDirectory = parentSplit + .slice(1, parentSplit.length - 1) + .join("/"); + + const currentParent = (parentDirectory && foldersCreated[parentDirectory]) + ? foldersCreated[parentDirectory] : rootFolder; + + await fileDB.updateFolderUploadedFile( + currentFile.uploadedFileId, + user._id.toString(), + currentParent._id!.toString(), + [...currentParent.parentList, currentParent._id!.toString()].toString() + ); + } + }; + + downloadFile = async (user: UserInterface, fileID: string, res: Response) => { + await getFileData(res, fileID, user); + }; + + downloadZip = async ( + userID: string, + folderIDs: string[], + fileIDs: string[], + res: Response + ) => { + const archive = archiver("zip", { + zlib: { level: 9 }, + }); + + const user = await userDB.getUserInfo(userID); + + if (!user) throw new NotFoundError("User Not Found"); + + const password = user.getEncryptionKey(); + + if (!password) throw new ForbiddenError("Invalid Encryption Key"); + + res.set("Content-Type", "application/zip"); + res.set( + "Content-Disposition", + `attachment; filename="myDrive-${new Date().toISOString()}.zip"` + ); + + archive.on("error", (e: Error) => { + console.log("archive error", e); + }); + + archive.pipe(res); + + const parentInfoMap = new Map(); + const previouslyUsedFileNames = new Map(); + + const getParentInfo = async (parentID: string) => { + if (parentInfoMap.has(parentID)) { + return parentInfoMap.get(parentID)!; + } + + const parentFolder = await folderDB.getFolderInfo(parentID, userID); + + if (!parentFolder) { + throw new NotFoundError("Parent Folder Not Found Error"); + } + + parentInfoMap.set(parentID, { + name: parentFolder.name, + }); + + return parentInfoMap.get(parentID)!; + }; + + const getFileName = (file: FileInterface, parentID: string) => { + const key = `${parentID}/${file.filename}`; + + if (!previouslyUsedFileNames.has(key)) { + previouslyUsedFileNames.set(key, 1); + return file.filename; + } else { + const counter = previouslyUsedFileNames.get(key)!; + const extensionSplit = file.filename.split("."); + const extension = extensionSplit[extensionSplit.length - 1]; + + const filenameWithoutExtension = extensionSplit.slice(0, -1).join("."); + + previouslyUsedFileNames.set(key, +counter + 1); + + return `${filenameWithoutExtension}-${counter}${ + extension ? `.${extension}` : "" + }`; + } + }; + + const formatName = (name: string) => { + return name.replace(/[/\\?%*:|"<>]/g, "-").trim(); + }; + + const processFile = async (file: FileInterface, directory: string) => { + return new Promise((resolve, reject) => { + const IV = file.metadata.IV; + + const readStreamParams = createGenericParams({ + filePath: file.metadata.filePath, + Key: file.metadata.s3ID, + }); + + const readStream = storageActions.createReadStream(readStreamParams); + + readStream.on("error", reject); + + const CIPHER_KEY = crypto + .createHash("sha256") + .update(password) + .digest(); + + const decipher = crypto.createDecipheriv("aes256", CIPHER_KEY, IV); + + decipher.on("error", reject); + + archive.append(readStream.pipe(decipher), { name: directory }); + + readStream.on("end", () => { + resolve(); + }); + }); + }; + + const queue = async.queue(async (task: Function, callback: Function) => { + try { + await task(); + callback(); + } catch (e) { + console.log("queue error", e); + } + }, 4); + + for (const folderID of folderIDs) { + queue.push(async () => { + const folder = await folderDB.getFolderInfo(folderID, userID); + + if (!folder) throw new NotFoundError("Folder Info Not Found Error"); + + const parentList = [...folder.parentList, folder._id]; + + const files = await fileDB.getFileListByIncludedParent( + userID, + parentList.toString() + ); + + for (const file of files) { + const fileParent = await folderDB.getFolderInfo( + file.metadata.parent, + userID + ); + + if (!fileParent) + throw new NotFoundError("File Parent Not Found Error"); + + let directory = ""; + + const parentSplit = file.metadata.parentList.split(","); + + for (const parent of parentSplit) { + if (parent === "/") continue; + + const parentInfo = await getParentInfo(parent); + + directory += formatName(parentInfo.name) + "/"; + } + + const fileName = formatName(getFileName(file, file.metadata.parent)); + + directory += fileName; + + await processFile(file, directory); + } + }); + } + + for (const fileID of fileIDs) { + queue.push(async () => { + const file = await fileDB.getFileInfo(fileID, userID); + + if (!file) throw new NotFoundError("File Info Not Found Error"); + + const fileName = formatName(getFileName(file, "/")); + + await processFile(file, fileName); + }); + } + + await queue.drain(); + archive.finalize(); + + return { archive }; + }; + + getThumbnail = async (user: UserInterface, id: string, res: Response) => { + await getThumbnailData(res, id, user); + }; + + getFullThumbnail = async ( + user: UserInterface, + fileID: string, + res: Response + ) => { + await getFileData(res, fileID, user); + }; + + streamVideo = async ( + user: UserInterface, + fileID: string, + headers: any, + res: Response + ) => { + const userID = user._id; + const currentFile = await fileDB.getFileInfo(fileID, userID.toString()); + + if (!currentFile) throw new NotFoundError("Video File Not Found"); + + const password = user.getEncryptionKey(); + + if (!password) throw new ForbiddenError("Invalid Encryption Key"); + + const fileSize = currentFile.metadata.size; + + const range = headers.range; + const parts = range.replace(/bytes=/, "").split("-"); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; + const chunksize = end - start + 1; + const IV = currentFile.metadata.IV; + + const head = { + "Content-Range": "bytes " + start + "-" + end + "/" + fileSize, + "Accept-Ranges": "bytes", + "Content-Length": chunksize, + "Content-Type": "video/mp4", + }; + + let fixedStart = 0; + let fixedEnd = fixEndChunkLength(end) - 1; + + if (start === 0 && end === 1) { + fixedStart = 0; + fixedEnd = 15; + } else { + fixedStart = start % 16 === 0 ? start : fixStartChunkLength(start); + } + + if (+start === 0) { + fixedStart = 0; + } + + let currentIV = IV; + + if (fixedStart !== 0 && start !== 0) { + const readStreamParams = createGenericParams({ + filePath: currentFile.metadata.filePath, + Key: currentFile.metadata.s3ID, + }); + currentIV = (await storageActions.getPrevIV( + readStreamParams, + fixedStart - 16 + )) as Buffer; + } + + res.writeHead(206, head); + + await getFileData(res, fileID, user, currentIV, { + start: start, + end, + chunksize, + fixedStart, + fixedEnd, + skip: start - fixedStart, + }); + }; + + getPublicDownload = async (fileID: string, tempToken: any, res: Response) => { + await getPublicFileData(res, fileID, tempToken); + }; + + deleteMulti = async ( + userID: string, + items: { + type: "file" | "folder" | "quick-item"; + id: string; + file?: FileInterface; + folder?: FolderInterface; + }[] + ) => { + const fileList = items.filter( + (item) => item.type === "file" || item.type === "quick-item" + ); + const folderList = items + .filter((item) => item.type === "folder") + .sort((a, b) => { + if (!a.folder || !b.folder) return 0; + return b.folder.parentList.length - a.folder.parentList.length; + }); + + for (const file of fileList) { + await this.deleteFile(userID, file.id); + } + for (const folder of folderList) { + await this.deleteFolder(userID, folder.id); + } + }; + + deleteFile = async (userID: string, fileID: string) => { + const file = await fileDB.getFileInfo(fileID, userID); + + if (!file) throw new NotFoundError("Delete File Not Found Error"); + + if (file.metadata.thumbnailID) { + const thumbnail = await thumbnailDB.getThumbnailInfo( + userID, + file.metadata.thumbnailID + ); + + if (!thumbnail) throw new NotFoundError("Thumbnail Not Found"); + + const removeChunksParams = createGenericParams({ + filePath: thumbnail.path, + Key: thumbnail.s3ID, + }); + + await storageActions.removeChunks(removeChunksParams); + + await thumbnailDB.removeThumbnail(userID, thumbnail._id); + } + + const removeChunksParams = createGenericParams({ + filePath: file.metadata.filePath, + Key: file.metadata.s3ID, + }); + + await storageActions.removeChunks(removeChunksParams); + await fileDB.deleteFile(fileID, userID); + }; + + deleteFolder = async (userID: string, folderID: string) => { + const folder = await folderDB.getFolderInfo(folderID, userID); + + if (!folder) throw new NotFoundError("Delete Folder Not Found Error"); + + const parentList = [...folder.parentList, folder._id]; + + await folderDB.deleteFoldersByParentList(parentList, userID); + await folderDB.deleteFolder(folderID, userID); + + const fileList = await fileDB.getFileListByIncludedParent( + userID, + parentList.toString() + ); + + if (!fileList) throw new NotFoundError("Delete File List Not Found"); + + for (let i = 0; i < fileList.length; i++) { + const currentFile = fileList[i]; + + try { + if (currentFile.metadata.thumbnailID) { + const thumbnail = await thumbnailDB.getThumbnailInfo( + userID, + currentFile.metadata.thumbnailID + ); + + if (!thumbnail) throw new NotFoundError("Thumbnail Not Found"); + + const removeChunksParams = createGenericParams({ + filePath: thumbnail.path, + Key: thumbnail.s3ID, + }); + + await storageActions.removeChunks(removeChunksParams); + + await thumbnailDB.removeThumbnail(userID, thumbnail._id); + } + + const removeChunksParams = createGenericParams({ + filePath: currentFile.metadata.filePath, + Key: currentFile.metadata.s3ID, + }); + + await storageActions.removeChunks(removeChunksParams); + await fileDB.deleteFile(currentFile._id.toString(), userID); + } catch (e) { + console.log( + "Could not delete file", + currentFile.filename, + currentFile._id + ); + } + } + }; + + deleteAll = async (userID: string) => { + await folderDB.deleteFoldersByOwner(userID); + + const fileList = await fileDB.getFileListByOwner(userID); + + if (!fileList) + throw new NotFoundError("Delete All File List Not Found Error"); + + for (let i = 0; i < fileList.length; i++) { + const currentFile = fileList[i]; + + try { + if (currentFile.metadata.thumbnailID) { + const thumbnail = await thumbnailDB.getThumbnailInfo( + userID, + currentFile.metadata.thumbnailID + ); + + if (!thumbnail) throw new NotFoundError("Thumbnail Not Found"); + + const removeChunksParams = createGenericParams({ + filePath: thumbnail.path, + Key: thumbnail.s3ID, + }); + + await storageActions.removeChunks(removeChunksParams); + + await thumbnailDB.removeThumbnail(userID, thumbnail._id); + } + + const removeChunksParams = createGenericParams({ + filePath: currentFile.metadata.filePath, + Key: currentFile.metadata.s3ID, + }); + + await storageActions.removeChunks(removeChunksParams); + await fileDB.deleteFile(currentFile._id.toString(), userID); + } catch (e) { + console.log( + "Could Not Remove File", + currentFile.filename, + currentFile._id + ); + } + } + }; +} + +export default StorageService; diff --git a/backend/services/chunk-service/store-types.ts b/backend/services/chunk-service/store-types.ts new file mode 100644 index 0000000..1bd756d --- /dev/null +++ b/backend/services/chunk-service/store-types.ts @@ -0,0 +1,16 @@ +import internal from "stream"; + +export interface GenericParams { + Key?: string; + Bucket?: string; + filePath?: string; + [key: string]: any; +} + +export interface IStorageActions { + getAuth(): Object; + createReadStream( + params: GenericParams + ): NodeJS.ReadableStream | internal.Readable; + removeChunks(params: GenericParams): Promise; +} diff --git a/backend/services/chunk-service/utils/ChunkInterface.ts b/backend/services/chunk-service/utils/ChunkInterface.ts new file mode 100644 index 0000000..d9f8079 --- /dev/null +++ b/backend/services/chunk-service/utils/ChunkInterface.ts @@ -0,0 +1,40 @@ +import { UserInterface } from "../../../models/user-model"; +import { FileInterface } from "../../../models/file-model"; +import { Request, Response } from "express"; +import { FolderInterface } from "../../../models/folder-model"; +import crypto from "crypto"; + +interface ChunkInterface { + uploadFile: ( + user: UserInterface, + busboy: any, + req: Request + ) => Promise; + downloadFile: (user: UserInterface, fileID: string, res: Response) => void; + getThumbnail: (user: UserInterface, id: string) => Promise; + getFullThumbnail: ( + user: UserInterface, + fileID: string, + res: Response + ) => void; + getPublicDownload: (fileID: string, tempToken: any, res: Response) => void; + streamVideo: ( + user: UserInterface, + fileID: string, + headers: any, + res: Response, + req: Request + ) => void; + getFileReadStream: (user: UserInterface, fileID: string) => any; + trashMulti: ( + userID: string, + items: { + type: "file" | "folder"; + id: string; + file?: FileInterface; + folder?: FolderInterface; + }[] + ) => Promise; +} + +export default ChunkInterface; diff --git a/backend/services/chunk-service/utils/awaitStream.ts b/backend/services/chunk-service/utils/awaitStream.ts new file mode 100644 index 0000000..ee0c96d --- /dev/null +++ b/backend/services/chunk-service/utils/awaitStream.ts @@ -0,0 +1,23 @@ +const awaitStream = ( + inputSteam: any, + outputStream: any, + allStreamsToErrorCatch: any[] +) => { + return new Promise((resolve, reject) => { + allStreamsToErrorCatch.forEach((currentStream: any) => { + currentStream.on("error", (e: Error) => { + reject({ + message: "Await Stream Input Error", + code: 500, + error: e, + }); + }); + }); + + inputSteam.pipe(outputStream).on("finish", (data: T) => { + resolve(data); + }); + }); +}; + +export default awaitStream; diff --git a/backend/services/chunk-service/utils/awaitUploadStreamFS.ts b/backend/services/chunk-service/utils/awaitUploadStreamFS.ts new file mode 100644 index 0000000..785bb1a --- /dev/null +++ b/backend/services/chunk-service/utils/awaitUploadStreamFS.ts @@ -0,0 +1,33 @@ +import {Request} from "express" +import removeChunksFS from "./removeChunksFS"; + +const awaitUploadStream = (inputSteam: any, outputStream: any, req: Request, path: string, allStreamsToCatchError: any[]) => { + + return new Promise((resolve, reject) => { + + allStreamsToCatchError.forEach((currentStream: any) => { + + currentStream.on("error", (e: Error) => { + + removeChunksFS(path); + + reject({ + message: "Await Stream Input Error", + code: 500, + error: e + }) + }) + }) + + req.on("aborted", () => { + + removeChunksFS(path); + }) + + inputSteam.pipe(outputStream).on("finish", (data: T) => { + resolve(data); + }) + }) +} + +export default awaitUploadStream; \ No newline at end of file diff --git a/backend/services/chunk-service/utils/awaitUploadStreamS3.ts b/backend/services/chunk-service/utils/awaitUploadStreamS3.ts new file mode 100644 index 0000000..d9c800a --- /dev/null +++ b/backend/services/chunk-service/utils/awaitUploadStreamS3.ts @@ -0,0 +1,17 @@ +import { ManagedUpload } from "aws-sdk/clients/s3"; +import s3 from "../../../db/connections/s3"; + +const uploadStreamS3 = (params: any) => { + return new Promise((resolve, reject) => { + s3.upload(params, (err: any, data: ManagedUpload.SendData) => { + if (err) { + console.log("Amazon upload err", err); + reject("Amazon upload error"); + } + + resolve(); + }); + }); +}; + +export default uploadStreamS3; diff --git a/backend/services/chunk-service/utils/cachedSubscriptionStatuses.ts b/backend/services/chunk-service/utils/cachedSubscriptionStatuses.ts new file mode 100644 index 0000000..b6a9252 --- /dev/null +++ b/backend/services/chunk-service/utils/cachedSubscriptionStatuses.ts @@ -0,0 +1,57 @@ +// class CachedSubsriptions { + +// cachedSubscriptionStatuses: any; + +// constructor() { +// this.cachedSubscriptionStatuses = {} +// } + +// addToCachedSubscriptionStatus = (userID: string) => { +// this.cachedSubscriptionStatuses[userID] = true; +// console.log("new cached", this.cachedSubscriptionStatuses) +// } + +// checkCachedSubscriptionStatus = (userID: string) => { +// console.log("cache check", this.cachedSubscriptionStatuses) +// return this.cachedSubscriptionStatuses[userID]; +// } +// } + +// export default CachedSubsriptions; + +// const cachedSubscriptionStatuses: any = {}; + +// export default cachedSubscriptionStatuses; + +// import redis from "redis"; + +// const client = redis.createClient(); + +// export const setCachedValue = (userID: string) => { + +// const date = new Date(); + +// return new Promise((resolve, reject) => { +// client.set(userID, date.getTime().toString(), (err, res) => { +// if (err) { +// console.log("Redis key err", err); +// reject() +// } +// resolve(); +// }); +// }) +// } + +// export const checkCachedValue = (userID: string) => { + +// return new Promise((resolve, reject) => { +// client.get(userID, (err) => { +// if (err) { +// console.log("Redis key err", err); +// reject() +// } else { +// resolve(); +// } +// }) +// }) +// } \ No newline at end of file diff --git a/backend/services/chunk-service/utils/createImageThumbnail.ts b/backend/services/chunk-service/utils/createImageThumbnail.ts new file mode 100644 index 0000000..3050649 --- /dev/null +++ b/backend/services/chunk-service/utils/createImageThumbnail.ts @@ -0,0 +1,133 @@ +import crypto from "crypto"; +import Thumbnail from "../../../models/thumbnail-model"; +import sharp from "sharp"; +import { FileInterface } from "../../../models/file-model"; +import { UserInterface } from "../../../models/user-model"; +import uuid from "uuid"; +import env from "../../../enviroment/env"; +import { createGenericParams } from "./storageHelper"; +import { S3Actions } from "../actions/S3-actions"; +import { FilesystemActions } from "../actions/file-system-actions"; +import { EventEmitter } from "stream"; +import FileDB from "../../../db/mongoDB/fileDB"; +import { getStorageActions } from "../actions/helper-actions"; +import { getFSStoragePath } from "../../../utils/getFSStoragePath"; + +const fileDB = new FileDB(); + +const storageActions = getStorageActions(); + +const processData = ( + file: FileInterface, + filename: string, + user: UserInterface +) => { + const eventEmitter = new EventEmitter(); + + try { + const password = user.getEncryptionKey(); + + let CIPHER_KEY = crypto.createHash("sha256").update(password!).digest(); + + const thumbnailFilename = uuid.v4(); + + const thumbnailIV = crypto.randomBytes(16); + + const params = createGenericParams({ + filePath: file.metadata.filePath, + Key: file.metadata.s3ID, + }); + + const readStream = storageActions.createReadStream(params); + + const { writeStream, emitter } = storageActions.createWriteStream( + params, + readStream, + thumbnailFilename + ); + + const decipher = crypto.createDecipheriv( + "aes256", + CIPHER_KEY, + file.metadata.IV + ); + + const imageResize = sharp().resize(300); + + const handleFinish = async () => { + const thumbnailModel = new Thumbnail({ + name: filename, + owner: user._id, + IV: thumbnailIV, + path: getFSStoragePath() + thumbnailFilename, + s3ID: thumbnailFilename, + }); + + await thumbnailModel.save(); + + const updatedFile = await fileDB.setThumbnail( + file._id!.toString(), + thumbnailModel._id.toString() + ); + + if (!updatedFile) { + throw new Error("Thumbnail Not Set"); + } + + eventEmitter.emit("finish", updatedFile); + }; + + const handleError = (e: Error) => { + eventEmitter.emit("error", e); + }; + + readStream.on("error", handleError); + + writeStream.on("error", handleError); + + decipher.on("error", handleError); + + imageResize.on("error", handleError); + + const thumbnailCipher = crypto.createCipheriv( + "aes256", + CIPHER_KEY, + thumbnailIV + ); + + readStream + .pipe(decipher) + .pipe(imageResize) + .pipe(thumbnailCipher) + .pipe(writeStream); + + if (emitter) { + emitter.on("finish", handleFinish); + } else { + writeStream.on("finish", handleFinish); + } + } catch (e) { + eventEmitter.emit("error", e); + } + + return eventEmitter; +}; + +const createThumbnail = ( + file: FileInterface, + filename: string, + user: UserInterface +) => { + return new Promise((resolve, _) => { + const eventEmitter = processData(file, filename, user); + eventEmitter.on("error", (e) => { + console.log("Error creating thumbnail", e); + resolve(file); + }); + eventEmitter.on("finish", (updatedFile: FileInterface) => { + resolve(updatedFile); + }); + }); +}; + +export default createThumbnail; diff --git a/backend/services/chunk-service/utils/createVideoThumbnail.ts b/backend/services/chunk-service/utils/createVideoThumbnail.ts new file mode 100644 index 0000000..796655b --- /dev/null +++ b/backend/services/chunk-service/utils/createVideoThumbnail.ts @@ -0,0 +1,188 @@ +import mongoose from "../../../db/connections/mongoose"; +import crypto from "crypto"; +import Thumbnail from "../../../models/thumbnail-model"; +import sharp from "sharp"; +import { FileInterface } from "../../../models/file-model"; +import { UserInterface } from "../../../models/user-model"; +import fs from "fs"; +import uuid from "uuid"; +import env from "../../../enviroment/env"; +import { ObjectId } from "mongodb"; +import File from "../../../models/file-model"; +import ffmpeg from "fluent-ffmpeg"; +import tempCreateVideoThumbnail from "./tempCreateVideoThumbnail"; +import { S3Actions } from "../actions/S3-actions"; +import { FilesystemActions } from "../actions/file-system-actions"; +import { createGenericParams } from "./storageHelper"; +import { getStorageActions } from "../actions/helper-actions"; +import { getFSStoragePath } from "../../../utils/getFSStoragePath"; + +const storageActions = getStorageActions(); + +const attemptToRemoveChunks = async ( + file: FileInterface, + thumbnailFilename: string +) => { + try { + const readStreamParams = createGenericParams({ + filePath: getFSStoragePath() + thumbnailFilename, + Key: thumbnailFilename, + }); + await storageActions.removeChunks(readStreamParams); + } catch (e) { + console.log("error removing chunks", e); + } +}; + +const createVideoThumbnail = ( + file: FileInterface, + filename: string, + user: UserInterface +) => { + return new Promise((resolve, reject) => { + try { + const password = user.getEncryptionKey(); + + let CIPHER_KEY = crypto.createHash("sha256").update(password!).digest(); + + const thumbnailFilename = uuid.v4(); + + const readStreamParams = createGenericParams({ + filePath: file.metadata.filePath, + Key: file.metadata.s3ID, + }); + + const readStream = storageActions.createReadStream(readStreamParams); + + const { writeStream, emitter } = storageActions.createWriteStream( + readStreamParams, + readStream, + thumbnailFilename + ); + + const decipher = crypto.createDecipheriv( + "aes256", + CIPHER_KEY, + file.metadata.IV + ); + + const thumbnailIV = crypto.randomBytes(16); + + const thumbnailCipher = crypto.createCipheriv( + "aes256", + CIPHER_KEY, + thumbnailIV + ); + + const handleError = (e: Error) => { + console.log("thumbnail stream error", e); + resolve(file); + }; + + readStream.on("error", handleError); + + decipher.on("error", handleError); + + writeStream.on("error", handleError); + + thumbnailCipher.on("error", handleError); + + const decryptedReadStream = readStream.pipe(decipher); + + let error = false; + + const handleFinish = async () => { + try { + if (error) return; + + const thumbnailModel = new Thumbnail({ + name: filename, + owner: user._id, + IV: thumbnailIV, + path: getFSStoragePath() + thumbnailFilename, + s3ID: thumbnailFilename, + }); + + await thumbnailModel.save(); + if (!file._id) { + return reject(); + } + const updateFileResponse = await File.updateOne( + { _id: new ObjectId(file._id), "metadata.owner": user._id }, + { + $set: { + "metadata.hasThumbnail": true, + "metadata.thumbnailID": thumbnailModel._id, + "metadata.isVideo": true, + }, + } + ); + if (updateFileResponse.modifiedCount === 0) { + return reject(); + } + + const updatedFile = await File.findById({ + _id: new ObjectId(file._id), + "metadata.owner": user._id, + }); + + if (!updatedFile) return reject(); + + resolve(updatedFile?.toObject()); + } catch (e) { + console.log("thumbnail error", e); + resolve(file); + } + }; + + if (emitter) { + emitter.on("finish", async () => { + await handleFinish(); + }); + } + + ffmpeg(decryptedReadStream, { + timeout: 60, + }) + .seek(1) + .format("image2pipe") + .outputOptions([ + "-f image2pipe", + "-vframes 1", + "-vf scale='if(gt(iw,ih),600,-1):if(gt(ih,iw),300,-1)'", + ]) + .on("start", (command) => {}) + .on("end", async (err) => { + if (!emitter) { + await handleFinish(); + } + }) + .on("error", async (err, sdf, stderr) => { + // console.log("thumbnail error attempting temp directory fix"); + + error = true; + + await attemptToRemoveChunks(file, thumbnailFilename); + + if (env.tempDirectory && env.tempVideoThumbnailLimit > file.length) { + const updatedFile = await tempCreateVideoThumbnail( + file, + filename, + user + ); + resolve(updatedFile); + } else { + resolve(file); + } + // resolve(file); + }) + .pipe(thumbnailCipher) + .pipe(writeStream, { end: true }); + } catch (e) { + console.log("thumbnail error", e); + resolve(file); + } + }); +}; + +export default createVideoThumbnail; diff --git a/backend/services/chunk-service/utils/fixEndChunkLength.ts b/backend/services/chunk-service/utils/fixEndChunkLength.ts new file mode 100644 index 0000000..385d62b --- /dev/null +++ b/backend/services/chunk-service/utils/fixEndChunkLength.ts @@ -0,0 +1,5 @@ +const fixEndChunkLength = (length: number) => { + return Math.floor((length - 1) / 16) * 16 + 16; +}; + +export default fixEndChunkLength; diff --git a/backend/services/chunk-service/utils/fixStartChunkLength.ts b/backend/services/chunk-service/utils/fixStartChunkLength.ts new file mode 100644 index 0000000..ca34370 --- /dev/null +++ b/backend/services/chunk-service/utils/fixStartChunkLength.ts @@ -0,0 +1,6 @@ +const fixStartChunkLength = (length: number) => { + + return Math.floor((length-1) / 16) * 16 - 16; +} + +export default fixStartChunkLength; \ No newline at end of file diff --git a/backend/services/chunk-service/utils/getBusboyData.ts b/backend/services/chunk-service/utils/getBusboyData.ts new file mode 100644 index 0000000..44c9f4e --- /dev/null +++ b/backend/services/chunk-service/utils/getBusboyData.ts @@ -0,0 +1,229 @@ +import { EventEmitter, Stream } from "stream"; +import { UserInterface } from "../../../models/user-model"; +import File, { + FileInterface, + FileMetadateInterface, +} from "../../../models/file-model"; +import uuid from "uuid"; +import crypto from "crypto"; +import ForbiddenError from "../../../utils/ForbiddenError"; +import env from "../../../enviroment/env"; +import { getStorageActions } from "../actions/helper-actions"; +import getFileSize from "./getFileSize"; +import imageChecker from "../../../utils/imageChecker"; +import videoChecker from "../../../utils/videoChecker"; +import createVideoThumbnail from "./createVideoThumbnail"; +import createThumbnail from "./createImageThumbnail"; +import { RequestTypeFullUser } from "../../../controllers/file-controller"; +import { getFSStoragePath } from "../../../utils/getFSStoragePath"; + +// TODO: We should stop using moongoose directly here, +// Also in our fileDB make sure we are actually using File instead +// Of just modifying data directly so we get validation + +const storageActions = getStorageActions(); + +type FileInfo = { + file: FileInterface; + parent: string; +}; + +const processData = ( + busboy: any, + user: UserInterface, + req: RequestTypeFullUser +) => { + const eventEmitter = new EventEmitter(); + + try { + let parent = ""; + let size = 0; + + const handleFinish = async ( + filename: string, + metadata: FileMetadateInterface + ) => { + const date = new Date(); + + let length = 0; + + if (env.dbType === "fs" && metadata.filePath) { + length = (await getFileSize(metadata.filePath)) as number; + } else { + // TODO: Fix this we should be using the encrypted file size + length = metadata.size; + } + + const videoCheck = videoChecker(filename); + + const currentFile = new File({ + filename, + uploadDate: date.toISOString(), + length, + metadata: { + ...metadata, + isVideo: videoCheck, + }, + }); + + await currentFile.save(); + + const imageCheck = imageChecker(currentFile.filename); + + if (videoCheck && env.videoThumbnailsEnabled) { + const updatedFile = await createVideoThumbnail( + currentFile, + filename, + user + ); + return updatedFile; + } else if (currentFile.length < 15728640 && imageCheck) { + const updatedFile = await createThumbnail(currentFile, filename, user); + return updatedFile; + } else { + return currentFile; + } + }; + + const uploadFile = (filename: string, fileStream: Stream) => { + return new Promise<{ filename: string; metadata: FileMetadateInterface }>( + (resolve, reject) => { + const randomFilenameID = uuid.v4(); + + const password = user.getEncryptionKey(); + + if (!password) throw new ForbiddenError("Invalid Encryption Key"); + + const initVect = crypto.randomBytes(16); + + const CIPHER_KEY = crypto + .createHash("sha256") + .update(password) + .digest(); + + const cipher = crypto.createCipheriv("aes256", CIPHER_KEY, initVect); + + const metadata = { + owner: user._id.toString(), + parent: "/", + parentList: ["/"].toString(), + hasThumbnail: false, + thumbnailID: "", + isVideo: false, + size, + IV: initVect, + processingFile: true, + } as FileMetadateInterface; + + if (env.dbType === "fs") { + metadata.filePath = getFSStoragePath() + randomFilenameID; + } else { + metadata.s3ID = randomFilenameID; + } + + const { writeStream, emitter } = storageActions.createWriteStream( + metadata, + fileStream.pipe(cipher), + randomFilenameID + ); + + writeStream.on("error", (e: Error) => { + reject(e); + }); + + cipher.on("error", (e: Error) => { + reject(e); + }); + + fileStream.on("error", (e: Error) => { + reject(e); + }); + + if (emitter) { + emitter.on("finish", () => { + resolve({ filename, metadata }); + }); + emitter.on("error", (e: Error) => { + reject(e); + }); + } else { + writeStream.on("finish", () => { + resolve({ filename, metadata }); + }); + } + + cipher.pipe(writeStream); + } + ); + }; + + const processFile = async (filename: string, fileStream: Stream) => { + try { + const { filename: newFilename, metadata } = await uploadFile( + filename, + fileStream + ); + const file = await handleFinish(newFilename, metadata); + eventEmitter.emit("finish", { + file, + parent, + }); + } catch (e) { + eventEmitter.emit("error", e); + } + }; + + busboy.on("field", (field: any, val: any) => { + if (field === "parent") { + parent = val; + } else if (field === "size") { + size = +val; + } + }); + + busboy.on( + "file", + ( + _: string, + file: Stream, + filedata: { + filename: string; + } + ) => { + processFile(filedata.filename, file); + } + ); + + busboy.on("error", (e: Error) => { + eventEmitter.emit("error", e); + }); + + req.on("error", (e: Error) => { + eventEmitter.emit("error", e); + }); + + req.pipe(busboy); + } catch (e) { + eventEmitter.emit("error", e); + } + + return eventEmitter; +}; + +const uploadFileToStorage = ( + busboy: any, + user: UserInterface, + req: RequestTypeFullUser +) => { + return new Promise((resolve, reject) => { + const eventEmitter = processData(busboy, user, req); + eventEmitter.on("finish", (data) => { + resolve(data); + }); + eventEmitter.on("error", (e) => { + reject(e); + }); + }); +}; + +export default uploadFileToStorage; diff --git a/backend/services/chunk-service/utils/getFileData.ts b/backend/services/chunk-service/utils/getFileData.ts new file mode 100644 index 0000000..85f0f81 --- /dev/null +++ b/backend/services/chunk-service/utils/getFileData.ts @@ -0,0 +1,215 @@ +import { EventEmitter } from "stream"; +import { UserInterface } from "../../../models/user-model"; +import e, { Response } from "express"; +import ForbiddenError from "../../../utils/ForbiddenError"; +import NotFoundError from "../../../utils/NotFoundError"; +import crypto from "crypto"; +import { createGenericParams } from "./storageHelper"; +import { getStorageActions } from "../actions/helper-actions"; +import FileDB from "../../../db/mongoDB/fileDB"; +import { FileInterface } from "../../../models/file-model"; +import NotAuthorizedError from "../../../utils/NotAuthorizedError"; +import sanitizeFilename from "../../../utils/sanitizeFilename"; + +const fileDB = new FileDB(); + +const storageActions = getStorageActions(); + +const activeStreams = new Map< + string, + { + readStream: NodeJS.ReadableStream; + decipherStream: NodeJS.ReadableStream; + file: FileInterface; + } +>(); + +const getFileAndRemoveActiveStream = async ( + fileID: string, + userID: string, + isVideoStream: boolean +) => { + const cachedFileData = activeStreams.get(fileID); + if (!cachedFileData || !isVideoStream) { + const file = await fileDB.getFileInfo(fileID, userID); + if (!file) { + throw new NotFoundError("File not found"); + } + if (file.metadata.owner !== userID) { + throw new NotAuthorizedError("Not owner of file"); + } + return file; + } else { + const { file, readStream, decipherStream } = cachedFileData; + try { + activeStreams.delete(fileID); + // @ts-ignore + readStream.destroy(); + // @ts-ignore + decipherStream.destroy(); + } catch (e) { + console.log("Error destroying streams", e); + } + return file; + } +}; + +const proccessData = ( + res: Response, + fileID: string, + user: UserInterface, + rangeIV?: Buffer, + range?: { + start: number; + end: number; + fixedStart: number; + fixedEnd: number; + skip: number; + chunksize: number; + } +) => { + const eventEmitter = new EventEmitter(); + + const processFile = async () => { + try { + const currentFile = await getFileAndRemoveActiveStream( + fileID, + user._id.toString(), + !!range + ); + + if (!currentFile) throw new NotFoundError("Download File Not Found"); + + const password = user.getEncryptionKey(); + + if (!password) throw new ForbiddenError("Invalid Encryption Key"); + + const IV = rangeIV || currentFile.metadata.IV; + + const readStreamParams = createGenericParams({ + filePath: currentFile.metadata.filePath, + Key: currentFile.metadata.s3ID, + }); + + let readStream: NodeJS.ReadableStream; + + if (range) { + readStream = storageActions.createReadStreamWithRange( + readStreamParams, + range.fixedStart, + range.fixedEnd + ); + } else { + readStream = storageActions.createReadStream(readStreamParams); + } + + const CIPHER_KEY = crypto.createHash("sha256").update(password).digest(); + + const decipher = crypto.createDecipheriv("aes256", CIPHER_KEY, IV); + + if (range) { + decipher.setAutoPadding(false); + } + + decipher.on("error", (e: Error) => { + eventEmitter.emit("error", e); + }); + + readStream.on("error", (e: Error) => { + eventEmitter.emit("error", e); + }); + + if (!!range) { + activeStreams.set(fileID, { + readStream, + decipherStream: decipher, + file: currentFile, + }); + } + + res.on("error", (e: Error) => { + eventEmitter.emit("error", e); + }); + + if (!range) { + const sanatizedFilename = sanitizeFilename(currentFile.filename); + const encodedFilename = encodeURIComponent(sanatizedFilename); + res.set("Content-Type", "binary/octet-stream"); + res.set( + "Content-Disposition", + `attachment; filename="${sanatizedFilename}"; filename*=UTF-8''${encodedFilename}` + ); + res.set("Content-Length", currentFile.metadata.size.toString()); + } + + if (range) { + let bytesSent = 0; + + decipher.on("data", (data: Buffer) => { + if (bytesSent === 0 && range.skip > 0) { + const neededData = data.slice(range.skip, data.length); + res.write(neededData); + bytesSent += neededData.length; + } else if (bytesSent + data.length > range.chunksize) { + const currentDataLength = bytesSent + data.length; + const difference = currentDataLength - range.chunksize; + const neededData = data.slice(0, data.length - difference); + res.write(neededData); + bytesSent += neededData.length; + } else { + res.write(data); + bytesSent += data.length; + } + }); + + decipher.on("finish", () => { + res.end(); + + eventEmitter.emit("finish"); + }); + + readStream.pipe(decipher); + } else { + readStream + .pipe(decipher) + .pipe(res) + .on("finish", () => { + eventEmitter.emit("finish"); + }); + } + } catch (e) { + eventEmitter.emit("error", e); + } + }; + + processFile(); + + return eventEmitter; +}; + +const getFileData = ( + res: Response, + fileID: string, + user: UserInterface, + rangeIV?: Buffer, + range?: { + start: number; + end: number; + fixedStart: number; + fixedEnd: number; + skip: number; + chunksize: number; + } +) => { + return new Promise((resolve, reject) => { + const eventEmitter = proccessData(res, fileID, user, rangeIV, range); + eventEmitter.on("finish", (data) => { + resolve(data); + }); + eventEmitter.on("error", (e) => { + reject(e); + }); + }); +}; + +export default getFileData; diff --git a/backend/services/chunk-service/utils/getFileSize.ts b/backend/services/chunk-service/utils/getFileSize.ts new file mode 100644 index 0000000..f493f6e --- /dev/null +++ b/backend/services/chunk-service/utils/getFileSize.ts @@ -0,0 +1,19 @@ +import fs from "fs"; + +const getFileSize = (path: string) => { + + return new Promise((resolve, reject) => { + + fs.stat(path, (error, stats) => { + + if (error) { + + resolve(0); + } + + resolve(stats.size); + }); + }) +} + +export default getFileSize; \ No newline at end of file diff --git a/backend/services/chunk-service/utils/getFolderUploadBusboyData.ts b/backend/services/chunk-service/utils/getFolderUploadBusboyData.ts new file mode 100644 index 0000000..82b4c66 --- /dev/null +++ b/backend/services/chunk-service/utils/getFolderUploadBusboyData.ts @@ -0,0 +1,271 @@ +import { Stream } from "stream"; +import uuid from "uuid"; +import { UserInterface } from "../../../models/user-model"; +import File, { + FileInterface, + FileMetadateInterface, +} from "../../../models/file-model"; +import env from "../../../enviroment/env"; +import { S3Actions } from "../actions/S3-actions"; +import { FilesystemActions } from "../actions/file-system-actions"; +import ForbiddenError from "../../../utils/ForbiddenError"; +import crypto from "crypto"; +import getFileSize from "./getFileSize"; +import imageChecker from "../../../utils/imageChecker"; +import videoChecker from "../../../utils/videoChecker"; +import createVideoThumbnail from "./createVideoThumbnail"; +import createThumbnail from "./createImageThumbnail"; +import { EventEmitter } from "events"; +import { getStorageActions } from "../actions/helper-actions"; +import { RequestTypeFullUser } from "../../../controllers/file-controller"; +import { getFSStoragePath } from "../../../utils/getFSStoragePath"; + +type FileDataType = { + name: string; + size: number; + type: string; + path: string; + index: number; + file: Stream; + uploadedFileId: string; +}; + +const storageActions = getStorageActions(); + +type dataType = Record; + +const processData = ( + busboy: any, + user: UserInterface, + req: RequestTypeFullUser +) => { + const eventEmitter = new EventEmitter(); + + try { + const formData = new Map(); + + let filesProcessed = 0; + let filesToProcess = 0; + let parent = ""; + + const fileDataMap: dataType = {}; + + const uploadQueue: { file: Stream; index: string }[] = []; + + let processing = false; + + const handleFinish = async ( + filename: string, + metadata: FileMetadateInterface + ) => { + const date = new Date(); + + let length = 0; + + if (env.dbType === "fs" && metadata.filePath) { + length = (await getFileSize(metadata.filePath)) as number; + } else { + // TODO: Fix this we should be using the encrypted file size + length = metadata.size; + } + + const currentFile = new File({ + filename, + uploadDate: date.toISOString(), + length, + metadata, + }); + + await currentFile.save(); + + const imageCheck = imageChecker(currentFile.filename); + const videoCheck = videoChecker(currentFile.filename); + + if (videoCheck) { + const updatedFile = await createVideoThumbnail( + currentFile, + filename, + user + ); + return updatedFile; + } else if (currentFile.length < 15728640 && imageCheck) { + const updatedFile = await createThumbnail(currentFile, filename, user); + return updatedFile; + } else { + return currentFile; + } + }; + + const uploadFile = (currentFile: { file: Stream; index: string }) => { + return new Promise<{ filename: string; metadata: FileMetadateInterface }>( + (resolve, reject) => { + const { file, index } = currentFile; + + const fileData = fileDataMap[index]; + + const randomFilenameID = uuid.v4(); + + const password = user.getEncryptionKey(); + + if (!password) throw new ForbiddenError("Invalid Encryption Key"); + + const initVect = crypto.randomBytes(16); + + const CIPHER_KEY = crypto + .createHash("sha256") + .update(password) + .digest(); + + const cipher = crypto.createCipheriv("aes256", CIPHER_KEY, initVect); + + const filename = fileData.name; + const fileSize = fileData.size; + + const metadata = { + owner: user._id.toString(), + parent: "/", + parentList: ["/"].toString(), + hasThumbnail: false, + thumbnailID: "", + isVideo: false, + size: fileSize, + IV: initVect, + processingFile: true, + } as FileMetadateInterface; + + if (env.dbType === "fs") { + metadata.filePath = getFSStoragePath() + randomFilenameID; + } else { + metadata.s3ID = randomFilenameID; + } + + const { writeStream, emitter } = storageActions.createWriteStream( + metadata, + file.pipe(cipher), + randomFilenameID + ); + + writeStream.on("error", (e: Error) => { + reject(e); + }); + + cipher.on("error", (e: Error) => { + reject(e); + }); + + cipher.pipe(writeStream); + + if (emitter) { + emitter.on("finish", () => { + resolve({ filename, metadata }); + }); + } else { + writeStream.on("finish", () => { + resolve({ filename, metadata }); + }); + } + } + ); + }; + + const processQueue = async () => { + if (processing) return; + + processing = true; + + try { + while (uploadQueue.length > 0) { + const currentFile = uploadQueue.shift(); + const { filename, metadata } = await uploadFile(currentFile!); + const file = await handleFinish(filename, metadata); + + fileDataMap[currentFile!.index] = { + ...fileDataMap[currentFile!.index], + uploadedFileId: file._id!.toString(), + }; + + filesProcessed++; + + if (filesProcessed === filesToProcess) { + eventEmitter.emit("finish", { fileDataMap, parent }); + } + } + } catch (e) { + eventEmitter.emit("error", e); + } + + processing = false; + }; + + busboy.on("field", (field: any, val: any) => { + if (typeof val !== "string" || val !== "undefined") { + formData.set(field, val); + if (field === "file-data") { + const fileData = JSON.parse(val); + fileDataMap[fileData.index] = fileData; + } + if (field === "total-files") { + filesToProcess = +val; + } + if (field === "parent") { + parent = val; + } + } + }); + + busboy.on( + "file", + ( + _: string, + file: Stream, + fileData: { + filename: string; + } + ) => { + const index = fileData.filename; + + uploadQueue.push({ file, index }); + + processQueue(); + } + ); + + busboy.on("error", (e: Error) => { + eventEmitter.emit("error", e); + }); + + req.on("error", (e: Error) => { + eventEmitter.emit("error", e); + }); + + busboy.on("error", (e: Error) => { + eventEmitter.emit("error", e); + }); + + req.pipe(busboy); + } catch (e) { + eventEmitter.emit("error", e); + } + + return eventEmitter; +}; + +const getFolderBusboyData = ( + busboy: any, + user: UserInterface, + req: RequestTypeFullUser +) => { + return new Promise<{ fileDataMap: dataType; parent: string }>( + (resolve, reject) => { + const fileEventEmitter = processData(busboy, user, req); + fileEventEmitter.on("finish", (data) => { + resolve(data); + }); + fileEventEmitter.on("error", (e) => { + reject(e); + }); + } + ); +}; + +export default getFolderBusboyData; diff --git a/backend/services/chunk-service/utils/getPrevIVFS.ts b/backend/services/chunk-service/utils/getPrevIVFS.ts new file mode 100644 index 0000000..fbea067 --- /dev/null +++ b/backend/services/chunk-service/utils/getPrevIVFS.ts @@ -0,0 +1,19 @@ +import fs from "fs"; + +const getPrevIV = (start: number, path: string) => { + + return new Promise((resolve, reject) => { + + const stream = fs.createReadStream(path, { + start, + end: start + 15 + }) + + stream.on("data", (data) => { + + resolve(data); + }) + }) +} + +export default getPrevIV; \ No newline at end of file diff --git a/backend/services/chunk-service/utils/getPrevIVS3.ts b/backend/services/chunk-service/utils/getPrevIVS3.ts new file mode 100644 index 0000000..70d83a2 --- /dev/null +++ b/backend/services/chunk-service/utils/getPrevIVS3.ts @@ -0,0 +1,26 @@ +import s3 from "../../../db/connections/s3"; +import env from "../../../enviroment/env"; +import { UserInterface } from "../../../models/user-model"; + +const getPrevIV = ( + start: number, + key: string, + isPersonal: boolean, + user: UserInterface +) => { + return new Promise(async (resolve, reject) => { + const params: any = { + Bucket: env.s3Bucket, + Key: key, + Range: `bytes=${start}-${start + 15}`, + }; + + const stream = s3.getObject(params).createReadStream(); + + stream.on("data", (data: any) => { + resolve(data); + }); + }); +}; + +export default getPrevIV; diff --git a/backend/services/chunk-service/utils/getPublicFileData.ts b/backend/services/chunk-service/utils/getPublicFileData.ts new file mode 100644 index 0000000..6e36462 --- /dev/null +++ b/backend/services/chunk-service/utils/getPublicFileData.ts @@ -0,0 +1,108 @@ +import { EventEmitter } from "stream"; +import { UserInterface } from "../../../models/user-model"; +import { Response } from "express"; +import ForbiddenError from "../../../utils/ForbiddenError"; +import NotFoundError from "../../../utils/NotFoundError"; +import crypto from "crypto"; +import { createGenericParams } from "./storageHelper"; +import { getStorageActions } from "../actions/helper-actions"; +import FileDB from "../../../db/mongoDB/fileDB"; +import NotAuthorizedError from "../../../utils/NotAuthorizedError"; +import UserDB from "../../../db/mongoDB/userDB"; +import sanitizeFilename from "../../../utils/sanitizeFilename"; + +const fileDB = new FileDB(); +const userDB = new UserDB(); + +const storageActions = getStorageActions(); + +const proccessData = (res: Response, fileID: string, tempToken: string) => { + const eventEmitter = new EventEmitter(); + + const processFile = async () => { + try { + const file = await fileDB.getPublicFile(fileID); + + if (!file || !file.metadata.link || file.metadata.link !== tempToken) { + throw new NotAuthorizedError("File Not Public"); + } + + if (file.metadata.linkType === "one") { + await fileDB.removeOneTimePublicLink(fileID); + } + + const user = await userDB.getUserInfo(file.metadata.owner); + + if (!user) throw new NotFoundError("User Not Found"); + + const password = user.getEncryptionKey(); + + if (!password) throw new ForbiddenError("Invalid Encryption Key"); + + const IV = file.metadata.IV; + + const readStreamParams = createGenericParams({ + filePath: file.metadata.filePath, + Key: file.metadata.s3ID, + }); + + const readStream = storageActions.createReadStream(readStreamParams); + + const CIPHER_KEY = crypto.createHash("sha256").update(password).digest(); + + const decipher = crypto.createDecipheriv("aes256", CIPHER_KEY, IV); + + decipher.on("error", (e: Error) => { + eventEmitter.emit("error", e); + }); + + readStream.on("error", (e: Error) => { + eventEmitter.emit("error", e); + }); + + res.on("error", (e: Error) => { + eventEmitter.emit("error", e); + }); + + const sanatizedFilename = sanitizeFilename(file.filename); + const encodedFilename = encodeURIComponent(sanatizedFilename); + res.set("Content-Type", "binary/octet-stream"); + res.set( + "Content-Disposition", + `attachment; filename="${sanatizedFilename}"; filename*=UTF-8''${encodedFilename}` + ); + res.set("Content-Length", file.metadata.size.toString()); + + readStream + .pipe(decipher) + .pipe(res) + .on("finish", () => { + eventEmitter.emit("finish"); + }); + } catch (e) { + eventEmitter.emit("error", e); + } + }; + + processFile(); + + return eventEmitter; +}; + +const getPublicFileData = ( + res: Response, + fileID: string, + tempToken: string +) => { + return new Promise((resolve, reject) => { + const eventEmitter = proccessData(res, fileID, tempToken); + eventEmitter.on("finish", (data) => { + resolve(data); + }); + eventEmitter.on("error", (e) => { + reject(e); + }); + }); +}; + +export default getPublicFileData; diff --git a/backend/services/chunk-service/utils/getThumbnailData.ts b/backend/services/chunk-service/utils/getThumbnailData.ts new file mode 100644 index 0000000..d288d0b --- /dev/null +++ b/backend/services/chunk-service/utils/getThumbnailData.ts @@ -0,0 +1,93 @@ +import { EventEmitter } from "stream"; +import { UserInterface } from "../../../models/user-model"; +import { Response } from "express"; +import ForbiddenError from "../../../utils/ForbiddenError"; +import NotFoundError from "../../../utils/NotFoundError"; +import crypto from "crypto"; +import { createGenericParams } from "./storageHelper"; +import { getStorageActions } from "../actions/helper-actions"; + +import ThumbnailDB from "../../../db/mongoDB/thumbnailDB"; + +const thumbnailDB = new ThumbnailDB(); + +const storageActions = getStorageActions(); + +const proccessData = ( + res: Response, + thumbnailID: string, + user: UserInterface +) => { + const eventEmitter = new EventEmitter(); + + const processFile = async () => { + try { + const thumbnail = await thumbnailDB.getThumbnailInfo( + user._id.toString(), + thumbnailID + ); + + if (!thumbnail) throw new NotFoundError("Thumbnail Not Found"); + + const password = user.getEncryptionKey(); + + if (!password) throw new ForbiddenError("Invalid Encryption Key"); + + const IV = thumbnail.IV; + + const readStreamParams = createGenericParams({ + filePath: thumbnail.path, + Key: thumbnail.s3ID, + }); + + const readStream = storageActions.createReadStream(readStreamParams); + + const CIPHER_KEY = crypto.createHash("sha256").update(password).digest(); + + const decipher = crypto.createDecipheriv("aes256", CIPHER_KEY, IV); + + decipher.on("error", (e: Error) => { + eventEmitter.emit("error", e); + }); + + readStream.on("error", (e: Error) => { + eventEmitter.emit("error", e); + }); + + res.on("error", (e: Error) => { + eventEmitter.emit("error", e); + }); + + readStream + .pipe(decipher) + .pipe(res) + .on("finish", () => { + eventEmitter.emit("finish"); + }); + } catch (e) { + eventEmitter.emit("error", e); + } + }; + + processFile(); + + return eventEmitter; +}; + +const getThumbnailData = ( + res: Response, + thumbnailID: string, + user: UserInterface +) => { + return new Promise((resolve, reject) => { + const eventEmitter = proccessData(res, thumbnailID, user); + eventEmitter.on("finish", (data) => { + resolve(data); + }); + eventEmitter.on("error", (e) => { + reject(e); + }); + }); +}; + +export default getThumbnailData; diff --git a/backend/services/chunk-service/utils/removeChunksFS.ts b/backend/services/chunk-service/utils/removeChunksFS.ts new file mode 100644 index 0000000..26527d8 --- /dev/null +++ b/backend/services/chunk-service/utils/removeChunksFS.ts @@ -0,0 +1,17 @@ +import fs from "fs"; + +const removeChunksFS = (path: string) => { + return new Promise((resolve, reject) => { + fs.unlink(path, (err) => { + if (err) { + console.log("Could not remove fs file", err); + resolve(); + } + + resolve(); + }); + }); +}; + +export default removeChunksFS; +module.exports = removeChunksFS; diff --git a/backend/services/chunk-service/utils/removeChunksS3.ts b/backend/services/chunk-service/utils/removeChunksS3.ts new file mode 100644 index 0000000..044548d --- /dev/null +++ b/backend/services/chunk-service/utils/removeChunksS3.ts @@ -0,0 +1,17 @@ +//import s3 from "../../../db/s3"; + +const removeChunksS3 = (s3: any, parmas: any) => { + return new Promise((resolve, reject) => { + s3.deleteObject(parmas, (err: any, data: any) => { + if (err) { + console.log("Could not remove S3 file"); + reject("Could Not Remove S3 File"); + } + + resolve(); + }); + }); +}; + +export default removeChunksS3; +module.exports = removeChunksS3; diff --git a/backend/services/chunk-service/utils/removeTempToken.ts b/backend/services/chunk-service/utils/removeTempToken.ts new file mode 100644 index 0000000..2d27626 --- /dev/null +++ b/backend/services/chunk-service/utils/removeTempToken.ts @@ -0,0 +1,11 @@ +import { UserInterface } from "../../../models/user-model"; + +const removeTempToken = async (user: UserInterface, tempToken: any) => { + user.tempTokens = user.tempTokens.filter((filterToken) => { + return filterToken.token !== tempToken; + }); + + await user.save(); +}; + +export default removeTempToken; diff --git a/backend/services/chunk-service/utils/storageHelper.ts b/backend/services/chunk-service/utils/storageHelper.ts new file mode 100644 index 0000000..e29f912 --- /dev/null +++ b/backend/services/chunk-service/utils/storageHelper.ts @@ -0,0 +1,29 @@ +import env from "../../../enviroment/env"; +import { getFSStoragePath } from "../../../utils/getFSStoragePath"; + +type GenericParmasType = { + filePath?: string; + Key?: string; + Bucket?: string; +}; + +export const createGenericParams = ({ filePath, Key }: GenericParmasType) => { + // TODO: Remove file split after migration + if (env.dbType === "fs") { + if (filePath?.includes("/")) { + const filePathSplit = filePath!.split("/"); + const fileName = filePathSplit[filePathSplit.length - 1]; + return { + filePath: getFSStoragePath() + fileName, + }; + } else { + return { + filePath: getFSStoragePath() + Key!, + }; + } + } else { + return { + Key, + }; + } +}; diff --git a/backend/services/chunk-service/utils/tempCreateVideoThumbnail.ts b/backend/services/chunk-service/utils/tempCreateVideoThumbnail.ts new file mode 100644 index 0000000..6459c49 --- /dev/null +++ b/backend/services/chunk-service/utils/tempCreateVideoThumbnail.ts @@ -0,0 +1,140 @@ +import mongoose from "../../../db/connections/mongoose"; +import crypto from "crypto"; +import Thumbnail from "../../../models/thumbnail-model"; +import sharp from "sharp"; +import { FileInterface } from "../../../models/file-model"; +import { UserInterface } from "../../../models/user-model"; +import fs from "fs"; +import uuid from "uuid"; +import env from "../../../enviroment/env"; +import { ObjectId } from "mongodb"; +import File from "../../../models/file-model"; +import ffmpeg from "fluent-ffmpeg"; +import { S3Actions } from "../actions/S3-actions"; +import { FilesystemActions } from "../actions/file-system-actions"; +import { createGenericParams } from "./storageHelper"; +import { getStorageActions } from "../actions/helper-actions"; +import { getFSStoragePath } from "../../../utils/getFSStoragePath"; + +const storageActions = getStorageActions(); + +const tempCreateVideoThumbnail = ( + file: FileInterface, + filename: string, + user: UserInterface +) => { + return new Promise((resolve, reject) => { + const password = user.getEncryptionKey(); + + let CIPHER_KEY = crypto.createHash("sha256").update(password!).digest(); + + const thumbnailFilename = uuid.v4(); + + const readStreamParams = createGenericParams({ + filePath: file.metadata.filePath, + Key: file.metadata.s3ID, + }); + + const readStream = storageActions.createReadStream(readStreamParams); + + const { writeStream, emitter } = storageActions.createWriteStream( + readStreamParams, + readStream, + thumbnailFilename + ); + + const tempDirectory = env.tempDirectory + thumbnailFilename; + const tempWriteStream = fs.createWriteStream(tempDirectory); + const decipher = crypto.createDecipheriv( + "aes256", + CIPHER_KEY, + file.metadata.IV + ); + + const thumbnailIV = crypto.randomBytes(16); + + const thumbnailCipher = crypto.createCipheriv( + "aes256", + CIPHER_KEY, + thumbnailIV + ); + + const decryptedReadStream = readStream.pipe(decipher); + + decryptedReadStream.pipe(tempWriteStream, { end: true }); + + if (emitter) { + emitter.on("finish", async () => { + await handleFinish(); + }); + } + + const handleFinish = async () => { + const thumbnailModel = new Thumbnail({ + name: filename, + owner: user._id, + IV: thumbnailIV, + path: getFSStoragePath() + thumbnailFilename, + s3ID: thumbnailFilename, + }); + + await thumbnailModel.save(); + if (!file._id) { + return reject(); + } + const updatedFile = await File.findOneAndUpdate( + { _id: new ObjectId(file._id), "metadata.owner": user._id }, + { + $set: { + "metadata.hasThumbnail": true, + "metadata.thumbnailID": thumbnailModel._id, + "metadata.isVideo": true, + }, + }, + { new: true } + ); + + if (!updatedFile) return reject(); + + fs.unlink(tempDirectory, (err) => { + resolve(updatedFile); + }); + }; + + const attemptToRemoveTempDirectory = () => { + return new Promise((resolve, reject) => { + fs.unlink(tempDirectory, (err) => { + resolve(!err); + }); + }); + }; + + tempWriteStream.on("finish", () => { + ffmpeg(tempDirectory, { + timeout: 60, + }) + .seek(1) + .format("image2pipe") + .outputOptions([ + "-f image2pipe", + "-vframes 1", + "-vf scale='if(gt(iw,ih),600,-1):if(gt(ih,iw),300,-1)'", + ]) + .on("start", () => {}) + .on("end", async () => { + if (!emitter) { + await handleFinish(); + } + }) + .on("error", (err, _, stderr) => { + console.log("error", err, stderr); + attemptToRemoveTempDirectory(); + resolve(file); + }) + .pipe(thumbnailCipher) + .pipe(writeStream, { end: true }); + }); + }); +}; + +export default tempCreateVideoThumbnail; diff --git a/backend/services/file-service/file-service.ts b/backend/services/file-service/file-service.ts new file mode 100644 index 0000000..348d156 --- /dev/null +++ b/backend/services/file-service/file-service.ts @@ -0,0 +1,345 @@ +import NotAuthorizedError from "../../utils/NotAuthorizedError"; +import NotFoundError from "../../utils/NotFoundError"; +import env from "../../enviroment/env"; +import jwt from "jsonwebtoken"; +import Folder, { FolderInterface } from "../../models/folder-model"; +import sortBySwitch from "../../utils/sortBySwitch"; +import FileDB from "../../db/mongoDB/fileDB"; +import FolderDB from "../../db/mongoDB/folderDB"; +import { UserInterface } from "../../models/user-model"; +import { FileInterface } from "../../models/file-model"; +import tempStorage from "../../tempStorage/tempStorage"; +import FolderService from "../folder-service/folder-service"; +import { FileListQueryType } from "../../types/file-types"; + +const fileDB = new FileDB(); +const folderDB = new FolderDB(); +const folderService = new FolderService(); + +type userAccessType = { + _id: string; + emailVerified: boolean; + email: string; + s3Enabled: boolean; +}; + +class MongoFileService { + constructor() {} + + removePublicOneTimeLink = async (currentFile: FileInterface) => { + const fileID = currentFile._id; + if (!fileID) return; + + if (currentFile.metadata.linkType === "one") { + await fileDB.removeOneTimePublicLink(fileID); + } + }; + + removeLink = async (userID: string, fileID: string) => { + const file = await fileDB.removeLink(fileID, userID); + + if (!file) throw new NotFoundError("Remove Link File Not Found Error"); + + return file; + }; + + makePublic = async (userID: string, fileID: string) => { + const token = jwt.sign({ _id: userID.toString() }, env.passwordAccess!); + + const file = await fileDB.makePublic(fileID, userID, token); + + if (!file) throw new NotFoundError("Make Public File Not Found Error"); + + return { file, token }; + }; + + getPublicInfo = async (fileID: string, tempToken: string) => { + const file = await fileDB.getPublicInfo(fileID, tempToken); + + if (!file) throw new NotFoundError("Public Info Not Found"); + + if (!file.metadata.link || file.metadata.link !== tempToken) { + throw new NotAuthorizedError("Public Info Not Authorized"); + } else { + return file; + } + }; + + makeOneTimePublic = async (userID: string, fileID: string) => { + const token = jwt.sign({ _id: userID.toString() }, env.passwordAccess!); + + const file = await fileDB.makeOneTimePublic(fileID, userID, token); + + if (!file) throw new NotFoundError("Make One Time Public Not Found Error"); + + return { file, token }; + }; + + getFileInfo = async (userID: string, fileID: string) => { + let currentFile = await fileDB.getFileInfo(fileID, userID); + + if (!currentFile) throw new NotFoundError("Get File Info Not Found Error"); + + return currentFile; + }; + + getQuickList = async ( + user: userAccessType | UserInterface, + limit: number + ) => { + const userID = user._id; + + const quickList = await fileDB.getQuickList(userID.toString(), limit); + + if (!quickList) throw new NotFoundError("Quick List Not Found Error"); + + return quickList; + }; + + getList = async ( + queryData: FileListQueryType, + sortBy: string, + limit: number + ) => { + const fileList = await fileDB.getList(queryData, sortBy, limit); + + if (!fileList) throw new NotFoundError("File List Not Found"); + + return fileList; + }; + + getDownloadToken = async (user: UserInterface) => { + const tempToken = await user.generateTempAuthToken(); + + if (!tempToken) + throw new NotAuthorizedError("Get Download Token Not Authorized Error"); + + return tempToken; + }; + + // No longer needed left for reference + + // getDownloadTokenVideo = async(user: UserInterface, cookie: string) => { + + // if (!cookie) throw new NotAuthorizedError("Get Download Token Video Cookie Not Authorized Error"); + + // const tempToken = await user.generateTempAuthTokenVideo(cookie); + + // if (!tempToken) throw new NotAuthorizedError("Get Download Token Video Not Authorized Error"); + + // return tempToken; + // } + + removeTempToken = async ( + user: UserInterface, + tempToken: any, + currentUUID: string + ) => { + const key = user.getEncryptionKey(); + + const decoded = (await jwt.verify(tempToken, env.passwordAccess!)) as any; + + const publicKey = decoded.iv; + + const encryptedToken = user.encryptToken(tempToken, key, publicKey); + + const removedTokenUser = await fileDB.removeTempToken(user, encryptedToken); + + if (!removedTokenUser) + throw new NotFoundError("Remove Temp Token User Not Found Errors"); + + delete tempStorage[currentUUID]; + + await removedTokenUser.save(); + }; + + getSuggestedList = async ( + userID: string, + searchQuery: any, + trashMode: boolean, + mediaMode: boolean + ) => { + searchQuery = new RegExp(searchQuery, "i"); + + const fileList = await fileDB.getFileSearchList( + userID, + searchQuery, + trashMode, + mediaMode + ); + + if (!fileList) throw new NotFoundError("Suggested List Not Found Error"); + + if (mediaMode) { + return { + fileList, + folderList: [], + }; + } + + const folderList = await folderDB.getFolderSearchList( + userID, + searchQuery, + trashMode + ); + + if (!folderList) throw new NotFoundError("Suggested List Not Found Error"); + + return { + fileList, + folderList, + }; + }; + + trashFile = async (userID: string, fileID: string) => { + const file = await fileDB.getFileInfo(fileID, userID); + + if (!file) throw new NotFoundError("Trash File Not Found Error"); + + let parent = file.metadata.parent; + let parentList = file.metadata.parentList; + + if (file.metadata.parent !== "/") { + parent = "/"; + parentList = ["/"].toString(); + } + + const trashedFile = await fileDB.trashFile( + fileID, + parent, + parentList, + userID + ); + if (!trashedFile) throw new NotFoundError("Trash File Not Found Error"); + return trashedFile; + }; + + trashMulti = async ( + userID: string, + items: { + type: "file" | "folder" | "quick-item"; + id: string; + file?: FileInterface; + folder?: FolderInterface; + }[] + ) => { + const fileList = items.filter( + (item) => item.type === "file" || item.type === "quick-item" + ); + const folderList = items + .filter((item) => item.type === "folder") + .sort((a, b) => { + if (!a.folder || !b.folder) return 0; + return b.folder.parentList.length - a.folder.parentList.length; + }); + + for (const file of fileList) { + await this.trashFile(userID, file.id); + } + for (const folder of folderList) { + await folderService.trashFolder(userID, folder.id); + } + }; + + restoreFile = async (userID: string, fileID: string) => { + const restoredFile = await fileDB.restoreFile(fileID, userID); + if (!restoredFile) throw new NotFoundError("Restore File Not Found Error"); + return restoredFile; + }; + + restoreMulti = async ( + userID: string, + items: { + type: "file" | "folder" | "quick-item"; + id: string; + file?: FileInterface; + folder?: FolderInterface; + }[] + ) => { + const fileList = items.filter( + (item) => item.type === "file" || item.type === "quick-item" + ); + const folderList = items + .filter((item) => item.type === "folder") + .sort((a, b) => { + if (!a.folder || !b.folder) return 0; + return b.folder.parentList.length - a.folder.parentList.length; + }); + + for (const file of fileList) { + await this.restoreFile(userID, file.id); + } + for (const folder of folderList) { + await folderService.restoreFolder(userID, folder.id); + } + }; + + renameFile = async (userID: string, fileID: string, title: string) => { + const file = await fileDB.renameFile(fileID, userID, title); + + if (!file) throw new NotFoundError("Rename File Not Found Error"); + + return file; + }; + + moveFile = async (userID: string, fileID: string, parentID: string) => { + const file = await fileDB.getFileInfo(fileID, userID); + + if (!file) throw new NotFoundError("Move File Not Found Error"); + + const newParentList = []; + + if (parentID !== "/") { + const folder = await folderDB.getFolderInfo(parentID, userID); + + if (!folder) throw new NotFoundError("Move Folder Not Found Error"); + + newParentList.push(...folder.parentList, folder._id); + } else { + newParentList.push("/"); + } + + const updatedFile = await fileDB.moveFile( + fileID, + userID, + parentID, + newParentList.toString() + ); + + if (!updatedFile) { + throw new NotFoundError("Move Updated File Not Found Error"); + } + + return updatedFile; + }; + + moveMultiFiles = async ( + userID: string, + items: { + type: "file" | "folder" | "quick-item"; + id: string; + file?: FileInterface; + folder?: FolderInterface; + }[], + parentID: string + ) => { + const fileList = items.filter( + (item) => item.type === "file" || item.type === "quick-item" + ); + const folderList = items + .filter((item) => item.type === "folder") + .sort((a, b) => { + if (!a.folder || !b.folder) return 0; + return b.folder.parentList.length - a.folder.parentList.length; + }); + + for (const file of fileList) { + await this.moveFile(userID, file.id, parentID); + } + for (const folder of folderList) { + await folderService.moveFolder(userID, folder.id, parentID); + } + }; +} + +export default MongoFileService; diff --git a/backend/services/folder-service/folder-service.ts b/backend/services/folder-service/folder-service.ts new file mode 100644 index 0000000..99d83f6 --- /dev/null +++ b/backend/services/folder-service/folder-service.ts @@ -0,0 +1,184 @@ +import InternalServerError from "../../utils/InternalServerError"; +import NotFoundError from "../../utils/NotFoundError"; +import FileDB from "../../db/mongoDB/fileDB"; +import FolderDB from "../../db/mongoDB/folderDB"; +import { FolderListQueryType } from "../../types/folder-types"; +import UserDB from "../../db/mongoDB/userDB"; + +type userAccessType = { + _id: string; + emailVerified: boolean; + email: string; + s3Enabled: boolean; +}; + +const fileDB = new FileDB(); +const folderDB = new FolderDB(); +const userDB = new UserDB(); + +class FolderService { + createFolder = async (userID: string, name: string, parent: string) => { + const newFolderParentList = []; + if (parent !== "/") { + const parentFolder = await folderDB.getFolderInfo(parent, userID); + + if (!parentFolder) throw new Error("Parent not found"); + newFolderParentList.push( + ...parentFolder.parentList, + parentFolder._id?.toString() + ); + } else { + newFolderParentList.push("/"); + } + const newFolderData = { + name, + parent: parent || "/", + parentList: newFolderParentList, + owner: userID, + }; + + const folder = await folderDB.createFolder(newFolderData); + + if (!folder) throw new InternalServerError("Upload Folder Error"); + + return folder; + }; + + getFolderInfo = async (userID: string, folderID: string) => { + const currentFolder = await folderDB.getFolderInfo(folderID, userID); + if (!currentFolder) throw new NotFoundError("Folder Info Not Found Error"); + return currentFolder; + }; + + getFolderList = async (queryData: FolderListQueryType, sortBy: string) => { + const folderList = await folderDB.getFolderList(queryData, sortBy); + + if (!folderList) throw new NotFoundError("Folder List Not Found Error"); + + return folderList; + }; + + renameFolder = async (userID: string, folderID: string, title: string) => { + const folder = await folderDB.renameFolder(folderID, userID, title); + + if (!folder) throw new NotFoundError("Rename Folder Not Found"); + }; + + trashFolder = async (userID: string, folderID: string) => { + const folder = await folderDB.getFolderInfo(folderID, userID); + + if (!folder) throw new NotFoundError("Trash Folder Not Found Error"); + + const parentList = []; + + if (folder.parent !== "/") { + await folderDB.moveFolder(folderID, userID, "/", ["/"]); + parentList.push("/", folder._id!.toString()); + } else { + parentList.push(...folder.parentList, folder._id!.toString()); + } + + await folderDB.trashFolder(folderID, userID); + + await folderDB.trashFoldersByParent(parentList, userID); + + await fileDB.trashFilesByParent(parentList.toString(), userID); + }; + + restoreFolder = async (userID: string, folderID: string) => { + const folder = await folderDB.restoreFolder(folderID, userID); + + if (!folder) throw new NotFoundError("Restore Folder Not Found Error"); + + const parentList = [...folder.parentList, folder._id!.toString()]; + + await folderDB.restoreFoldersByParent(parentList, userID); + + await fileDB.restoreFilesByParent(parentList.toString(), userID); + }; + + getMoveFolderList = async ( + userID: string, + parent?: string, + search?: string, + folderIDs?: string[] + ) => { + const folderList = await folderDB.getMoveFolderList( + userID, + parent, + search, + folderIDs + ); + + return folderList; + }; + + moveFolder = async (userID: string, folderID: string, parentID: string) => { + const folder = await folderDB.getFolderInfo(folderID, userID); + + if (!folder) throw new NotFoundError("Move Folder Not Found Error"); + + const foldersByIncludedParent = + await folderDB.getFolderListByIncludedParent(userID, folderID); + + const startParentList = []; + + if (parentID !== "/") { + const folderToMoveTo = await folderDB.getFolderInfo(parentID, userID); + if (!folderToMoveTo) { + throw new NotFoundError("Move Folder Not Found Error"); + } + startParentList.push( + ...folderToMoveTo.parentList, + folderToMoveTo._id.toString() + ); + } else { + startParentList.push("/"); + } + + await Promise.all([ + folderDB.moveFolder(folderID, userID, parentID, startParentList), + fileDB.moveMultipleFiles( + userID, + folderID, + folderID, + [...startParentList, folderID].toString() + ), + ]); + + for (let i = 0; i < foldersByIncludedParent.length; i++) { + const currentFolder = foldersByIncludedParent[i]; + + const currentParentIndex = currentFolder.parentList.indexOf(folderID); + + const newParentList = []; + + newParentList.push( + ...startParentList, + folderID, + ...currentFolder.parentList.slice(currentParentIndex + 1) + ); + + await Promise.all([ + folderDB.moveFolder( + currentFolder._id.toString(), + userID, + currentFolder.parent, + newParentList + ), + fileDB.moveMultipleFiles( + userID, + currentFolder._id.toString(), + currentFolder._id.toString(), + [...newParentList, currentFolder._id.toString()].toString() + ), + ]); + } + + const updatedFolder = await folderDB.getFolderInfo(folderID, userID); + + return updatedFolder; + }; +} + +export default FolderService; diff --git a/backend/services/user-service/user-service.ts b/backend/services/user-service/user-service.ts new file mode 100644 index 0000000..114ada8 --- /dev/null +++ b/backend/services/user-service/user-service.ts @@ -0,0 +1,245 @@ +import User, { UserInterface } from "../../models/user-model"; +import bcrypt from "bcryptjs"; +import NotFoundError from "../../utils/NotFoundError"; +import InternalServerError from "../../utils/InternalServerError"; +import sendEmailVerification from "../../utils/sendVerificationEmail"; +import File from "../../models/file-model"; +import env from "../../enviroment/env"; +import jwt from "jsonwebtoken"; +import sendVerificationEmail from "../../utils/sendVerificationEmail"; +import sendPasswordResetEmail from "../../utils/sendPasswordResetEmail"; +import ForbiddenError from "../../utils/ForbiddenError"; +import ConflictError from "../../utils/ConflictError"; +import NotAuthorizedError from "../../utils/NotAuthorizedError"; + +type UserDataType = { + email: string; + password: string; +}; + +type jwtType = { + iv: Buffer; + _id: string; +}; + +const uknownUserType = User as unknown; + +const UserStaticType = uknownUserType as { + findByCreds: (email: string, password: string) => Promise; +}; + +class UserService { + constructor() {} + + login = async (userData: UserDataType, uuid: string | undefined) => { + const email = userData.email; + const password = userData.password; + + const user = await UserStaticType.findByCreds(email, password); + + if (!user) throw new NotFoundError("Cannot Find User"); + + const { accessToken, refreshToken } = await user.generateAuthToken(uuid); + + if (!accessToken || !refreshToken) + throw new NotFoundError("Login User Not Found Error"); + + return { user, accessToken, refreshToken }; + }; + + logout = async (userID: string, refreshToken: string) => { + const user = await User.findById(userID); + + if (!user) throw new NotFoundError("Could Not Find User"); + + if (refreshToken) { + const decoded = jwt.verify(refreshToken, env.passwordRefresh!) as jwtType; + const encrpytionKey = user.getEncryptionKey(); + const encryptedToken = user.encryptToken( + refreshToken, + encrpytionKey, + decoded.iv + ); + + for (let i = 0; i < user.tokens.length; i++) { + const currentEncryptedToken = user.tokens[i].token; + + if (currentEncryptedToken === encryptedToken) { + user.tokens.splice(i, 1); + await user.save(); + break; + } + } + } + + await user.save(); + }; + + logoutAll = async (userID: string) => { + const user = await User.findById(userID); + + if (!user) throw new NotFoundError("Could Not Find User"); + + user.tokens = []; + user.tempTokens = []; + + await user.save(); + }; + + create = async (userData: any, uuid: string | undefined) => { + if (env.createAcctBlocked) { + throw new ForbiddenError("Account Creation Blocked"); + } + + const userExistsLookedUp = await User.findOne({ email: userData.email }); + + if (userExistsLookedUp) { + throw new ConflictError("Email Already Exists"); + } + + const user = new User({ + email: userData.email, + password: userData.password, + }); + await user.save(); + + if (!user) throw new NotFoundError("User Not Found"); + + await user.generateEncryptionKeys(); + + const { accessToken, refreshToken } = await user.generateAuthToken(uuid); + + let emailSent = false; + + if (env.emailVerification === "true") { + const emailToken = await user.generateEmailVerifyToken(); + + emailSent = await sendEmailVerification(user, emailToken); + } + + if (!accessToken || !refreshToken) + throw new InternalServerError("Could Not Create New User Error"); + + return { user, accessToken, refreshToken, emailSent }; + }; + + changePassword = async ( + userID: string, + oldPassword: string, + newPassword: string, + oldRefreshToken: string, + uuid: string | undefined + ) => { + const user = await User.findById(userID); + + if (!user) throw new NotAuthorizedError("User information is incorrect"); + + const date = new Date(); + + const isMatch = await bcrypt.compare(oldPassword, user.password); + + if (!isMatch) throw new NotAuthorizedError("User information is incorrect"); + + const encryptionKey = user.getEncryptionKey(); + + user.password = newPassword; + + user.tokens = []; + user.tempTokens = []; + user.passwordLastModified = date.getTime(); + + await user.save(); + await user.changeEncryptionKey(encryptionKey!); + + const { accessToken, refreshToken } = await user.generateAuthToken(uuid); + + return { accessToken, refreshToken }; + }; + + getUserDetailed = async (userID: string) => { + const user = await User.findById(userID); + + if (!user) throw new NotFoundError("Cannot find user"); + + return user; + }; + + verifyEmail = async (verifyToken: any) => { + const decoded: any = jwt.verify(verifyToken!, env.passwordAccess!); + + const iv = decoded.iv; + + const user = (await User.findOne({ _id: decoded._id })) as UserInterface; + const encrpytionKey = user.getEncryptionKey(); + const encryptedToken = user.encryptToken(verifyToken, encrpytionKey, iv); + + if (encryptedToken === user.emailToken) { + user.emailVerified = true; + await user.save(); + return user; + } else { + throw new ForbiddenError("Email Token Verification Failed"); + } + }; + + resendVerifyEmail = async (userID: string) => { + const user = await User.findById(userID); + + if (!user) throw new NotFoundError("Cannot find user"); + + const verifiedEmail = user.emailVerified; + + if (env.emailVerification !== "true") { + throw new ForbiddenError("Email Verification Disabled"); + } + + if (!verifiedEmail) { + const emailToken = await user.generateEmailVerifyToken(); + const result = await sendVerificationEmail(user, emailToken); + if (!result) throw new InternalServerError("Email Verification Error"); + } else { + throw new ForbiddenError("Email Already Authorized"); + } + }; + + sendPasswordReset = async (email: string) => { + if (env.emailVerification !== "true") { + throw new ForbiddenError("Email Verification Not Enabled"); + } + + const user = await User.findOne({ email }); + + if (!user) throw new NotFoundError("User Not Found Password Reset Email"); + + const passwordResetToken = await user.generatePasswordResetToken(); + + await sendPasswordResetEmail(user, passwordResetToken!); + }; + + resetPassword = async (newPassword: string, verifyToken: any) => { + const decoded: any = jwt.verify(verifyToken!, env.passwordAccess!); + + const iv = decoded.iv; + + const user = (await User.findOne({ _id: decoded._id })) as UserInterface; + const encrpytionKey = user.getEncryptionKey(); + const encryptedToken = user.encryptToken(verifyToken, encrpytionKey, iv); + + if (encryptedToken === user.passwordResetToken) { + const encryptionKey = user.getEncryptionKey(); + + user.password = newPassword; + + user.tokens = []; + user.tempTokens = []; + user.passwordResetToken = undefined; + + await user.save(); + await user.changeEncryptionKey(encryptionKey!); + } else { + throw new ForbiddenError("Reset Password Token Do Not Match"); + } + }; +} + +export default UserService; diff --git a/backend/tempStorage/tempStorage.ts b/backend/tempStorage/tempStorage.ts new file mode 100644 index 0000000..270004f --- /dev/null +++ b/backend/tempStorage/tempStorage.ts @@ -0,0 +1,3 @@ +const tempStorage: any = {}; + +export default tempStorage; \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..b38a93d --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,83 @@ +{ + "compilerOptions": { + /* Basic Options */ + // "incremental": true, /* Enable incremental compilation */ + "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, + "module": "CommonJS" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, + // "lib": [], /* Specify library files to be included in the compilation. */ + "allowJs": true /* Allow javascript files to be compiled. */, + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + // "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + // "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + "outDir": "../dist-backend" /* Redirect output structure to the directory. */, + "rootDir": "./" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + /* Strict Type-Checking Options */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + + /* Module Resolution Options */ + "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + "typeRoots": [ + "./node_modules/@types" + ] /* List of folders to include type definitions from. */, + "types": [] /* Type declaration files to be included in compilation. */, + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + + /* Advanced Options */ + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ + }, + "exclude": [ + "serverUtils", + "src", + "key", + "public", + "node_modules", + "webpack.config.js", + "dist", + "webUI", + "webUI.config.js", + "webUISetup", + "webUISetup.config.js", + "vite.config.js", + "tailwind.config.js", + "postcss.config.js", + "config" + ] +} diff --git a/backend/types/file-types.ts b/backend/types/file-types.ts new file mode 100644 index 0000000..94b1e91 --- /dev/null +++ b/backend/types/file-types.ts @@ -0,0 +1,11 @@ +export interface FileListQueryType { + userID: string; + search: string | undefined; + parent: string; + startAtDate: string | undefined; + startAtName: string | undefined; + trashMode: boolean; + mediaMode: boolean; + sortBy: string; + mediaFilter: string; +} diff --git a/backend/types/folder-types.ts b/backend/types/folder-types.ts new file mode 100644 index 0000000..5cff5c4 --- /dev/null +++ b/backend/types/folder-types.ts @@ -0,0 +1,6 @@ +export interface FolderListQueryType { + userID: string; + search: string | undefined; + parent: string; + trashMode: boolean; +} diff --git a/backend/utils/ConflictError.ts b/backend/utils/ConflictError.ts new file mode 100644 index 0000000..2b886f9 --- /dev/null +++ b/backend/utils/ConflictError.ts @@ -0,0 +1,11 @@ +class ConflictError extends Error { + code: number; + + constructor(args: any) { + super(args); + + this.code = 409; + } +} + +export default ConflictError; diff --git a/backend/utils/ForbiddenError.ts b/backend/utils/ForbiddenError.ts new file mode 100644 index 0000000..44b036a --- /dev/null +++ b/backend/utils/ForbiddenError.ts @@ -0,0 +1,11 @@ +class ForbiddenError extends Error { + code: number; + + constructor(args: any) { + super(args); + + this.code = 403; + } +} + +export default ForbiddenError; diff --git a/backend/utils/InternalServerError.ts b/backend/utils/InternalServerError.ts new file mode 100644 index 0000000..c93ae4e --- /dev/null +++ b/backend/utils/InternalServerError.ts @@ -0,0 +1,10 @@ +class InternalServerError extends Error { + code: number; + constructor(args: any) { + super(args); + + this.code = 500; + } +} + +export default InternalServerError; diff --git a/backend/utils/NotAuthorizedError.ts b/backend/utils/NotAuthorizedError.ts new file mode 100644 index 0000000..bc3f770 --- /dev/null +++ b/backend/utils/NotAuthorizedError.ts @@ -0,0 +1,11 @@ +class NotAuthorizedError extends Error { + code: number; + + constructor(args: any) { + super(args); + + this.code = 401; + } +} + +export default NotAuthorizedError; diff --git a/backend/utils/NotEmailVerifiedError.ts b/backend/utils/NotEmailVerifiedError.ts new file mode 100644 index 0000000..8c33281 --- /dev/null +++ b/backend/utils/NotEmailVerifiedError.ts @@ -0,0 +1,13 @@ +class NotEmailVerifiedError extends Error { + code: number; + isCustomError: boolean; + + constructor(args: any) { + super(args); + + this.code = 404; + this.isCustomError = true; + } +} + +export default NotEmailVerifiedError; diff --git a/backend/utils/NotFoundError.ts b/backend/utils/NotFoundError.ts new file mode 100644 index 0000000..70de105 --- /dev/null +++ b/backend/utils/NotFoundError.ts @@ -0,0 +1,11 @@ +class NotFoundError extends Error { + code: number; + + constructor(args: any) { + super(args); + + this.code = 404; + } +} + +export default NotFoundError; diff --git a/backend/utils/NotValidDataError.ts b/backend/utils/NotValidDataError.ts new file mode 100644 index 0000000..893ca7c --- /dev/null +++ b/backend/utils/NotValidDataError.ts @@ -0,0 +1,11 @@ +class NotValidDataError extends Error { + code: number; + + constructor(args: any) { + super(args); + + this.code = 403; + } +} + +export default NotValidDataError; diff --git a/backend/utils/convertDriveFolderToMongoFolder.ts b/backend/utils/convertDriveFolderToMongoFolder.ts new file mode 100644 index 0000000..267a084 --- /dev/null +++ b/backend/utils/convertDriveFolderToMongoFolder.ts @@ -0,0 +1,17 @@ +const convertDriveFolderToMongoFolder = (driveObj: any, ownerID: string) => { + + let convertedObj:any = {}; + convertedObj._id = driveObj.id; + convertedObj.name = driveObj.name; + convertedObj.createdAt = driveObj.createdTime; + convertedObj.owner = ownerID; + convertedObj.parent = driveObj.parents[driveObj.parents.length - 1]; + convertedObj.parentList = driveObj.parents; + convertedObj.updatedAt = driveObj.createdTime; + convertedObj.drive = true; + convertedObj.googleDoc = driveObj.mimeType === "application/vnd.google-apps.document"; + + return convertedObj; +} + +export default convertDriveFolderToMongoFolder; \ No newline at end of file diff --git a/backend/utils/convertDriveFoldersToMongoFolders.ts b/backend/utils/convertDriveFoldersToMongoFolders.ts new file mode 100644 index 0000000..727f2c3 --- /dev/null +++ b/backend/utils/convertDriveFoldersToMongoFolders.ts @@ -0,0 +1,14 @@ +import convertDriveFolderToMongoFolder from "./convertDriveFolderToMongoFolder"; + +const convertDriveFoldersToMongoFolders = (driveObjs: any, ownerID: string) => { + + let convertedFolders = []; + + for (let currentFolder of driveObjs) { + convertedFolders.push(convertDriveFolderToMongoFolder(currentFolder, ownerID)) + } + + return convertedFolders; +} + +export default convertDriveFoldersToMongoFolders; \ No newline at end of file diff --git a/backend/utils/convertDriveListToMongoList.ts b/backend/utils/convertDriveListToMongoList.ts new file mode 100644 index 0000000..b5e5e61 --- /dev/null +++ b/backend/utils/convertDriveListToMongoList.ts @@ -0,0 +1,14 @@ +import convertDriveToMongo from "./convertDriveToMongo"; + +const convertDriveListToMongoList = (driveObjs: any, ownerID:string, pageToken?: string | null | undefined) => { + + let convertedObjs = []; + + for (let currentObj of driveObjs) { + convertedObjs.push(convertDriveToMongo(currentObj, ownerID, pageToken)); + } + + return convertedObjs; +} + +export default convertDriveListToMongoList; \ No newline at end of file diff --git a/backend/utils/convertDriveToMongo.ts b/backend/utils/convertDriveToMongo.ts new file mode 100644 index 0000000..51697c8 --- /dev/null +++ b/backend/utils/convertDriveToMongo.ts @@ -0,0 +1,36 @@ +import videoChecker from "./videoChecker"; +import { FileInterface } from "../models/file-model"; + +const convertDriveToMongo = ( + driveObj: any, + ownerID: string, + pageToken?: string | undefined | null +) => { + let convertedObj: any = {}; + convertedObj._id = driveObj.id; + convertedObj.filename = driveObj.name; + convertedObj.length = driveObj.size; + convertedObj.uploadDate = driveObj.modifiedTime; + convertedObj.pageToken = pageToken; + convertedObj.metadata = { + IV: "", + hasThumbnail: driveObj.hasThumbnail, + isVideo: videoChecker(driveObj.name), + owner: ownerID, + parent: + driveObj.parents[driveObj.parents.length - 1] === "root" + ? "/" + : driveObj.parents[driveObj.parents.length - 1], + parentList: driveObj.parents, + size: driveObj.size, + drive: true, + googleDoc: driveObj.mimeType === "application/vnd.google-apps.document", + thumbnailID: driveObj.thumbnailLink, + link: driveObj.shared ? driveObj.webViewLink : undefined, + linkType: driveObj.shared ? "public" : undefined, + }; + + return convertedObj; +}; + +export default convertDriveToMongo; diff --git a/backend/utils/createEmailTransporter.ts b/backend/utils/createEmailTransporter.ts new file mode 100644 index 0000000..fbf99a9 --- /dev/null +++ b/backend/utils/createEmailTransporter.ts @@ -0,0 +1,33 @@ +import env from "../enviroment/env"; +import nodemailer from "nodemailer"; + +const createEmailTransporter = () => { + const emailVerification = env.emailVerification === "true"; + const emailAPIKey = env.emailAPIKey; + const emailDomain = env.emailDomain; + const emailHost = env.emailHost; + const emailPort = env.emailPort; + const emailAddress = env.emailAddress; + + if (!emailVerification) { + throw new Error("Email Verification Not Enabled"); + } + + if (!emailAPIKey || !emailDomain || !emailHost || !emailAddress) { + throw new Error("Email Verification Not Setup Correctly"); + } + + const transporter = nodemailer.createTransport({ + // @ts-ignore + host: emailHost, + port: emailPort || 587, + auth: { + user: emailDomain, + pass: emailAPIKey, + }, + }); + + return transporter; +}; + +export default createEmailTransporter; diff --git a/backend/utils/createQuery.ts b/backend/utils/createQuery.ts new file mode 100644 index 0000000..d880f54 --- /dev/null +++ b/backend/utils/createQuery.ts @@ -0,0 +1,104 @@ +import { ObjectId } from "mongodb"; +import { FileListQueryType } from "../types/file-types"; +import { FolderListQueryType } from "../types/folder-types"; + +export interface FileQueryInterface { + "metadata.owner": ObjectId | string; + "metadata.parent"?: string; + filename?: + | string + | RegExp + | { + $lt?: string; + $gt?: string; + }; + uploadDate?: { + $lt?: Date; + $gt?: Date; + }; + "metadata.personalFile"?: boolean | null; + "metadata.trashed"?: boolean | null; + "metadata.hasThumbnail"?: boolean | null; + "metadata.isVideo"?: boolean | null; + "metadata.processingFile"?: boolean | null; +} + +export const createFileQuery = ({ + userID, + search, + parent, + startAtDate, + startAtName, + trashMode, + mediaMode, + sortBy, + mediaFilter, +}: FileListQueryType) => { + const query: FileQueryInterface = { "metadata.owner": userID }; + + if (search && search !== "") { + query["filename"] = new RegExp(search, "i"); + } else if (!mediaMode) { + query["metadata.parent"] = parent; + } + + if (sortBy === "date_desc" && startAtDate) { + query.uploadDate = { $lt: new Date(startAtDate) }; + } else if (sortBy === "date_asc" && startAtDate) { + query.uploadDate = { $gt: new Date(startAtDate) }; + } else if (sortBy === "alp_desc" && startAtName) { + query.filename = { $lt: startAtName }; + } else if (sortBy === "alp_asc" && startAtName) { + query.filename = { $gt: startAtName }; + } + + if (trashMode) { + query["metadata.trashed"] = true; + } else { + query["metadata.trashed"] = null; + } + + if (mediaMode) { + query["metadata.hasThumbnail"] = true; + + if (mediaFilter === "photos") { + query["metadata.isVideo"] = false; + } else if (mediaFilter === "videos") { + query["metadata.isVideo"] = true; + } + } + + query["metadata.processingFile"] = null; + + return query; +}; + +export interface FolderQueryInterface { + owner: ObjectId | string; + parent?: string; + name?: string | RegExp; + trashed?: boolean | null; +} + +export const createFolderQuery = ({ + userID, + search, + parent, + trashMode, +}: FolderListQueryType) => { + const query: FolderQueryInterface = { owner: userID }; + + if (search && search !== "") { + query["name"] = new RegExp(search, "i"); + } else { + query["parent"] = parent; + } + + if (trashMode) { + query["trashed"] = true; + } else { + query["trashed"] = null; + } + + return query; +}; diff --git a/backend/utils/createQueryGoogle.ts b/backend/utils/createQueryGoogle.ts new file mode 100644 index 0000000..5d8b88e --- /dev/null +++ b/backend/utils/createQueryGoogle.ts @@ -0,0 +1,34 @@ +const createQueryGoogle = (query: any, parent: any) => { + + let queryBuilder = `mimeType != "application/vnd.google-apps.folder"` + + let orderBy = "" + + if (query.sortby === "date_desc" || query.sortby === "DEFAULT") { + orderBy = "modifiedTime desc" + } else if (query.sortby === "date_asc") { + orderBy = "modifiedTime asc" + } else if (query.sortby === "alp_desc") { + orderBy = "name desc" + } else { + orderBy = "name asc" + } + + if (query.search && query.search.length !== 0) { + queryBuilder += ` and name contains "${query.search}"` + } else { + queryBuilder += ` and "${parent}" in parents` + } + + queryBuilder += ` and trashed=false`; + + return {queryBuilder, orderBy} +} + +export interface googleQueryType { + limit: number, + parent: string, + pageToken: string, +} + +export default createQueryGoogle; \ No newline at end of file diff --git a/backend/utils/createQueryGoogleFolder.ts b/backend/utils/createQueryGoogleFolder.ts new file mode 100644 index 0000000..612b34b --- /dev/null +++ b/backend/utils/createQueryGoogleFolder.ts @@ -0,0 +1,28 @@ +const createQueryGoogleFolder = (query: any, parent: string) => { + + let orderBy = "" + + if (query.sortby === "date_desc") { + orderBy = "modifiedTime desc" + } else if (query.sortby === "date_asc") { + orderBy = "modifiedTime asc" + } else if (query.sortby === "alp_desc") { + orderBy = "name desc" + } else { + orderBy = "name asc" + } + + let queryBuilder = `mimeType = "application/vnd.google-apps.folder"` + + if (query.search && query.search.length !== 0) { + queryBuilder += ` and name contains "${query.search}"` + } else { + queryBuilder += ` and "${parent}" in parents`; + } + + queryBuilder += ` and trashed=false`; + + return {orderBy, queryBuilder} +} + +export default createQueryGoogleFolder; \ No newline at end of file diff --git a/backend/utils/getFSStoragePath.ts b/backend/utils/getFSStoragePath.ts new file mode 100644 index 0000000..cc98539 --- /dev/null +++ b/backend/utils/getFSStoragePath.ts @@ -0,0 +1,5 @@ +import env from "../enviroment/env"; + +export const getFSStoragePath = () => { + return env.fsDirectory +}; diff --git a/backend/utils/getKeyFromTerminal.ts b/backend/utils/getKeyFromTerminal.ts new file mode 100644 index 0000000..fec3a5e --- /dev/null +++ b/backend/utils/getKeyFromTerminal.ts @@ -0,0 +1,17 @@ +import prompts from "prompts"; + +const getKeyFromTerminal = async () => { + return new Promise((resolve, _) => { + setTimeout(async () => { + const response = await prompts({ + type: "password", + name: "key", + message: "Enter Server Encryption Key", + }); + + resolve(response.key); + }, 1500); + }); +}; + +export default getKeyFromTerminal; diff --git a/backend/utils/imageChecker.ts b/backend/utils/imageChecker.ts new file mode 100644 index 0000000..5fa3b44 --- /dev/null +++ b/backend/utils/imageChecker.ts @@ -0,0 +1,19 @@ +const imageExtList = ["jpeg", "jpg", "png", "gif", "svg", "tiff", "bmp"]; + +const imageChecker = (filename: string) => { + if (filename.length < 1 || !filename.includes(".")) { + return false; + } + + const extSplit = filename.split("."); + + if (extSplit.length <= 1) { + return false; + } + + const ext = extSplit[extSplit.length - 1]; + + return imageExtList.includes(ext.toLowerCase()); +}; + +export default imageChecker; diff --git a/backend/utils/mobileCheck.ts b/backend/utils/mobileCheck.ts new file mode 100644 index 0000000..2072520 --- /dev/null +++ b/backend/utils/mobileCheck.ts @@ -0,0 +1,8 @@ +declare let window:any + +const mobilecheck = () => { + var check = false; + (function(a){if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))) check = true;})(navigator.userAgent||navigator.vendor||window.opera); + return check; + }; +export default mobilecheck; diff --git a/backend/utils/sanitizeFilename.ts b/backend/utils/sanitizeFilename.ts new file mode 100644 index 0000000..1bc3849 --- /dev/null +++ b/backend/utils/sanitizeFilename.ts @@ -0,0 +1,9 @@ +const sanitizeFilename = (filename: string) => { + filename = filename.replace(/[\u0000-\u001F\u007F\u202F]/g, " "); + filename = filename.replace(/["<>:|?*\\;]/g, "_"); + filename = filename.trim(); + + return filename; +}; + +export default sanitizeFilename; diff --git a/backend/utils/sendPasswordResetEmail.ts b/backend/utils/sendPasswordResetEmail.ts new file mode 100644 index 0000000..4beb61d --- /dev/null +++ b/backend/utils/sendPasswordResetEmail.ts @@ -0,0 +1,57 @@ +import SMTPTransport from "nodemailer/lib/smtp-transport"; +import env from "../enviroment/env"; +import { UserInterface } from "../models/user-model"; +import nodemailer from "nodemailer"; +import createEmailTransporter from "./createEmailTransporter"; + +type MailOptionsType = { + from: string; + to: string; + subject: string; + text: string; +}; + +const sendEmail = ( + transporter: nodemailer.Transporter, + mailOptions: MailOptionsType +) => { + return new Promise((resolve, reject) => { + transporter.sendMail(mailOptions, (error, info) => { + if (error) { + reject(error); + } else { + resolve(info); + } + }); + }); +}; + +const sendPasswordResetEmail = async ( + user: UserInterface, + resetToken: string +) => { + try { + // TODO: Fix any, for some reason some envs come up with a ts error for this + const transporter = createEmailTransporter() as any; + + const emailAddress = env.emailAddress!; + const url = env.remoteURL + `/reset-password/${resetToken}`; + + const mailOptions = { + from: emailAddress, + to: user.email, + subject: "myDrive Password Reset", + text: + "Please navigate to the following link to reset your password: " + url, + }; + + await sendEmail(transporter, mailOptions); + + return true; + } catch (e) { + console.log("Error sending password reset email", e); + return false; + } +}; + +export default sendPasswordResetEmail; diff --git a/backend/utils/sendShareEmail.ts b/backend/utils/sendShareEmail.ts new file mode 100644 index 0000000..14673e8 --- /dev/null +++ b/backend/utils/sendShareEmail.ts @@ -0,0 +1,23 @@ +import env from "../enviroment/env"; +// import sgMail from "@sendgrid/mail"; + +const currentURL = env.remoteURL; + +const sendShareEmail = async (file: any, respient: string) => { + // if (process.env.NODE_ENV === "test") { + // return; + // } + // const apiKey: any = env.sendgridKey; + // const sendgridEmail:any = env.sendgridEmail; + // sgMail.setApiKey(apiKey); + // const fileLink = `${currentURL}/download-page/${file._id}/${file.metadata.link}` + // const msg = { + // to: respient, + // from: sendgridEmail, + // subject: "A File Was Shared With You Through myDrive", + // text: `Please navigate to the following link to view the file ${fileLink}` + // } + // await sgMail.send(msg); +}; + +export default sendShareEmail; diff --git a/backend/utils/sendVerificationEmail.ts b/backend/utils/sendVerificationEmail.ts new file mode 100644 index 0000000..e690cb8 --- /dev/null +++ b/backend/utils/sendVerificationEmail.ts @@ -0,0 +1,58 @@ +import SMTPTransport from "nodemailer/lib/smtp-transport"; +import env from "../enviroment/env"; +import { UserInterface } from "../models/user-model"; +import nodemailer from "nodemailer"; +import createEmailTransporter from "./createEmailTransporter"; + +type MailOptionsType = { + from: string; + to: string; + subject: string; + text: string; +}; + +const sendEmail = ( + transporter: nodemailer.Transporter, + mailOptions: MailOptionsType +) => { + return new Promise((resolve, reject) => { + transporter.sendMail(mailOptions, (error, info) => { + if (error) { + reject(error); + } else { + resolve(info); + } + }); + }); +}; + +const sendVerificationEmail = async ( + user: UserInterface, + emailToken: string +) => { + try { + // TODO: Fix any, for some reason some envs come up with a ts error for this + const transporter = createEmailTransporter() as any; + + const emailAddress = env.emailAddress!; + const url = env.remoteURL + `/verify-email/${emailToken}`; + + const mailOptions = { + from: emailAddress, + to: user.email, + subject: "myDrive Email Verification", + text: + "Please navigate to the following link to verify your email address: " + + url, + }; + + await sendEmail(transporter, mailOptions); + + return true; + } catch (e) { + console.log("Error sending email verification", e); + return false; + } +}; + +export default sendVerificationEmail; diff --git a/backend/utils/sortBySwitch.ts b/backend/utils/sortBySwitch.ts new file mode 100644 index 0000000..679a37f --- /dev/null +++ b/backend/utils/sortBySwitch.ts @@ -0,0 +1,19 @@ +type SortOrder = 1 | -1; + +interface SortBy { + [key: string]: SortOrder; +} + +const sortBySwitch = (sortBy: string): SortBy => { + if (sortBy === "date_desc") { + return { uploadDate: -1 }; + } else if (sortBy === "date_asc") { + return { uploadDate: 1 }; + } else if (sortBy === "alp_desc") { + return { filename: -1 }; + } else { + return { filename: 1 }; + } +}; + +export default sortBySwitch; diff --git a/backend/utils/sortBySwitchFolder.ts b/backend/utils/sortBySwitchFolder.ts new file mode 100644 index 0000000..3acb0e5 --- /dev/null +++ b/backend/utils/sortBySwitchFolder.ts @@ -0,0 +1,20 @@ +import { SortOrder } from "mongoose"; + +interface SortBy { + [key: string]: SortOrder; +} + +const sortBySwitchFolder = (sortBy: string): SortBy => { + switch (sortBy) { + case "alp_asc": + return { name: 1 }; + case "alp_desc": + return { name: -1 }; + case "date_asc": + return { createdAt: 1 }; + default: + return { createdAt: -1 }; + } +}; + +export default sortBySwitchFolder; diff --git a/backend/utils/sortGoogleMongoFolderList.ts b/backend/utils/sortGoogleMongoFolderList.ts new file mode 100644 index 0000000..471f47c --- /dev/null +++ b/backend/utils/sortGoogleMongoFolderList.ts @@ -0,0 +1,54 @@ +import { FolderInterface } from "../models/folder-model"; + +const sortGoogleMongoFolderList = ( + combinedList: FolderInterface[], + query: any +) => { + if (query.sortby === "date_desc" || query.sortby === "DEFAULT") { + combinedList = combinedList.sort((a, b) => { + const convertedDateA = new Date(a.createdAt).getTime(); + const convertedDateB = new Date(b.createdAt).getTime(); + return convertedDateB - convertedDateA; + }); + } else if (query.sortby === "date_asc") { + combinedList = combinedList.sort((a, b) => { + const convertedDateA = new Date(a.createdAt).getTime(); + const convertedDateB = new Date(b.createdAt).getTime(); + return convertedDateA - convertedDateB; + }); + } else if (query.sortby === "alp_desc") { + combinedList = combinedList.sort((a, b) => { + const name1 = a.name.toLowerCase(); + const name2 = b.name.toLowerCase(); + + if (name1 > name2) { + return -1; + } + + if (name2 > name1) { + return 1; + } + + return 0; + }); + } else if (query.sortby === "alp_asc") { + combinedList = combinedList.sort((a, b) => { + const name1 = a.name.toLowerCase(); + const name2 = b.name.toLowerCase(); + + if (name1 > name2) { + return 1; + } + + if (name2 > name1) { + return -1; + } + + return 0; + }); + } + + return combinedList; +}; + +export default sortGoogleMongoFolderList; diff --git a/backend/utils/sortGoogleMongoList.ts b/backend/utils/sortGoogleMongoList.ts new file mode 100644 index 0000000..c7dd733 --- /dev/null +++ b/backend/utils/sortGoogleMongoList.ts @@ -0,0 +1,55 @@ +import { FileInterface } from "../models/file-model"; + +const sortGoogleMongoList = (fileList: FileInterface[], query: any) => { + let combinedList = fileList; + + if (query.sortby === "date_desc" || query.sortby === "DEFAULT") { + combinedList = combinedList.sort((a, b) => { + const convertedDateA = new Date(a.uploadDate).getTime(); + const convertedDateB = new Date(b.uploadDate).getTime(); + //onsole.log("data", b.uploadDate, convertedDate) + return convertedDateB - convertedDateA; + }); + } else if (query.sortby === "date_asc") { + combinedList = combinedList.sort((a, b) => { + const convertedDateA = new Date(a.uploadDate).getTime(); + const convertedDateB = new Date(b.uploadDate).getTime(); + //onsole.log("data", b.uploadDate, convertedDate) + return convertedDateA - convertedDateB; + }); + } else if (query.sortby === "alp_desc") { + combinedList = combinedList.sort((a, b) => { + const name1 = a.filename.toLowerCase(); + const name2 = b.filename.toLowerCase(); + + if (name1 > name2) { + return -1; + } + + if (name2 > name1) { + return 1; + } + + return 0; + }); + } else if (query.sortby === "alp_asc") { + combinedList = combinedList.sort((a, b) => { + const name1 = a.filename.toLowerCase(); + const name2 = b.filename.toLowerCase(); + + if (name1 > name2) { + return 1; + } + + if (name2 > name1) { + return -1; + } + + return 0; + }); + } + + return combinedList; +}; + +export default sortGoogleMongoList; diff --git a/backend/utils/sortGoogleMongoQuickFiles.ts b/backend/utils/sortGoogleMongoQuickFiles.ts new file mode 100644 index 0000000..fa3c8a1 --- /dev/null +++ b/backend/utils/sortGoogleMongoQuickFiles.ts @@ -0,0 +1,19 @@ +const sortGoogleMongoQuickFiles = (convertedFiles: any[], quickList: any[]) => { + + let combinedData = [...convertedFiles, ...quickList] + + combinedData = combinedData.sort((a, b) => { + const convertedDateA = new Date(a.uploadDate).getTime(); + const convertedDateB = new Date(b.uploadDate).getTime(); + + return convertedDateB - convertedDateA; + }) + + if (combinedData.length >= 10) { + combinedData = combinedData.slice(0, 10); + } + + return combinedData; +} + +export default sortGoogleMongoQuickFiles; \ No newline at end of file diff --git a/backend/utils/streamToBuffer.ts b/backend/utils/streamToBuffer.ts new file mode 100644 index 0000000..7a193a9 --- /dev/null +++ b/backend/utils/streamToBuffer.ts @@ -0,0 +1,10 @@ +const streamToBuffer = (stream: any) => { + return new Promise((resolve, reject) => { + const chunks: any[] = []; + stream.on("data", (chunk: any) => chunks.push(chunk)); + stream.on("error", reject); + stream.on("end", () => resolve(Buffer.concat(chunks))); + }); +}; + +export default streamToBuffer; diff --git a/backend/utils/userUpdateCheck.ts b/backend/utils/userUpdateCheck.ts new file mode 100644 index 0000000..a5f6901 --- /dev/null +++ b/backend/utils/userUpdateCheck.ts @@ -0,0 +1,46 @@ +import User, { UserInterface } from "../models/user-model"; +import mongoose from "mongoose"; +import { Request, Response, NextFunction } from "express"; +import NotFoundError from "./NotFoundError"; +import { createLoginCookie } from "../cookies/create-cookies"; + +// interface RequestType extends Request { +// user?: userAccessType, +// token?: string, +// encryptedToken?: string, +// } + +type userAccessType = { + _id: mongoose.Types.ObjectId; + emailVerified: boolean; + email: string; + botChecked: boolean; +}; + +const userUpdateCheck = async ( + res: Response, + id: mongoose.Types.ObjectId, + uuid: string | undefined +) => { + const updatedUser = await User.findById(id); + + if (!updatedUser) throw new NotFoundError("Cannot find updated user auth"); + + if (updatedUser.emailVerified) { + const { accessToken, refreshToken } = await updatedUser.generateAuthToken( + uuid + ); + createLoginCookie(res, accessToken, refreshToken); + } + + let strippedUser: userAccessType = { + _id: updatedUser._id, + emailVerified: updatedUser.emailVerified!, + email: updatedUser.email, + botChecked: false, + }; + + return strippedUser; +}; + +export default userUpdateCheck; diff --git a/backend/utils/videoChecker.ts b/backend/utils/videoChecker.ts new file mode 100644 index 0000000..28bd071 --- /dev/null +++ b/backend/utils/videoChecker.ts @@ -0,0 +1,57 @@ +const videoExtList = [ + "3g2", + "3gp", + "aaf", + "asf", + "avchd", + "avi", + "drc", + "flv", + "m2v", + "m4p", + "m4v", + "mkv", + "mng", + "mov", + "mp2", + "mp4", + "mpe", + "mpeg", + "mpg", + "mpv", + "mxf", + "nsv", + "ogg", + "ogv", + "qt", + "rm", + "rmvb", + "roq", + "svi", + "vob", + "webm", + "wmv", + "yuv" +] + +const videoChecker = (filename: string) => { + + if (filename.length < 1 || !filename.includes(".")) { + + return false; + } + + const extSplit = filename.split("."); + + if (extSplit.length <= 1) { + + return false; + } + + const ext = extSplit[extSplit.length - 1]; + + return videoExtList.includes(ext.toLowerCase()); + +} + +export default videoChecker; \ No newline at end of file diff --git a/docker-compose copy.yml b/docker-compose copy.yml new file mode 100644 index 0000000..c2100c2 --- /dev/null +++ b/docker-compose copy.yml @@ -0,0 +1,35 @@ +version: '3.8' + +services: + mydrive: + image: kylehoell/mydrive:latest + container_name: mydrive-app + ports: + - "3001:3000" + environment: + - DOCKER=true + - MONGODB_URL=mongodb+srv://mydrive_user:Mohammad.93@cluster0.olmiwzg.mongodb.net/?appName=Cluster0 + - DB_TYPE= + - FS_DIRECTORY=/data/ + - KEY=thisis32charencryptionkey12345678901234 + - PASSWORD_ACCESS=access_token_secret_2024_123 + - PASSWORD_REFRESH=refresh_token_secret_2024_456 + - PASSWORD_COOKIE=cookie_secret_2024_789 + - VIDEO_THUMBNAILS_ENABLED=true + - TEMP_DIRECTORY=/temp/ + - TEMP_VIDEO_THUMBNAIL_LIMIT=5000000000 + volumes: + - ../data:/data + - ../temp:/temp + depends_on: + - mongo + restart: unless-stopped + + + + mongo: + image: mongo:latest + container_name: mydrive-mongo + volumes: + - ../mongo-data:/data/db + restart: unless-stopped \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c9ecbd6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +version: '3.8' + + +mongodb+srv://mydrive_user:Mohammad.93@cluster0.olmiwzg.mongodb.net +mongodb+srv://mydrive_user:Mohammad.93@mongodb-fd013b15dbc7 + + +services: + mydrive: + image: kylehoell/mydrive:latest + container_name: mydrive-app2 + ports: + - "3001:3000" + environment: + - DOCKER=true + - MONGODB_URL=mongodb+srv://mongoadmin:Mohammad.93@cluster0.olmiwzg.mongodb.net/?appName=Cluster0 + - DB_TYPE=fs + - FS_DIRECTORY=/data/ + - KEY=thisis32charencryptionkey12345678901234 + - PASSWORD_ACCESS=access_token_secret_2024_123 + - PASSWORD_REFRESH=refresh_token_secret_2024_456 + - PASSWORD_COOKIE=cookie_secret_2024_789 + - VIDEO_THUMBNAILS_ENABLED=true + - TEMP_DIRECTORY=/temp/ + - TEMP_VIDEO_THUMBNAIL_LIMIT=5000000000 + volumes: + - ../data:/data + - ../temp:/temp + + restart: unless-stopped diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..552444d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,26 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; +import tseslint from "typescript-eslint"; +import pluginReactConfig from "eslint-plugin-react/configs/recommended.js"; +import pluginReactHooks from "eslint-plugin-react-hooks"; +import pluginReact from "eslint-plugin-react"; + +export default [ + { files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"] }, + { languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } } }, + { languageOptions: { globals: globals.browser } }, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + { + ...pluginReactConfig, + plugins: { + react: pluginReact, + "react-hooks": pluginReactHooks, + }, + rules: { + "react/react-in-jsx-scope": "off", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "error", + }, + }, +]; diff --git a/index.html b/index.html new file mode 100644 index 0000000..5e81dba --- /dev/null +++ b/index.html @@ -0,0 +1,19 @@ + + + + + myDrive + + + + + + + + + + +
+ + + diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..c2c208b --- /dev/null +++ b/jest.config.js @@ -0,0 +1,198 @@ +/** + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/configuration + */ + +/** @type {import('jest').Config} */ +const config = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "/private/var/folders/zh/sgf63g810ss17919rfg886r00000gn/T/jest_dx", + + // Automatically clear mock calls, instances, contexts and results before every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + // collectCoverage: true, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: "v8", + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // The default configuration for fake timers + // fakeTimers: { + // "enableGlobally": false + // }, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "mjs", + // "cjs", + // "jsx", + // "ts", + // "tsx", + // "json", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + // moduleNameMapper: {}, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: undefined, + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state before every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state and implementation before every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + // testEnvironment: "jest-environment-node", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + // ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "/node_modules/" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jest-circus/runner", + + // A map from regular expressions to paths to transformers + // transform: undefined, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/", + // "\\.pnp\\.[^\\/]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; + +module.exports = config; diff --git a/key/getKey.js b/key/getKey.js new file mode 100644 index 0000000..05b51ef --- /dev/null +++ b/key/getKey.js @@ -0,0 +1,38 @@ +const prompt = require('password-prompt') +const env = require("../dist/enviroment/env") +const crypto = require("crypto"); + +// NOT IN USE + +const getKey = async() => { + + if (process.env.KEY) { + // For Docker + + let password = process.env.KEY; + + password = crypto.createHash("md5").update(password).digest("hex"); + + env.key = password; + + //console.log("Docker Key", env.key); + + } else if (process.env.NODE_ENV) { + + let password = await prompt("Enter Server Encryption Password: ", {method: "hide"}); + + password = crypto.createHash("md5").update(password).digest("hex"); + + env.key = password; + + } else { + + let password = "1234"; + + password = crypto.createHash("md5").update(password).digest("hex"); + + env.key = password; + } +} + +module.exports = getKey; \ No newline at end of file diff --git a/key/getNewKey.js b/key/getNewKey.js new file mode 100644 index 0000000..253ad1a --- /dev/null +++ b/key/getNewKey.js @@ -0,0 +1,22 @@ +const prompt = require('password-prompt') +import env from "../backend/enviroment/env"; +const crypto = require("crypto"); + +const getKey = async() => { + + let password = await prompt("Enter New Server Encryption Password: ", {method: "hide"}); + + let confirmPassword = await prompt("Verify New Server Encryption Password: ", {method: "hide"}); + + if (password !== confirmPassword) { + console.log("New Passwords do not match, exiting..."); + process.exit(); + } + + password = crypto.createHash("md5").update(password).digest("hex"); + + env.newKey = password; + +} + +module.exports = getKey; \ No newline at end of file diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..6738ca9 --- /dev/null +++ b/nodemon.json @@ -0,0 +1,6 @@ +{ + "watch": ["dist-backend/"], + "ignore": ["src", "node_modules"], + "ext": "js,json", + "exec": "node dist-backend/server/server-start.js" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..11d9036 --- /dev/null +++ b/package.json @@ -0,0 +1,134 @@ +{ + "name": "mydrive", + "version": "4.0.2", + "main": "index.js", + "license": "GNU General Public License v3.0", + "engines": { + "node": ">=20.14.0" + }, + "scripts": { + "dev": "concurrently \"vite\" \"tsc -w -p ./backend/tsconfig.json\" \"npm run dev:backend\"", + "dev:backend": "NODE_ENV=development nodemon --quiet dist-backend/server/server-start.js", + "build:frontend": "vite build", + "build:backend": "tsc -p ./backend/tsconfig.json", + "build": "npm run build:frontend && npm run build:backend", + "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'", + "start": "NODE_ENV=production node dist-backend/server/server-start.js", + "create-video-thumbnails": "NODE_ENV=production node serverUtils/createVideoThumbnails.js", + "create-video-thumbnails:dev": "NODE_ENV=development node serverUtils/createVideoThumbnails.js", + "migrate-to-mydrive4": "NODE_ENV=production node serverUtils/migrateMyDrive4.js", + "migrate-to-mydrive4:dev": "NODE_ENV=development node serverUtils/migrateMyDrive4.js", + "test": "NODE_ENV=test jest" + }, + "dependencies": { + "@reduxjs/toolkit": "^2.2.5", + "archiver": "^7.0.1", + "async": "^3.2.6", + "aws-sdk": "^2.657.0", + "axios": "^1.7.2", + "bcryptjs": "^3.0.2", + "body-parser": "^1.20.2", + "bytes": "^3.1.0", + "classnames": "^2.5.1", + "cli-progress": "^3.6.0", + "compression": "^1.7.4", + "concat-stream": "^2.0.0", + "connect-busboy": "^1.0.0", + "cookie-parser": "^1.4.6", + "copy-text-to-clipboard": "^2.1.1", + "core-js": "^3.6.4", + "cors": "^2.8.5", + "dayjs": "^1.11.13", + "dotenv": "^8.2.0", + "express": "^4.19.2", + "express-validator": "^7.1.0", + "fluent-ffmpeg": "^2.1.3", + "helmet": "^3.21.2", + "history": "^4.10.1", + "jsonwebtoken": "^9.0.2", + "lodash.debounce": "^4.0.8", + "mongodb": "^6.20.0", + "mongoose": "^8.4.1", + "nodemailer": "^6.9.14", + "normalize.css": "^8.0.1", + "password-prompt": "^1.1.2", + "progress-stream": "^2.0.0", + "prompts": "^2.4.2", + "raf": "^3.4.1", + "react": "^18.3.1", + "react-circular-progressbar": "^2.1.0", + "react-dom": "^18.3.1", + "react-query": "^3.39.3", + "react-redux": "^9.1.2", + "react-router-dom": "^6.23.1", + "react-toastify": "^10.0.5", + "redux": "^5.0.1", + "regenerator-runtime": "^0.13.3", + "sharp": "^0.33.4", + "sweetalert2": "^11.15.10", + "temp": "^0.9.1", + "uuid": "^3.4.0", + "validator": "^13.12.0" + }, + "devDependencies": { + "@babel/core": "^7.8.4", + "@babel/parser": "^7.9.4", + "@babel/plugin-proposal-class-properties": "^7.8.3", + "@babel/plugin-proposal-object-rest-spread": "^7.8.3", + "@babel/polyfill": "^7.12.1", + "@babel/preset-env": "^7.8.4", + "@babel/preset-react": "^7.8.3", + "@babel/types": "^7.9.5", + "@eslint/js": "^9.6.0", + "@types/archiver": "^6.0.3", + "@types/async": "^3.2.24", + "@types/bytes": "^3.1.4", + "@types/compression": "^1.7.0", + "@types/concat-stream": "^1.6.0", + "@types/connect-busboy": "0.0.2", + "@types/cookie-parser": "^1.4.2", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/fluent-ffmpeg": "^2.1.24", + "@types/helmet": "0.0.45", + "@types/jest": "^29.5.14", + "@types/jsonwebtoken": "^8.3.9", + "@types/lodash": "^4.17.5", + "@types/node": "^20.14.2", + "@types/nodemailer": "^6.4.15", + "@types/prompts": "^2.4.9", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/request": "^2.48.5", + "@types/request-ip": "0.0.35", + "@types/sharp": "^0.25.0", + "@types/supertest": "^6.0.2", + "@types/uuid": "^7.0.2", + "@types/validator": "^13.0.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.19", + "babel-polyfill": "^6.26.0", + "concurrently": "^8.2.2", + "cross-env": "^6.0.3", + "dart-sass": "^1.25.0", + "env-cmd": "^10.1.0", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.3", + "eslint-plugin-react-hooks": "^4.6.2", + "globals": "^15.7.0", + "jest": "^29.7.0", + "mongodb-memory-server": "^10.1.3", + "nodemon": "^3.1.3", + "postcss": "^8.4.38", + "rollup-plugin-visualizer": "^5.14.0", + "sass": "^1.77.4", + "superagent-binary-parser": "^1.0.1", + "supertest": "^6.3.4", + "supertest-session": "^4.1.0", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5", + "typescript-eslint": "^7.14.1", + "vite": "^5.2.13", + "vite-plugin-pwa": "^0.21.1" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/public/images/icon.png b/public/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..42ae808886f0d6990f69f728646ae58a5edf7f4b GIT binary patch literal 6418 zcmeHLX;@R&)?OzBjLd@rf=UFOs38MGAQA|Zf-)(HLs1DKU<8wp1Ox?gr~}$o5fubM zK|xSNp%n_ENN5XKCs0JhGDMtEQDQ|x0p&Y!y58?;?{}Z?&;4_G9-i#8_q*4-*4k^m zJ14sX{1)qInrH%mj<=V`asV7Q!~sDA`xuPBJ%W8q6nd=|W9#jzf5dHGPCS4H@PbxJ zR{8ocID$>q>~KLC*E)Wa5JLkvxx@?EoG7k@7{-m{@tNiW7wXK3yl|%ZN;_YQuh5;l zf#QbcX1{<#WOGio467-F@94tU(ASSn!mTpz{aX+vN`cRL=wd` zcU2J*SNR4I-31~p(azeQ#G%^S5$TTBRC^m+N5=(38indWrZ|wPHYBP&!hjYl>XznI1Un0h^)X!Mq27yE%-XQoJ)1R0Bi2zHjukUA$zw~9(rq2{& ziDw)Z#>a&GC0ZPmAmoylbH##K5r^v;hq-B?iblwA7jfAVfhb5Ii2ig?0iRqZy1T0m z#*sMBm(AhvRdLM!Vi(tgE#WfFX%rfTM6n}L9D`^Sh8>lmdbDLwC?BA{f^c3$!WU2~ zl|;1(!VoqThAkD_`41>oH{omv`~L)oa~Kf<(Iz$)HE$C;l1moyBh87QbIEWQL<>Y1 zG3K4km-F84?g1h}1TPv}5HDZsM)dY{x2HPV+mmS4)c5ZC`ZB!vVhNki;d*;8&9Mww z^LXJ5YM4WWgRP?-i9-vgkZfrjJCY;Uj!tq6qleMij*c{Yd%I8jJp`OsRUN4Ie`twt z0SB}35BI`3+^{ejolRoXs5Fwjy{#k3!O`Y}HLiU)l}_h|f3oH;;$dCMj{efB%Byh9 zA~(zyv*5rX(I^pIl5H4P_+d75F3E;&OXb>zJBHI}wyN5HUrr1!o)|mmgpb`6$c_0p zisljDR}h2EQFR8>oTDlUl?NZDd4G|Qe~bC!{>>Y>n9+YxB_C+Sf(XfGwutK*iRJF! zD@F3(MIdI!{cHdKz4QFw*cbl)b?N@U{r{LC&IUF=l8Xs0*<2M7StU&$j)(lePwxHN z7c%wdj9EW3!mJYYVqv`N$rLuf3&`S{Tc^9j{P*M4$y7{pzj61 zA|e1OcmTZNe<%La`AF_hMl!};wYywck8|oxXl+lrHyC@LEgk=J^yQ&|oq*1a)A_SG z@y%g8Zqgjc&-`}VyJcocD;^&AAmwWPVJ$%Ewy2BS##{$EJEl)M8@Cw ztJHUzwxCY0ePpFMdIk>*rx9FEXs0MIH4oYmad#I%x%)^_U8e7N!*k`V*tNUhCw&MZ z#U5CHb$>k*nR|#--e?|K5va|#7RzzI&_B#CK z$;<6u7IPms2i_XZ5jB^m8^Mjt^7IqhC#;tHspsN_R&ybj_s8Rl!FP;!D_YCy2eN_?*{vN9%}gYQviaD|&fPhRHTL&)i;fa}YK!lBfv{q?Y1{2%w2oh+>|b?< zSs=3XqD=utsa}V9?SgyE#zi=^z~^3;_PfWX(KGn9EP&5dL5(5(G**{ z8MEz`M^gdgY|dWgJPgZki{U)2P6=GvWOw`3I<6WbBQf~%X3+T?pH*ivy3d99fFfhB zsdtl3&b`EgzdGPhUi7WpQ>`PRf=6$JXG3665789C~`i?+?LzBHB?h`Qm-OJaL+E8UnJ#B}@+?5uvQCG;Kei z)7e=2^K9%A$~!egsdvT8eA@V{{B4D1O6}qg_mt4ypQgxKqV%f_^wcH7h%1U!UJPY@PbEj~!e*!ul1*!0Usw`1V_HOZ2$ zSkigZGUS)q##tZlOsC^lo?7S`!u0xd%aE(grmBf(_aq0{xGVpu%=IH`EBLa;2o>(>mUK!QuKU=x@XSSv^RqG9NYdbnXI7u<&L2P z-%UiLd`w0lQXfCn2%b;}Q_eBZoZou=`M^@`Toz1^dbWp9e!$ov1%{p7&EyR7*0cJ< za}Y)KVEw>jwK$QL!WSn`e!9&p-=r9)rw>&&!^lP>+-5~FTKCw0EiPkP+&H)AQi>6p zw%Q459Sw%J!!UW9S?H8mioL~8p1gkCWhQ+qzE_uo+cqB8e6?wD!0xxt<6cBl=18De1jw z=j!H*<`k;49(roXm&WL&dL#O=Y$V?6TB#QStu9%ZU9c#$W>+?q3Fg-3kZL-vaBzf0 z>m5*f?u{dy{sq}4z#-p_us0^&yqrxVL8Dm*b~P2|MRrDKPl5;5epLG@ef70ljxG~N zEP?N9=BalCboc9 zmO%g$=FHpr3ej9LyeohE6)EB5XoE794m0UoJ<`IfB^s;_dcQ{C6cS6_Xz2ESn=@f> zFtLDtJ1HF(#Fa8vH9+46w_~`V_JlH$Qus9quGG5H`)Z7lnr9}s6A>XNVBVX?&iv;% zeAe*?GeaWotk6XL7}gAQcYl*!i6L;m?bDcI$|807g)hB!(F$pCty0(6*ElmNRTBv~ zzWq|@GGwrYTi5M88aF{UM3-x5I+lolTDb>?;)EL!LbY#h@OhT*|Sfk%dNRjTQoaVlD(Fj?l*t+KR4ma!!UHfY%B; zoOR3!h##4dfBf$1eOqw)TkDZ|1N&1S-v&UZrt9rkiDy+l7{9Eg!}_YY*>5h^#>2v_ zDG)Q&7|rp#zRPU!TX*T$o93smV4L~GFIQFEl5hwbwvhp)gMID}Hu3kb3i%dlp|{6pPXtXy^c@EBkwInSGi57I zHlD7Jp8NhVwH2eUPWj}=dnO6SB$gEASDfVY)Z^1U! zYU#?}ghjfFpmcKLedO}cgP+UZRBY7keDcUbGyj;&yOBuc(!R63J~h!ZPvl_*eyUed zce@PqjOllNOuF8UGj3^ z>vioJb?S;WZ9Rne`*bykIOQZx9vyA&DH}h2NZR0RMV0Xsb54&}3xP^ct@76G%!>X6r>8^I5rJ?&k~fvev^@lyy><6H^JY--=!gkgp89z@+y~wt>P;8+b)8pODw|KU#~;bQmal zUntwd43I?jNcY<{IY}1+p)|t)DXUf1{5Ghd$+V@1a&rH-$8t0w2!CBH^CoOv_$ zbS9A3tjEJWp3CMUyljn{wFZ#B&!Qi|9GNv$9o@eusox39kclJ)=5es=&KRO}E(Sof zjjA2c%AM;dU~Lzd>7$E{lh&!pPAqCpdx|K+20iie00X_W`AB)NtN}0oJuJd=IaYnk zW5LOCqjRCU?7NfSY0HYu&2$6RLeI2d1xgNk!VJgI1WA)&dP9pe6^66)q3^nU9QzV+7#lUn>r$Zlh(4UWF0b1L z)tD}wY>-P)i0^!^K34I~kVejaDtLcjSRlKuM7 literal 0 HcmV?d00001 diff --git a/serverUtils/backupDatabase.js b/serverUtils/backupDatabase.js new file mode 100644 index 0000000..d19d635 --- /dev/null +++ b/serverUtils/backupDatabase.js @@ -0,0 +1,56 @@ +const mongoose = require("../backend/db/mongooseServerUtils"); +const conn = mongoose.connection; +const prompts = require("prompts"); +const createTempDirectory = require("./createTempDirectory"); + +const waitForDatabase = () => { + + return new Promise((resolve, reject) => { + + if (conn.readyState !== 1) { + + conn.once("open", () => { + + resolve(); + + }) + + } else { + + resolve(); + } + + }) +} + +const copyDatabase = async() => { + + console.log("Waiting For Database Connection..."); + await waitForDatabase(); + console.log("Connected To Database\n"); + + const userConfimation = await prompts({ + type: 'text', + message: "Warning: This will create a new Database backup, overwriting\n" + + "the current database backup. Only ONE Database backup\n" + + "can Be Stored At A Time.\n" + + "For more permanent backups, use MongoExport, or \n" + + "Backup data manually. \n" + + "Would you like to continue? (Yes/No)", + name: "value" + }) + + if (!userConfimation.value || userConfimation.value.toLowerCase() !== "yes") { + + console.log("Exiting...") + process.exit() + return; + } + + await createTempDirectory(); + + console.log("Finished Copying Database, Exiting..."); + process.exit(); +} + +copyDatabase() \ No newline at end of file diff --git a/serverUtils/changeEncryptionPassword.js b/serverUtils/changeEncryptionPassword.js new file mode 100644 index 0000000..2371b76 --- /dev/null +++ b/serverUtils/changeEncryptionPassword.js @@ -0,0 +1,295 @@ +const mongoose = require("../backend/db/mongooseServerUtils"); +const conn = mongoose.connection; +const ObjectID = require('mongodb').ObjectID +const imageChecker = require("../src/utils/imageChecker"); +const createThumbnail = require("./createThumbnailBuffer"); +const prompts = require("prompts"); +const getKey = require("../key/getKey"); +const getNewKey = require("../key/getNewKey"); +const crypto = require("crypto"); +const env = require("../backend/enviroment/env"); +const cliProgress = require('cli-progress'); +const createTempDirectory = require("./createTempDirectory"); + +const waitForDatabase = () => { + + return new Promise((resolve, reject) => { + + if (conn.readyState !== 1) { + + conn.once("open", () => { + + resolve(); + + }) + + } else { + + resolve(); + } + + }) +} + +const reencryptFile = (file, newKey, user) => { + + return new Promise(async(resolve, reject) => { + + const fileID = file._id; + const filename = file.filename; + + let decryptBucket = new mongoose.mongo.GridFSBucket(conn.db, { + chunkSizeBytes: 1024 * 255, + bucketName: "temp-fs" + }); + + let bucket = new mongoose.mongo.GridFSBucket(conn.db, { + chunkSizeBytes: 1024 * 255 + }); + + const metadata = file.metadata; + + const readStream = decryptBucket.openDownloadStream(ObjectID(fileID)); + + const writeStream = bucket.openUploadStream(filename, {metadata}); + + const foundOldUser = await conn.db.collection("temp-users").findOne({_id: user._id}); + + const password = getOldEncryptionKey(foundOldUser); + + const IV = file.metadata.IV.buffer + + const CIPHER_KEY = crypto.createHash('sha256').update(password).digest() + + const decipher = crypto.createDecipheriv('aes256', CIPHER_KEY, IV); + + const NEW_CIPHER_KEY = crypto.createHash('sha256').update(newKey).digest() + + const cipher = crypto.createCipheriv('aes256', NEW_CIPHER_KEY, IV); + + cipher.on("error", (e) => { + console.log("de", e); + }) + + readStream.pipe(decipher).pipe(cipher).pipe(writeStream); + + writeStream.on("finish", async(newFile) => { + + const imageCheck = imageChecker(filename); + + if (file.length < 15728640 && imageCheck) { + + try { + await createThumbnail(newFile, filename, user, newKey); + } catch (e) { + console.log("Cannot create thumbnail", e); + } + + resolve(); + + } else { + + resolve(); + } + + }) + + }) +} + + +const findFiles = async() => { + + const userListCursor = await conn.db.collection("users").find({}); + const userListCount = await conn.db.collection("users").find({}).count(); + const progressBar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic); + + progressBar.start(userListCount, 0); + + for await (const currentUser of userListCursor) { + + const currentUserID = currentUser._id; + + const newEncrpytionKey = getEncryptionKey(currentUser); + + const listCursor = await conn.db.collection("temp-fs.files").find({"metadata.owner": ObjectID(currentUserID)}); + + for await (const currentFile of listCursor) { + + await reencryptFile(currentFile, newEncrpytionKey, currentUser); + } + + progressBar.increment() + } + + progressBar.stop(); +} + +const generateEncryptionKeys = async(user) => { + + const userPassword = user.password; + const masterPassword = env.newKey; + + const randomKey = crypto.randomBytes(32); + + const iv = crypto.randomBytes(16); + const USER_CIPHER_KEY = crypto.createHash('sha256').update(userPassword).digest(); + const cipher = crypto.createCipheriv('aes-256-cbc', USER_CIPHER_KEY, iv); + let encryptedText = cipher.update(randomKey); + encryptedText = Buffer.concat([encryptedText, cipher.final()]); + + const MASTER_CIPHER_KEY = crypto.createHash('sha256').update(masterPassword).digest(); + const masterCipher = crypto.createCipheriv('aes-256-cbc', MASTER_CIPHER_KEY, iv); + let masterEncryptedText = masterCipher.update(encryptedText); + masterEncryptedText = Buffer.concat([masterEncryptedText, masterCipher.final()]).toString("hex"); + + user.privateKey = masterEncryptedText; + user.publicKey = iv.toString("hex"); + + return user; +} + +const getOldEncryptionKey = (user) => { + + const userPassword = user.password; + const masterEncryptedText = user.privateKey; + const masterPassword = env.key; + const iv = Buffer.from(user.publicKey, "hex"); + + const USER_CIPHER_KEY = crypto.createHash('sha256').update(userPassword).digest(); + const MASTER_CIPHER_KEY = crypto.createHash('sha256').update(masterPassword).digest(); + + const unhexMasterText = Buffer.from(masterEncryptedText, "hex"); + const masterDecipher = crypto.createDecipheriv('aes-256-cbc', MASTER_CIPHER_KEY, iv) + let masterDecrypted = masterDecipher.update(unhexMasterText); + masterDecrypted = Buffer.concat([masterDecrypted, masterDecipher.final()]) + + let decipher = crypto.createDecipheriv('aes-256-cbc', USER_CIPHER_KEY, iv); + let decrypted = decipher.update(masterDecrypted); + decrypted = Buffer.concat([decrypted, decipher.final()]); + + return decrypted; +} + +const getEncryptionKey = (user) => { + + const userPassword = user.password; + const masterEncryptedText = user.privateKey; + const masterPassword = env.newKey; + const iv = Buffer.from(user.publicKey, "hex"); + + const USER_CIPHER_KEY = crypto.createHash('sha256').update(userPassword).digest(); + const MASTER_CIPHER_KEY = crypto.createHash('sha256').update(masterPassword).digest(); + + const unhexMasterText = Buffer.from(masterEncryptedText, "hex"); + const masterDecipher = crypto.createDecipheriv('aes-256-cbc', MASTER_CIPHER_KEY, iv) + let masterDecrypted = masterDecipher.update(unhexMasterText); + masterDecrypted = Buffer.concat([masterDecrypted, masterDecipher.final()]) + + let decipher = crypto.createDecipheriv('aes-256-cbc', USER_CIPHER_KEY, iv); + let decrypted = decipher.update(masterDecrypted); + decrypted = Buffer.concat([decrypted, decipher.final()]); + + return decrypted; +} + +const findUsers = async() => { + + const listCursor = await conn.db.collection("temp-users").find({}); + const listCount = await conn.db.collection("temp-users").find({}).count(); + const progressBar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic); + + progressBar.start(listCount, 0); + + for await (const currentUser of listCursor) { + + try { + + const newUser = await generateEncryptionKeys(currentUser); + + await conn.db.collection("users").insertOne(newUser); + + progressBar.increment() + + } catch (e) { + console.log("e", e); + } + } + + progressBar.stop(); + +} + +const changeEncryptionPassword = async() => { + + console.log("Waiting For Database..."); + await waitForDatabase(); + console.log("Connected To Database...\n"); + + const userConfimation = await prompts({ + type: 'text', + message: "Warning: This will automatically run Backup-Database,\n" + + "overwriting the current Backup. And will also clear all file chunks\n" + + "other than the Data Backup. Then it will re-encrypt files and move them back over.\n" + + "(Optional) Create a manual Backup for additional safety.\n" + + "Would you like to continue? (Yes/No)", + name: "value" + }) + + if (!userConfimation.value || userConfimation.value.toLowerCase() !== "yes") { + + console.log("Exiting...") + process.exit() + return; + } + + console.log("\nGetting Old Password..."); + await getKey(); + console.log("Got Key\n") + + console.log("Getting New Password..."); + await getNewKey(); + console.log("Got New Key\n"); + + console.log("Creating Temporary Collection...\n"); + await createTempDirectory(); + console.log("Temporary Collection Completed\n") + + console.log("Created New Backup Sucessfully\n") + + console.log("Deleting Current Chunks Collection..."); + try { + await conn.db.collection("fs.chunks").drop(); + } catch (e) {} + console.log("Current Chunk Collection Deleted\n"); + + console.log("Deleting Current File Collection..."); + try { + await conn.db.collection("fs.files").drop(); + } catch (e) {} + console.log("Deleted Current File Collection\n") + + console.log("Delete Current Users..."); + try { + await conn.db.collection("users").drop(); + } catch (e) {} + console.log("Current Users Deleted\n"); + + console.log("Deleting Current Thumbnails..."); + try { + await conn.db.collection("thumbnails").drop(); + } catch (e) {} + console.log("Deleted Current Thumbnails\n"); + + console.log("Generating User Encryption Keys..."); + await findUsers(); + console.log("Generated User Encryption Keys\n") + + console.log("Moving Files By User..."); + await findFiles(); + console.log("Moved All Files...\n") + + process.exit(); +} + +changeEncryptionPassword(); \ No newline at end of file diff --git a/serverUtils/cleanDatabase.js b/serverUtils/cleanDatabase.js new file mode 100644 index 0000000..f7cc390 --- /dev/null +++ b/serverUtils/cleanDatabase.js @@ -0,0 +1,118 @@ +const mongoose = require("../backend/db/mongooseServerUtils"); +const conn = mongoose.connection; +const ObjectID = require('mongodb').ObjectID +const prompts = require("prompts"); +const cliProgress = require('cli-progress'); +const createTempDirectory = require("./createTempDirectory"); + +const waitForDatabase = () => { + + return new Promise((resolve, reject) => { + + if (conn.readyState !== 1) { + + conn.once("open", () => { + + resolve(); + + }) + + } else { + + resolve(); + } + + }) +} + +const moveFileChunks = async(fileID, oldDatabaseChunks, newDatabaseChunks) => { + + const listChunkCursor = await conn.db.collection(oldDatabaseChunks).find({files_id: ObjectID(fileID)}); + + for await (const currentChunk of listChunkCursor) { + + await conn.db.collection(newDatabaseChunks).insertOne(currentChunk); + } +} + +const findFiles = async(oldDatabaseList, oldDatabaseChunks, newDatabaseChunks) => { + + const listCursor = await conn.db.collection(oldDatabaseList).find({}); + const listCount = await conn.db.collection(oldDatabaseList).find({}).count(); + const progressBar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic); + + progressBar.start(listCount, 0); + + for await (const currentFile of listCursor) { + + const fileID = currentFile._id; + await moveFileChunks(fileID, oldDatabaseChunks, newDatabaseChunks); + progressBar.increment(); + } + + progressBar.stop(); +} + +const cleanDatabase = async() => { + + console.log("Waiting For Database Connection..."); + await waitForDatabase(); + console.log("Connected To Database\n"); + + const userConfimation = await prompts({ + type: 'text', + message: "Warning: This will automatically run Backup-Database,\n" + + "overwriting the current Backup. And will also clear all file chunks\n" + + "other than the Data Backup. Then it will move only used file chunks\n" + + "over to the Main Database. If this process fails AFTER the Automatic Backup\n" + + "use the Restore-Database feature. \n" + + "Would you like to continue? (Yes/No)", + name: "value" + }) + + if (!userConfimation.value || userConfimation.value.toLowerCase() !== "yes") { + + console.log("Exiting...") + process.exit() + return; + } + + console.log("Creating Temporary Collection...\n"); + await createTempDirectory(); + console.log("Temporary Collection Completed\n") + + console.log("Created New Backup Sucessfully\n") + + console.log("Deleting Current Chunks Collection..."); + try { + await conn.db.collection("fs.chunks").drop(); + } catch (e) {} + console.log("Current Chunk Collection Deleted\n"); + + console.log("Moving Used Files..."); + await findFiles("temp-fs.files", "temp-fs.chunks", "fs.chunks"); + console.log("Moved All Used Files\n"); + + console.log("Creating File Chunks Index..."); + await conn.db.collection("fs.chunks").createIndex({ files_id: 1, n: 1 }, { unique: true }); + console.log("Created File Chunks Index\n"); + + console.log("Deleteing Current Transcoded Video Chunks Collection..."); + try { + await conn.db.collection("videos.chunks").drop(); + } catch (e) {} + console.log("Deleted Current Transcoded Video Chunks Collection\n") + + console.log("Moving Used Video Files..."); + await findFiles("temp-videos.files", "temp-videos.chunks", "videos.chunks"); + console.log("Moved All Used Video Files\n") + + console.log("Creating Transcoded Video Chunks Index..."); + await conn.db.collection("videos.chunks").createIndex({ files_id: 1, n: 1 }, { unique: true }); + console.log("Created Transcoded Video Chunks Index") + + process.exit(); + +} + +cleanDatabase(); \ No newline at end of file diff --git a/serverUtils/createIndexes.js b/serverUtils/createIndexes.js new file mode 100644 index 0000000..641ae54 --- /dev/null +++ b/serverUtils/createIndexes.js @@ -0,0 +1,52 @@ +const getEnvVariables = require("../dist/enviroment/getEnvVariables"); +getEnvVariables(); +const mongoose = require("./mongoServerUtil"); +const conn = mongoose.connection; + +const waitForDatabase = () => { + + return new Promise((resolve, reject) => { + + if (conn.readyState !== 1) { + + conn.once("open", () => { + + resolve(); + + }) + + } else { + + resolve(); + } + + }) +} + +const createIndexes = async() => { + + console.log("Waiting For Database..."); + await waitForDatabase(); + console.log("Connected To Database\n"); + + console.log("Creating Indexes...") + await conn.db.collection("fs.files").createIndex({uploadDate: 1}); + await conn.db.collection("fs.files").createIndex({uploadDate: -1}); + await conn.db.collection("fs.files").createIndex({filename: 1}); + await conn.db.collection("fs.files").createIndex({filename: -1}); + await conn.db.collection("fs.files").createIndex({"metadata.owner": 1}); + + await conn.db.collection("folders").createIndex({createdAt: 1}) + await conn.db.collection("folders").createIndex({createdAt: -1}) + await conn.db.collection("folders").createIndex({name: 1}); + await conn.db.collection("folders").createIndex({name: -1}) + await conn.db.collection("folders").createIndex({owner: 1}) + + await conn.db.collection("thumbnails").createIndex({owner: 1}) + console.log("Indexes Created"); + + process.exit(); + +} + +createIndexes(); \ No newline at end of file diff --git a/serverUtils/createTempDirectory.js b/serverUtils/createTempDirectory.js new file mode 100644 index 0000000..7faaaa5 --- /dev/null +++ b/serverUtils/createTempDirectory.js @@ -0,0 +1,103 @@ +const mongoose = require("../backend/db/mongooseServerUtils"); +const conn = mongoose.connection; +const cliProgress = require('cli-progress'); + +const clearTempDirectory = async() => { + + console.log("Removing Temporary Collections..."); + + try { + await conn.db.collection("temp-fs.files").drop(); + } catch (e) {} + + try { + await conn.db.collection("temp-fs.chunks").drop(); + } catch (e) {} + + try { + await conn.db.collection("temp-thumbnails").drop(); + } catch (e) {} + + try { + await conn.db.collection("temp-folders").drop(); + } catch (e) {} + + try { + await conn.db.collection("temp-videos.files").drop(); + } catch (e) {} + + try { + await conn.db.collection("temp-videos.chunks").drop(); + } catch (e) {} + + try { + await conn.db.collection("temp-users").drop(); + } catch (e) {} + + console.log("Removed Temporary Collections\n") +} + +const moveItem = async(oldPath, newPath) => { + + const listCursor = await conn.db.collection(oldPath).find({}); + const listCount = await conn.db.collection(oldPath).find({}).count(); + const progressBar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic); + + progressBar.start(listCount, 0); + + for await (const currentFile of listCursor) { + + await conn.db.collection(newPath).insertOne(currentFile); + progressBar.increment(); + } + + progressBar.stop(); +} + +const createTempDirectory = async() => { + + await clearTempDirectory(); + + console.log("Moving Files...") + await moveItem("fs.files", "temp-fs.files") + console.log("Moved All Files\n"); + + + console.log(`Moving File Chunks...`); + await moveItem("fs.chunks", "temp-fs.chunks"); + console.log("Moved All Chunks \n"); + + console.log("Creating Temp File Chunks Index..."); + await conn.db.collection("temp-fs.chunks").createIndex({ files_id: 1, n: 1 }, { unique: true }); + console.log("Created Temp File Chunks Index\n"); + + console.log(`Moving Thumbnails...`) + await moveItem("thumbnails", "temp-thumbnails"); + console.log("Moved All Thumbnails \n") + + + console.log(`Moving Folders...`); + await moveItem("folders", "temp-folders"); + console.log("All Folders Moved \n"); + + + console.log(`Moving Transcoded Video Files...`); + await moveItem("videos.files", "temp-videos.files") + console.log("All Transcoded Video Files Moved \n"); + + + console.log(`Moving Transcoded Video Chunks...`) + await moveItem("videos.chunks", "temp-videos.chunks") + console.log("All Transcoded Video Chunks Moved \n"); + + console.log("Creating Temp Transcoded Video Chunks Index..."); + await conn.db.collection("videos.chunks").createIndex({ files_id: 1, n: 1 }, { unique: true }) + console.log("Created Temp Transcoded Video Chunks Index \n"); + + + console.log(`Moving Users...`) + await moveItem("users", "temp-users") + console.log("All Users Moved\n"); +} + +module.exports = createTempDirectory; diff --git a/serverUtils/createThumbnailBuffer.js b/serverUtils/createThumbnailBuffer.js new file mode 100644 index 0000000..23e7b7c --- /dev/null +++ b/serverUtils/createThumbnailBuffer.js @@ -0,0 +1,82 @@ +import mongoose from "../backend/db/mongoose"; +const conn = mongoose.connection; +const crypto= require("crypto"); +import env from "../backend/enviroment/env"; +const Thumbnail = require("../backend/models/thumbnail"); +const ObjectID = require('mongodb').ObjectID +const sharp = require("sharp"); +const concat = require("concat-stream") + +const createThumbnail = async(file, filename, user, newKey) => { + + return new Promise((resolve) => { + + try { + + const password = newKey; + + let CIPHER_KEY = crypto.createHash('sha256').update(password).digest() + + let bucket = new mongoose.mongo.GridFSBucket(conn.db, { + chunkSizeBytes: 1024 * 255, + }) + + const readStream = bucket.openDownloadStream(ObjectID(file._id)) + + readStream.on("error", (e) => { + console.log("File service upload thumbnail error", e); + resolve(file); + }) + + const decipher = crypto.createDecipheriv('aes256', CIPHER_KEY, file.metadata.IV.buffer); + + decipher.on("error", (e) => { + console.log("File service upload thumbnail decipher error", e); + resolve(file) + }) + + const concatStream = concat(async(bufferData) => { + + const thumbnailIV = crypto.randomBytes(16); + + const thumbnailCipher = crypto.createCipheriv("aes256", CIPHER_KEY, thumbnailIV); + + bufferData = Buffer.concat([thumbnailIV, thumbnailCipher.update(bufferData), thumbnailCipher.final()]); + + const thumbnailModel = new Thumbnail({name: filename, owner: user._id, data: bufferData}); + + await thumbnailModel.save(); + + let updatedFile = await conn.db.collection("fs.files") + .findOneAndUpdate({"_id": file._id}, {"$set": {"metadata.hasThumbnail": true, "metadata.thumbnailID": thumbnailModel._id}}) + + updatedFile = updatedFile.value; + + updatedFile = {...updatedFile, metadata: {...updatedFile.metadata, hasThumbnail: true, thumbnailID: thumbnailModel._id}} + + resolve(updatedFile); + + }).on("error", (e) => { + console.log("File service upload concat stream error", e); + resolve(file); + }) + + const imageResize = sharp().resize(300).on("error", (e) => { + + console.log("resize error", e); + resolve(file); + }) + + readStream.pipe(decipher).pipe(imageResize).pipe(concatStream); + + + } catch (e) { + + console.log(e); + resolve(file); + } + + }) +} + +module.exports = createThumbnail; \ No newline at end of file diff --git a/serverUtils/createVideoThumbnails.js b/serverUtils/createVideoThumbnails.js new file mode 100644 index 0000000..56d02df --- /dev/null +++ b/serverUtils/createVideoThumbnails.js @@ -0,0 +1,79 @@ +const getEnvVariables = require("../dist-backend/enviroment/get-env-variables"); +getEnvVariables(); +const mongoose = require("./mongoServerUtil"); +const conn = mongoose.connection; +const File = require("../dist-backend/models/file-model"); +const User = require("../dist-backend/models/user-model"); +const createVideoThumbnail = + require("../dist-backend/services/chunk-service/utils/createVideoThumbnail").default; +const getKey = require("../dist-backend/key/get-key").default; + +const waitForDatabase = () => { + return new Promise((resolve, reject) => { + // Wait for the database to be ready. + const timeoutWait = () => { + setTimeout(() => resolve(), 3000); + }; + + if (conn.readyState !== 1) { + conn.once("open", () => { + timeoutWait(); + }); + } else { + timeoutWait(); + } + }); +}; + +// Wait to be after anything else may be printed to the terminal +const terminalWait = () => { + return new Promise((resolve) => { + setTimeout(() => resolve(), 2000); + }); +}; + +const updateDocs = async () => { + await terminalWait(); + console.log(`Updating video thumbnails, env is ${process.env.NODE_ENV}`); + + console.log("\nWaiting for database..."); + await waitForDatabase(); + console.log("Connected to database\n"); + + console.log("Getting Key..."); + await getKey(); + console.log("Key Got\n"); + + // console.log("env", process.env.KEY); + + console.log("Getting file list..."); + const files = await File.find({ + filename: { + $regex: + /\.(mp4|mov|avi|mkv|webm|wmv|flv|mpg|mpeg|3gp|3g2|mxf|ogv|ogg|m4v)$/i, + }, + "metadata.thumbnailID": "", + }); + + console.log("Found", files.length, "files"); + + for (let i = 0; i < files.length; i++) { + try { + const currentFile = files[i]; + + console.log(`Progress ${i + 1}/${files.length}`); + + const user = await User.findById(currentFile.metadata.owner); + + await createVideoThumbnail(currentFile, currentFile.filename, user); + } catch (e) { + console.log("error creating video thumbnail", e); + } + } + + console.log("Done"); + + process.exit(); +}; + +updateDocs(); diff --git a/serverUtils/deleteDatabase.js b/serverUtils/deleteDatabase.js new file mode 100644 index 0000000..97c1c48 --- /dev/null +++ b/serverUtils/deleteDatabase.js @@ -0,0 +1,81 @@ +const mongoose = require("../backend/db/mongooseServerUtils"); +const prompts = require("prompts"); +const conn = mongoose.connection; + +const waitForDatabase = () => { + + return new Promise((resolve, reject) => { + + if (conn.readyState !== 1) { + + conn.once("open", () => { + + resolve(); + + }) + + } else { + + resolve(); + } + + }) +} + +const deleteTempDatabase = async() => { + + console.log("Waiting For Database..."); + await waitForDatabase(); + console.log("Connected To Database\n") + + const userConfimation = await prompts({ + type: 'text', + message: "Warning: This will delete all the data in the Main Database,\n" + + "this will not delete any data in the Database Backup.\n" + + "Would you like to continue? (Yes/No)", + name: "value" + }) + + if (!userConfimation.value || userConfimation.value.toLowerCase() !== "yes") { + + console.log("Exiting...") + process.exit() + return; + } + + console.log("Removing Collections..."); + + try { + await conn.db.collection("fs.files").drop(); + } catch (e) {} + + try { + await conn.db.collection("fs.chunks").drop(); + } catch (e) {} + + try { + await conn.db.collection("thumbnails").drop(); + } catch (e) {} + + try { + await conn.db.collection("folders").drop(); + } catch (e) {} + + try { + await conn.db.collection("videos.files").drop(); + } catch (e) {} + + try { + await conn.db.collection("videos.chunks").drop(); + } catch (e) {} + + try { + await conn.db.collection("users").drop(); + } catch (e) {} + + console.log("Removed Collections\n") + + process.exit(); +} + +deleteTempDatabase(); \ No newline at end of file diff --git a/serverUtils/deleteTempDatabase.js b/serverUtils/deleteTempDatabase.js new file mode 100644 index 0000000..66eb81c --- /dev/null +++ b/serverUtils/deleteTempDatabase.js @@ -0,0 +1,79 @@ +const mongoose = require("../backend/db/mongooseServerUtils"); +const prompts = require("prompts"); +const conn = mongoose.connection; + +const waitForDatabase = () => { + + return new Promise((resolve, reject) => { + + if (conn.readyState !== 1) { + + conn.once("open", () => { + + resolve(); + + }) + + } else { + + resolve(); + } + + }) +} + +const deleteTempDatabase = async() => { + + console.log("Waiting For Database..."); + await waitForDatabase(); + console.log("Connected To Database\n") + + const userConfimation = await prompts({ + type: 'text', + message: "Warning: Deleting the Backup Database cannot be undone,\n" + + "Would you like to continue? (Yes/No)", + name: "value" + }) + + if (!userConfimation.value || userConfimation.value.toLowerCase() !== "yes") { + + console.log("Exiting...") + process.exit() + return; + } + + console.log("Removing Temporary Collections..."); + + try { + await conn.db.collection("temp-fs.files").drop(); + } catch (e) {} + + try { + await conn.db.collection("temp-fs.chunks").drop(); + } catch (e) {} + + try { + await conn.db.collection("temp-thumbnails").drop(); + } catch (e) {} + + try { + await conn.db.collection("temp-folders").drop(); + } catch (e) {} + + try { + await conn.db.collection("temp-videos.files").drop(); + } catch (e) {} + + try { + await conn.db.collection("temp-videos.chunks").drop(); + } catch (e) {} + + try { + await conn.db.collection("temp-users").drop(); + } catch (e) {} + + console.log("Removed Temporary Collections, Exiting..."); + process.exit(); +} + +deleteTempDatabase(); \ No newline at end of file diff --git a/serverUtils/getEnvVaribables.js b/serverUtils/getEnvVaribables.js new file mode 100644 index 0000000..ff38277 --- /dev/null +++ b/serverUtils/getEnvVaribables.js @@ -0,0 +1,10 @@ +const path = require("path"); + +const getEnvVariables = () => { + + const configPath = path.join(__dirname, "..", "config"); + + require('dotenv').config({ path: configPath + "/prod.env"}) +} + +module.exports = getEnvVariables; \ No newline at end of file diff --git a/serverUtils/migrateMyDrive4.js b/serverUtils/migrateMyDrive4.js new file mode 100644 index 0000000..f0186e7 --- /dev/null +++ b/serverUtils/migrateMyDrive4.js @@ -0,0 +1,47 @@ +const getEnvVariables = require("../dist-backend/enviroment/get-env-variables"); +getEnvVariables(); +const mongoose = require("./mongoServerUtil"); +const conn = mongoose.connection; +const File = require("../dist-backend/models/file-model"); + +const waitForDatabase = () => { + return new Promise((resolve, reject) => { + if (conn.readyState !== 1) { + conn.once("open", () => { + resolve(); + }); + } else { + resolve(); + } + }); +}; + +const updateDocs = async () => { + console.log("\nWaiting for database..."); + await waitForDatabase(); + console.log("Connected to database\n"); + + console.log("Getting file list..."); + const files = await File.find({}); + console.log("Found", files.length, "files"); + + for (let i = 0; i < files.length; i++) { + const currentFile = files[i]; + + await File.updateOne( + { _id: currentFile._id }, + { + $set: { + "metadata.owner": currentFile.metadata.owner.toString(), + "metadata.thumbnailID": currentFile.metadata.thumbnailID.toString(), + }, + } + ); + } + + console.log("Done"); + + process.exit(); +}; + +updateDocs(); diff --git a/serverUtils/mongoServerUtil.js b/serverUtils/mongoServerUtil.js new file mode 100644 index 0000000..49c0f1e --- /dev/null +++ b/serverUtils/mongoServerUtil.js @@ -0,0 +1,5 @@ +const mongoose = require("mongoose"); + +mongoose.connect(process.env.MONGODB_URL, {}); + +module.exports = mongoose; diff --git a/serverUtils/removeOldPersonalData.js b/serverUtils/removeOldPersonalData.js new file mode 100644 index 0000000..b030e9a --- /dev/null +++ b/serverUtils/removeOldPersonalData.js @@ -0,0 +1,79 @@ +const getEnvVariables = require("../dist/enviroment/getEnvVariables"); +getEnvVariables() +const mongoose = require("./mongoServerUtil"); +const conn = mongoose.connection; +const Thumbnail = require("../dist/models/thumbnail"); +const File = require("../dist/models/file"); +const User = require('../dist/models/user'); + +const DAY_LIMIT = 0; + +const waitForDatabase = () => { + + return new Promise((resolve, reject) => { + + if (conn.readyState !== 1) { + + conn.once("open", () => { + + resolve(); + + }) + + } else { + + resolve(); + } + + }) +} + +const removePersonalMetadata = async(user) => { + + const fileList = await conn.db.collection("fs.files").find({ + "metadata.owner": user._id, + "metadata.personalFile": true, + }).toArray(); + + for (let currentFile of fileList) { + + await File.deleteOne({_id: currentFile._id}); + + if (currentFile.metadata.hasThumbnail) { + + await Thumbnail.deleteOne({_id: currentFile.metadata.thumbnailID}) + } + } + + await conn.db.collection("folders").deleteMany({'owner': user._id.toString(), 'personalFolder': true}) +} + +const removeOldPeronsalData = async() => { + + console.log("Waiting for mongoDB Database..."); + await waitForDatabase(); + console.log("MongoDB Connection established\n"); + + const userList = await User.find({'personalStorageCanceledDate': {$exists: true}}); + + console.log('user list', userList.length); + + for (const currentUser of userList) { + + let date = new Date(currentUser.personalStorageCanceledDate); + date.setDate(date.getDate() + DAY_LIMIT); + + const nowDate = new Date(); + + if (date.getTime() <= nowDate) { + console.log(`\nUser ${currentUser.email} over expire limit for personal data, deleting metadata...`); + await removePersonalMetadata(currentUser); + console.log(`Removed user ${currentUser.email} personal metadata successfully`); + } + } + + console.log('\nFinished removing expired personal metadata') + process.exit(); +} + +removeOldPeronsalData(); \ No newline at end of file diff --git a/serverUtils/removeOldSubscriptionData.js b/serverUtils/removeOldSubscriptionData.js new file mode 100644 index 0000000..e091c20 --- /dev/null +++ b/serverUtils/removeOldSubscriptionData.js @@ -0,0 +1,204 @@ +const getEnvVariables = require("../dist/enviroment/getEnvVariables"); +getEnvVariables() +const mongoose = require("./mongoServerUtil"); +const conn = mongoose.connection; +const env = require("../dist/enviroment/env") +const DbUtilsFile = require("../dist/db/utils/fileUtils/index") +const dbUtilsFile = new DbUtilsFile(); +const Thumbnail = require("../dist/models/thumbnail"); +const removeChunksFS = require("../dist/services/ChunkService/utils/removeChunksFS"); +const File = require("../dist/models/file"); +const mongod = require("mongodb"); +const ObjectID = mongod.ObjectID; +const User = require('../dist/models/user'); +const s3 = require("../dist/db/s3"); +const Stripe = require("stripe") +const removeChunksS3 = require("../dist/services/ChunkService/utils/removeChunksS3"); +const getKey = require("../key/getKey"); + +const stripKey = env.stripeKey; + +const DAY_LIMIT = 30; + +const waitForDatabase = () => { + + return new Promise((resolve, reject) => { + + if (conn.readyState !== 1) { + + conn.once("open", () => { + + resolve(); + + }) + + } else { + + resolve(); + } + + }) +} + +const mongoRemoveChunks = async(fileList) => { + + for (const file of fileList) { + + const fileID = file._id; + + let bucket = new mongoose.mongo.GridFSBucket(conn.db, { + chunkSizeBytes: 1024 * 255, + }); + + if (file.metadata.thumbnailID) { + + await Thumbnail.deleteOne({_id: file.metadata.thumbnailID}); + } + + await bucket.delete(new ObjectID(fileID)); + + } +} + +const fsRemoveChunks = async(fileList) => { + + for (const file of fileList) { + + if (file.metadata.thumbnailID) { + + const thumbnail = await Thumbnail.findById(file.metadata.thumbnailID) + const thumbnailPath = thumbnail.path; + await removeChunksFS(thumbnailPath); + + await Thumbnail.deleteOne({_id: file.metadata.thumbnailID}); + } + + await removeChunksFS(file.metadata.filePath); + await File.deleteOne({_id: file._id}); + } +} + +const s3RemoveChunks = async(fileList) => { + + for (const file of fileList) { + + const s3Storage = s3; + const bucket = env.s3Bucket; + + if (file.metadata.thumbnailID) { + + const thumbnail = await Thumbnail.findById(file.metadata.thumbnailID); + const paramsThumbnail = {Bucket: bucket, Key: thumbnail.s3ID}; + await removeChunksS3(s3Storage, paramsThumbnail); + await Thumbnail.deleteOne({_id: file.metadata.thumbnailID}); + } + + const params = {Bucket: bucket, Key: file.metadata.s3ID}; + await removeChunksS3(s3Storage, params); + await File.deleteOne({_id: file._id}); + } + +} + +const removeChunkData = async(user) => { + + const fileList = await conn.db.collection("fs.files").find({ + "metadata.owner": user._id, + "metadata.personalFile": null, + }).toArray(); + + // console.log("file list", fileList.length); + + if (env.dbType === "mongo") { + + await mongoRemoveChunks(fileList); + + } else if (env.dbType === "fs") { + + await fsRemoveChunks(fileList); + + } else { + + await s3RemoveChunks(fileList); + } +} + +const removeFolders = async(user) => { + + // console.log("removing folders", user._id) + + await conn.db.collection("folders").deleteMany({ + owner: user._id.toString(), + personalFolder: null + }) +} + +const removeOldSubscriptionData = async() => { + + console.log("Getting Encryption Password"); + await getKey(); + console.log("Got encryption key\n") + + console.log("Waiting for mongoDB Database..."); + await waitForDatabase(); + console.log("MongoDB Connection established\n") + + console.log("Starting expire data check...") + + const allUsers = await User.find({}); + + console.log("All users length", allUsers.length); + + for (const currentUser of allUsers) { + + if (currentUser.stripeCanceledDate) { + + let date = new Date(currentUser.stripeCanceledDate); + date.setDate(date.getDate() + DAY_LIMIT); + + const nowDate = new Date(); + + if (date.getTime() <= nowDate) { + + console.log(`\nUser ${currentUser.email} over expire limit, deleting data...`); + await removeChunkData(currentUser) + await removeFolders(currentUser) + console.log(`Removed user ${currentUser.email} data successfully`); + } + + } else if (currentUser.stripeEnabled) { + + const stripe = new Stripe(stripKey, { + apiVersion: '2020-03-02', + }); + + const {subID}= await currentUser.decryptStripeData(); + + const subscriptionDetails = await stripe.subscriptions.retrieve(subID); + + if (subscriptionDetails.status !== "active" && subscriptionDetails.status !== "trailing") { + + const endedAt = (subscriptionDetails.ended_at * 1000); + + let date = new Date(endedAt); + date.setDate(date.getDate() + DAY_LIMIT); + const nowDate = new Date(); + + if (date.getTime() <= nowDate) { + + console.log(`\nUser ${currentUser.email} over expire limit, deleting data...`); + await removeChunkData(currentUser) + await removeFolders(currentUser) + console.log(`Removed user ${currentUser.email} data successfully`); + } + } + + + } + } + + console.log("\nFinished removing all expired data") + process.exit() +} + +removeOldSubscriptionData() \ No newline at end of file diff --git a/serverUtils/removeTokens.js b/serverUtils/removeTokens.js new file mode 100644 index 0000000..a95e9d5 --- /dev/null +++ b/serverUtils/removeTokens.js @@ -0,0 +1,44 @@ +const getEnvVariables = require("../dist/enviroment/getEnvVariables"); +getEnvVariables(); +const mongoose = require("./mongoServerUtil"); +const conn = mongoose.connection; +const User = require("../dist/models/user"); + +const waitForDatabase = () => { + + return new Promise((resolve, reject) => { + + if (conn.readyState !== 1) { + + conn.once("open", () => { + + resolve(); + + }) + + } else { + + resolve(); + } + + }) +} + +const removeTokens = async() => { + + console.log("\nWaiting for database..."); + await waitForDatabase(); + console.log("Connected to database\n"); + + console.log("Removing tokens from users..."); + const userList = await User.find({}); + await User.updateMany({}, { + tokens: [], + tempTokens: [] + }) + console.log("Removed tokens from", userList.length, "users"); + + process.exit(); +} + +removeTokens(); \ No newline at end of file diff --git a/serverUtils/restoreDatabase.js b/serverUtils/restoreDatabase.js new file mode 100644 index 0000000..aebabee --- /dev/null +++ b/serverUtils/restoreDatabase.js @@ -0,0 +1,55 @@ +const mongoose = require("../backend/db/mongooseServerUtils"); +const prompts = require("prompts"); +const restoreFromTempDirectory = require("./restoreFromTempDirectory"); +const conn = mongoose.connection; + +const waitForDatabase = () => { + + return new Promise((resolve, reject) => { + + if (conn.readyState !== 1) { + + conn.once("open", () => { + + resolve(); + + }) + + } else { + + resolve(); + } + + }) +} + +const restoreDatabase = async() => { + + const userConfimation = await prompts({ + type: 'text', + message: "Warning: This will delete ALL data," + + " other than the Data Backup created by CopyDatabase. \nMake sure to first run CopyDatabase, and backup" + + " your data, \nWould you like to continue? (Yes/No)", + name: "value" + }) + + if (!userConfimation.value || userConfimation.value.toLowerCase() !== "yes") { + + console.log("Exiting...") + process.exit() + return; + + } else { + + await waitForDatabase(); + + await restoreFromTempDirectory(); + + console.log("Finished Restoring Data, Exiting..."); + process.exit(); + } + +} + +restoreDatabase() + diff --git a/serverUtils/restoreFromTempDirectory.js b/serverUtils/restoreFromTempDirectory.js new file mode 100644 index 0000000..810bf20 --- /dev/null +++ b/serverUtils/restoreFromTempDirectory.js @@ -0,0 +1,105 @@ +const mongoose = require("../backend/db/mongooseServerUtils"); +const cliProgress = require('cli-progress'); +const conn = mongoose.connection; + +const clearDirectory = async() => { + + console.log("Removing Collections..."); + + try { + await conn.db.collection("fs.files").drop(); + } catch (e) {} + + try { + await conn.db.collection("fs.chunks").drop(); + } catch (e) {} + + try { + await conn.db.collection("thumbnails").drop(); + } catch (e) {} + + try { + await conn.db.collection("folders").drop(); + } catch (e) {} + + try { + await conn.db.collection("videos.files").drop(); + } catch (e) {} + + try { + await conn.db.collection("videos.chunks").drop(); + } catch (e) {} + + try { + await conn.db.collection("users").drop(); + } catch (e) {} + + console.log("Removed Collections\n") +} + +const moveItem = async(oldPath, newPath) => { + + const listCursor = await conn.db.collection(oldPath).find({}); + const listCount = await conn.db.collection(oldPath).find({}).count(); + const progressBar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic); + + progressBar.start(listCount, 0); + + for await (const currentFile of listCursor) { + + await conn.db.collection(newPath).insertOne(currentFile); + progressBar.increment(); + } + + progressBar.stop(); +} + + +const restoreFromTempDirectory = async() => { + + console.log("\n"); + await clearDirectory(); + + console.log("Moving Files...") + await moveItem("temp-fs.files", "fs.files") + console.log("Moved All Files\n"); + + + console.log(`Moving File Chunks...`); + await moveItem("temp-fs.chunks","fs.chunks"); + console.log("Moved All Chunks \n"); + + console.log("Creating File Index..."); + await conn.db.collection("fs.chunks").createIndex({ files_id: 1, n: 1 }, { unique: true }) + console.log("File Index Created \n"); + + console.log(`Moving Thumbnails...`) + await moveItem("temp-thumbnails", "thumbnails"); + console.log("Moved All Thumbnails \n") + + + console.log(`Moving Folders...`); + await moveItem("temp-folders", "folders"); + console.log("All Folders Moved \n"); + + + console.log(`Moving Transcoded Video Files...`); + await moveItem("temp-videos.files", "videos.files") + console.log("All Transcoded Video Files Moved \n"); + + + console.log(`Moving Transcoded Video Chunks...`) + await moveItem("temp-videos.chunks", "videos.chunks") + console.log("All Transcoded Video Chunks Moved \n"); + + console.log("Creating Transcoded Video Chunks Index..."); + await conn.db.collection("videos.chunks").createIndex({ files_id: 1, n: 1 }, { unique: true }) + console.log("Created Transcoded Video Chunks Index \n"); + + + console.log(`Moving Users...`) + await moveItem("temp-users", "users") + console.log("All Users Moved\n"); +} + +module.exports = restoreFromTempDirectory; \ No newline at end of file diff --git a/serverUtils/setupServer.js b/serverUtils/setupServer.js new file mode 100644 index 0000000..17ef0ae --- /dev/null +++ b/serverUtils/setupServer.js @@ -0,0 +1,365 @@ +const prompts = require("prompts"); +const fs = require("fs"); +const crypto = require("crypto"); + +const awaitcreateDir = (path) => { + + return new Promise((resolve, reject) => { + + fs.mkdir(path, () => { + resolve(); + }) + }) +} + +const awaitWriteFile = (path, data) => { + + return new Promise((resolve, reject) => { + + fs.writeFile(path, data, async(err) => { + + if (err) { + console.log("file write error", err); + reject(); + } + + resolve(); + }) + }) +} + +const initServer = async() => { + + console.log("Setting Up Server...\n"); + + await awaitcreateDir("./config"); + + const getDocker = await prompts({ + type: 'toggle', + name: 'value', + message: 'Use Docker With myDrive?', + initial: true, + active: 'yes', + inactive: 'no' + }) + + const docker = getDocker.value; + + + if (docker) { + + let stringBuilder = ''; + + const getUsingMongo = await prompts({ + type: 'toggle', + name: 'value', + message: "Include MongoDB In The Docker Image? (Select No If You're Using MongoDB Atlas)", + initial: true, + active: 'yes', + inactive: 'no' + }) + + const mongo = getUsingMongo.value; + + let mongoURL = "mongodb://mongo:27017/personal-drive"; + + if (!mongo) { + + const getMongoURL = await prompts({ + type: 'text', + message: "Enter The MongoDB URL", + name: "value" + }) + + mongoURL = getMongoURL.value; + } + + stringBuilder += "MONGODB_URL=" + mongoURL + "\n" + + const getKeyType = await prompts({ + type: 'toggle', + name: 'value', + message: "Use WebUI For Encryption Key (Recommended, Selecting No Will Require You To Enter An Encryption Key Now, Which Is Less Secure)", + initial: true, + active: 'yes', + inactive: 'no' + }) + + let keyType = getKeyType.value; + + if (!keyType) { + + const getKey = await prompts({ + type: 'password', + message: "Enter The Encryption Key", + name: "value" + }) + + const key = getKey.value; + + stringBuilder += "KEY=" + key + "\n"; + } + + const getClientURL = await prompts({ + type: 'text', + message: "Enter The Client URL/IP Address (Enter The Client URL/IP Address (Must Be A Valid Link, Include Port With IP Address If Needed)", + name: "value" + }) + + const clientURL = getClientURL.value; + + stringBuilder += "REMOTE_URL=" + clientURL + "\n"; + + const getChunkDB = await prompts({ + type: 'select', + name: 'value', + message: 'Pick A Database To Store File Chunks', + choices: [ + { title: 'Amazon S3', value: 's3'}, + { title: 'FileSystem', value: 'fs'}, + { title: 'MongoDB', value: 'mongo' }, + ], + initial: 1 + }) + + const chunkDB = getChunkDB.value; + + stringBuilder += "DB_TYPE=" + chunkDB + "\n"; + + if (chunkDB === "s3") { + + const gets3ID = await prompts({ + type: 'text', + message: "Enter The S3 ID", + name: "value" + }) + + const s3ID = gets3ID.value; + + stringBuilder += "S3_ID=" + s3ID + "\n"; + + const gets3Key = await prompts({ + type: 'password', + message: "Enter The S3 Key", + name: "value" + }) + + const s3Key = gets3Key.value; + + stringBuilder += "S3_KEY=" + s3Key + "\n"; + + const gets3Bucket = await prompts({ + type: 'text', + message: "Enter The S3 Bucket", + name: "value" + }) + + const s3Bucket = gets3Bucket.value; + + stringBuilder += "S3_BUCKET=" + s3Bucket + "\n"; + + } else if (chunkDB === "fs") { + + const getFSPath = await prompts({ + type: 'text', + message: "Enter The FileSystem Path", + name: "value" + }) + + const fsPath = getFSPath.value; + + stringBuilder += "FS_DIRECTORY=" + fsPath + "\n"; + } + + const getJWTSecret = await prompts({ + type: 'password', + message: "Enter JWT Secret", + name: "value", + }) + + const JWTsecret = getJWTSecret.value; + + stringBuilder += "PASSWORD=" + JWTsecret + "\n"; + + const getUseSSL = await prompts({ + type: 'toggle', + name: 'value', + message: "Use SSL? (Will Require SSL Certificate certificate.crt, certificate.ca-bundle, And certificate.key At Root Of The Project)", + initial: true, + active: 'yes', + inactive: 'no' + }) + + const useSSL = getUseSSL.value; + + if (useSSL) { + + stringBuilder += "SSL=true\n"; + } + + stringBuilder += "DISABLE_STORAGE=true\n"; + stringBuilder += "DOCKER=true\n"; + stringBuilder += "NODE_ENV=production\n"; + stringBuilder += "PORT=3000\n"; + stringBuilder += "HTTP_PORT=3000\n"; + stringBuilder += "HTTPS_PORT=8080\n" + + await awaitWriteFile("./docker-variables.env", stringBuilder); + + console.log("\nCreated Docker Env File"); + + } else { + + let stringBuilderClient = ''; + let stringBuilderServer = ''; + + const getMongoURL = await prompts({ + type: 'text', + message: "Enter The MongoDB URL", + name: "value" + }) + + const mongoURL = getMongoURL.value; + + stringBuilderServer += "MONGODB_URL=" + mongoURL + "\n"; + + const getKeyType = await prompts({ + type: 'toggle', + name: 'value', + message: "Use WebUI For Encryption Key (Recommended, Selecting No Will Require You To Enter An Encryption Key Now, Which Is Less Secure)", + initial: true, + active: 'yes', + inactive: 'no' + }) + + let keyType = getKeyType.value; + + if (!keyType) { + + const getKey = await prompts({ + type: 'password', + message: "Enter The Encryption Key", + name: "value" + }) + + const key = getKey.value; + + stringBuilderServer += "KEY=" + key + "\n"; + } + + const getClientURL = await prompts({ + type: 'text', + message: "Enter The Client URL/IP Address (Must Be A Valid Link, Include Port With IP Address If Needed)", + name: "value" + }) + + const clientURL = getClientURL.value; + + stringBuilderClient += "REMOTE_URL=" + clientURL + "\n"; + + const getChunkDB = await prompts({ + type: 'select', + name: 'value', + message: 'Pick A Database To Store File Chunks', + choices: [ + { title: 'Amazon S3', value: 's3'}, + { title: 'FileSystem', value: 'fs'}, + { title: 'MongoDB', value: 'mongo' }, + ], + initial: 1 + }) + + const chunkDB = getChunkDB.value; + + stringBuilderServer += "DB_TYPE=" + chunkDB + "\n"; + + if (chunkDB === "s3") { + + const gets3ID = await prompts({ + type: 'text', + message: "Enter The S3 ID", + name: "value" + }) + + const s3ID = gets3ID.value; + + stringBuilderServer += "S3_ID=" + s3ID + "\n"; + + const gets3Key = await prompts({ + type: 'password', + message: "Enter The S3 Key", + name: "value" + }) + + const s3Key = gets3Key.value; + + stringBuilderServer += "S3_KEY=" + s3Key + "\n"; + + const gets3Bucket = await prompts({ + type: 'text', + message: "Enter The S3 Bucket", + name: "value" + }) + + const s3Bucket = gets3Bucket.value; + + stringBuilderServer += "S3_BUCKET=" + s3Bucket + "\n"; + stringBuilderClient += "DISABLE_STORAGE=true\n"; + + } else if (chunkDB === "fs") { + + const getFSPath = await prompts({ + type: 'text', + message: "Enter The FileSystem Path", + name: "value" + }) + + const fsPath = getFSPath.value; + + stringBuilderServer += "FS_DIRECTORY=" + fsPath + "\n"; + stringBuilderServer += "ROOT=" + fsPath + "\n"; + + } else { + + stringBuilderClient += "DISABLE_STORAGE=true\n"; + } + + const getJWTSecret = await prompts({ + type: 'password', + message: "Enter JWT Secret", + name: "value", + }) + + const JWTsecret = getJWTSecret.value; + + stringBuilderServer += "PASSWORD=" + JWTsecret + "\n"; + + const getUseSSL = await prompts({ + type: 'toggle', + name: 'value', + message: "Use SSL? (Will Require SSL Certificate certificate.crt, certificate.ca-bundle, And certificate.key At Root Of The Project)", + initial: true, + active: 'yes', + inactive: 'no' + }) + + const useSSL = getUseSSL.value; + + if (useSSL) { + + stringBuilderServer += "SSL=true\n"; + } + + stringBuilderServer += "NODE_ENV=production\n"; + stringBuilderClient += "PORT=3000\n"; + stringBuilderServer += "HTTP_PORT=3000\n"; + stringBuilderServer += "HTTPS_PORT=8080\n" + + await awaitWriteFile("./.env.production", stringBuilderClient); + await awaitWriteFile("./config/prod.env", stringBuilderServer); + + console.log("\nServer And Client Env Files Created"); + } +} + +initServer(); \ No newline at end of file diff --git a/src/api/filesAPI.ts b/src/api/filesAPI.ts new file mode 100644 index 0000000..98974ab --- /dev/null +++ b/src/api/filesAPI.ts @@ -0,0 +1,231 @@ +import { QueryFunctionContext } from "react-query"; +import axios from "../axiosInterceptor"; +import { getUserToken } from "./userAPI"; +import getBackendURL from "../utils/getBackendURL"; +import { isPwa } from "../utils/PWAUtils"; + +interface QueryKeyParams { + parent: string; + search?: string; + sortBy?: string; + limit?: number; + startAtDate?: string; + startAtName?: string; + startAt?: boolean; + trashMode?: boolean; + mediaMode?: boolean; + mediaFilter?: string; +} + +// GET + +export const getFilesListAPI = async ({ + queryKey, + pageParam, +}: QueryFunctionContext<[string, QueryKeyParams]>) => { + const [ + _key, + { + parent = "/", + search = "", + sortBy = "date_desc", + limit = 50, + trashMode, + mediaMode, + mediaFilter, + }, + ] = queryKey; + + const queryParams: QueryKeyParams = { + parent, + search, + sortBy, + limit, + trashMode, + mediaMode, + mediaFilter, + }; + + if (pageParam?.startAtDate && pageParam?.startAtName) { + queryParams.startAtDate = pageParam.startAtDate; + queryParams.startAtName = pageParam.startAtName; + queryParams.startAt = true; + } + + const response = await axios.get(`/file-service/list`, { + params: queryParams, + }); + return response.data; +}; + +export const getQuickFilesListAPI = async () => { + const response = await axios.get(`/file-service/quick-list`, { + params: { + limit: 20, + }, + }); + return response.data; +}; + +export const downloadFileAPI = async (fileID: string) => { + await getUserToken(); + + const url = `${getBackendURL()}/file-service/download/${fileID}`; + + const link = document.createElement("a"); + document.body.appendChild(link); + link.href = url; + link.setAttribute("type", "hidden"); + link.setAttribute("download", "true"); + link.click(); +}; + +export const getVideoTokenAPI = async () => { + const response = await axios.get( + "/file-service/download/access-token-stream-video" + ); + return response.data; +}; + +export const getSuggestedListAPI = async ({ + queryKey, +}: QueryFunctionContext< + [string, { searchText: string; trashMode: boolean; mediaMode: boolean }] +>) => { + const [_key, { searchText, trashMode, mediaMode }] = queryKey; + const response = await axios.get(`/file-service/suggested-list`, { + params: { + search: searchText, + trashMode, + mediaMode, + }, + }); + return response.data; +}; + +export const getPublicFileInfoAPI = async ( + fileID: string, + tempToken: string +) => { + const response = await axios.get( + `/file-service/public/info/${fileID}/${tempToken}` + ); + return response.data; +}; + +export const downloadPublicFileAPI = async ( + fileID: string, + tempToken: string +) => { + const url = `${getBackendURL()}/file-service/public/download/${fileID}/${tempToken}`; + + const link = document.createElement("a"); + document.body.appendChild(link); + link.href = url; + link.setAttribute("type", "hidden"); + link.setAttribute("download", "true"); + link.click(); +}; + +// PATCH + +export const trashFileAPI = async (fileID: string) => { + const response = await axios.patch(`/file-service/trash`, { + id: fileID, + }); + return response.data; +}; + +export const trashMultiAPI = async (items: any) => { + const response = await axios.patch(`/file-service/trash-multi`, { + items, + }); + return response.data; +}; + +export const restoreFileAPI = async (fileID: string) => { + const response = await axios.patch(`/file-service/restore`, { + id: fileID, + }); + return response.data; +}; + +export const restoreMultiAPI = async (items: any) => { + const response = await axios.patch(`/file-service/restore-multi`, { + items, + }); + return response.data; +}; + +export const renameFileAPI = async (fileID: string, name: string) => { + const response = await axios.patch(`/file-service/rename`, { + id: fileID, + title: name, + }); + return response.data; +}; + +export const makePublicAPI = async (fileID: string) => { + const response = await axios.patch(`/file-service/make-public/${fileID}`); + return response.data; +}; + +export const makeOneTimePublicAPI = async (fileID: string) => { + const response = await axios.patch(`/file-service/make-one/${fileID}`); + return response.data; +}; + +export const removeLinkAPI = async (fileID: string) => { + const response = await axios.patch(`/file-service/remove-link/${fileID}`); + return response.data; +}; + +export const moveFileAPI = async (fileID: string, parentID: string) => { + const response = await axios.patch(`/file-service/move`, { + id: fileID, + parentID, + }); + return response.data; +}; + +export const moveMultiAPI = async (items: any, parentID: string) => { + const response = await axios.patch(`/file-service/move-multi`, { + items, + parentID, + }); + return response.data; +}; + +// DELETE + +export const deleteFileAPI = async (fileID: string) => { + const response = await axios.delete(`/file-service/remove`, { + data: { + id: fileID, + }, + }); + return response.data; +}; + +export const deleteMultiAPI = async (items: any) => { + const response = await axios.delete(`/file-service/remove-multi`, { + data: { + items, + }, + }); + return response.data; +}; + +export const deleteVideoTokenAPI = async () => { + const response = await axios.delete( + "/file-service/remove-stream-video-token" + ); + return response.data; +}; + +// POST +export const uploadFileAPI = async (data: FormData, config: any) => { + const url = "/file-service/upload"; + const response = await axios.post(url, data, config); + return response.data; +}; diff --git a/src/api/foldersAPI.ts b/src/api/foldersAPI.ts new file mode 100644 index 0000000..c1ef715 --- /dev/null +++ b/src/api/foldersAPI.ts @@ -0,0 +1,147 @@ +import { QueryFunctionContext } from "react-query"; +import axios from "../axiosInterceptor"; +import { getUserToken } from "./userAPI"; +import getBackendURL from "../utils/getBackendURL"; + +interface QueryKeyParams { + parent: string; + search?: string; + sortBy?: string; + limit?: number; + trashMode?: boolean; +} + +// GET + +export const getFoldersListAPI = async ({ + queryKey, +}: QueryFunctionContext<[string, QueryKeyParams]>) => { + const [_key, { parent, search, sortBy, limit, trashMode }] = queryKey; + const response = await axios.get(`/folder-service/list`, { + params: { + parent, + search, + sortBy, + limit, + trashMode, + }, + }); + return response.data; +}; + +export const getFolderInfoAPI = async ({ + queryKey, +}: QueryFunctionContext<[string, { id: string | undefined }]>) => { + const [_key, { id }] = queryKey; + if (!id) return undefined; + const response = await axios.get(`/folder-service/info/${id}`); + return response.data; +}; + +export const getMoveFolderListAPI = async ({ + queryKey, +}: QueryFunctionContext< + [ + string, + { + parent: string; + search: string; + folderIDs?: string[]; + currentParent: string; + } + ] +>) => { + const [_key, { parent, search, folderIDs, currentParent }] = queryKey; + const response = await axios.get(`/folder-service/move-folder-list`, { + params: { + parent, + search, + folderIDs, + currentParent, + }, + }); + return response.data; +}; + +export const downloadZIPAPI = async ( + folderIDs: string[], + fileIDs: string[] +) => { + await getUserToken(); + + let url = `${getBackendURL()}/folder-service/download-zip?`; + + for (const folderID of folderIDs) { + url += `folderIDs[]=${folderID}&`; + } + + for (const fileID of fileIDs) { + url += `fileIDs[]=${fileID}&`; + } + + const link = document.createElement("a"); + document.body.appendChild(link); + link.href = url; + link.setAttribute("type", "hidden"); + link.setAttribute("download", "true"); + link.click(); +}; + +// POST + +export const createFolderAPI = async (name: string, parent?: string) => { + const response = await axios.post("/folder-service/create", { + name, + parent, + }); + return response.data; +}; + +export const uploadFolderAPI = async (data: FormData, config: any) => { + const url = "/folder-service/upload"; + const response = await axios.post(url, data, config); + return response.data; +}; + +// PATCH + +export const renameFolder = async (folderID: string, name: string) => { + const response = await axios.patch("/folder-service/rename", { + id: folderID, + title: name, + }); + return response.data; +}; + +export const trashFolderAPI = async (folderID: string) => { + const response = await axios.patch("/folder-service/trash", { + id: folderID, + }); + return response.data; +}; + +export const restoreFolderAPI = async (folderID: string) => { + const response = await axios.patch("/folder-service/restore", { + id: folderID, + }); + return response.data; +}; + +export const moveFolderAPI = async (folderID: string, parentID: string) => { + const response = await axios.patch(`/folder-service/move`, { + id: folderID, + parentID, + }); + return response.data; +}; + +// DELETE + +export const deleteFolderAPI = async (folderID: string) => { + const response = await axios.delete("/folder-service/remove", { + data: { + id: folderID, + }, + }); + return response.data; +}; diff --git a/src/api/userAPI.ts b/src/api/userAPI.ts new file mode 100644 index 0000000..6b8e876 --- /dev/null +++ b/src/api/userAPI.ts @@ -0,0 +1,98 @@ +import axios from "../axiosInterceptor"; + +// GET + +export const getUserToken = async () => { + const response = await axios.post("/user-service/get-token"); + response.data; +}; + +export const getUserAPI = async () => { + const response = await axios.get("/user-service/user"); + return response.data; +}; + +export const getUserDetailedAPI = async () => { + const response = await axios.get("/user-service/user-detailed"); + return response.data; +}; + +// POST + +export const loginAPI = async (email: string, password: string) => { + const response = await axios.post("/user-service/login", { + email, + password, + }); + return response.data; +}; + +export const createAccountAPI = async (email: string, password: string) => { + const response = await axios.post("/user-service/create", { + email, + password, + }); + return response.data; +}; + +export const logoutAPI = async () => { + const response = await axios.post("/user-service/logout"); + return response.data; +}; + +export const logoutAllAPI = async () => { + const response = await axios.post("/user-service/logout-all"); + return response.data; +}; + +export const getAccessToken = async (uuid: string) => { + const response = await axios.post("/user-service/get-token", undefined, { + headers: { + uuid, + }, + }); + return response.data; +}; + +// PATCH + +export const changePasswordAPI = async ( + oldPassword: string, + newPassword: string +) => { + const response = await axios.patch("/user-service/change-password", { + oldPassword, + newPassword, + }); + return response.data; +}; + +export const resendVerifyEmailAPI = async () => { + const response = await axios.patch("/user-service/resend-verify-email"); + return response.data; +}; + +export const verifyEmailAPI = async (emailToken: string) => { + const response = await axios.patch("/user-service/verify-email", { + emailToken, + }); + return response.data; +}; + +export const sendPasswordResetAPI = async (email: string) => { + const response = await axios.patch("/user-service/send-password-reset", { + email, + }); + return response.data; +}; + +export const resetPasswordAPI = async ( + password: string, + passwordToken: string +) => { + const response = await axios.patch("/user-service/reset-password", { + passwordToken, + password, + }); + return response.data; +}; diff --git a/src/app.tsx b/src/app.tsx new file mode 100644 index 0000000..c134e5e --- /dev/null +++ b/src/app.tsx @@ -0,0 +1,26 @@ +import ReactDOM from "react-dom/client"; +import { Provider } from "react-redux"; +import store from "./store/configureStore"; +import AppRouter from "./routers/AppRouter"; +import "normalize.css/normalize.css"; +import "./styles/styles.scss"; +import "core-js/stable"; +import "regenerator-runtime/runtime"; +import "react-circular-progressbar/dist/styles.css"; +import "react-toastify/dist/ReactToastify.css"; +import { QueryClient, QueryClientProvider } from "react-query"; +// import '../node_modules/@fortawesome/fontawesome-free/css/all.css'; +// import '../node_modules/@fortawesome/fontawesome-free/js/all.js'; + +const queryClient = new QueryClient(); + +const jsxWrapper = ( + + + + + +); + +const root = ReactDOM.createRoot(document.getElementById("app")); +root.render(jsxWrapper); diff --git a/src/axiosInterceptor/index.ts b/src/axiosInterceptor/index.ts new file mode 100644 index 0000000..f27e7f2 --- /dev/null +++ b/src/axiosInterceptor/index.ts @@ -0,0 +1,94 @@ +import axios from "axios"; +import uuid from "uuid"; +import getBackendURL from "../utils/getBackendURL"; + +let browserIDCheck = localStorage.getItem("browser-id"); + +const sleep = () => { + return new Promise((resolve, reject) => { + setTimeout(() => { + resolve(true); + }, 150); + }); +}; + +const axiosRetry = axios.create({ baseURL: getBackendURL() }); +const axiosNoRetry = axios.create({ baseURL: getBackendURL() }); +const axios3 = axios.create({ baseURL: getBackendURL() }); + +axiosRetry.interceptors.request.use( + (config) => { + if (!browserIDCheck) { + browserIDCheck = uuid.v4(); + localStorage.setItem("browser-id", browserIDCheck); + } + + config.headers.uuid = browserIDCheck; + + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +axiosRetry.interceptors.response.use( + (response) => { + //console.log("axios interceptor successful") + return response; + }, + (error) => { + return new Promise((resolve, reject) => { + let originalRequest = error.config; + + if (error.response.status !== 401) { + return reject(error); + } + + if (originalRequest.ran === true) { + //console.log("original request ran", error.config.url); + return reject(error); + } + + if (error.config.url === "/user-service/get-token") { + //console.log("error url equal to refresh token route") + return reject(); + } + + if (!browserIDCheck) { + browserIDCheck = uuid.v4(); + localStorage.setItem("browser-id", browserIDCheck); + } + + axiosNoRetry + .post( + "/user-service/get-token", + {}, + { + headers: { + uuid: browserIDCheck, + }, + } + ) + .then((cookieResponse) => { + // We need to sleep before requesting again, if not I believe + // The old request will still be open and it will not make a + // Brand new request sometimes, so it will log users out + // But adding a sleep function seems to fix this. + return sleep(); + }) + .then((sleepres) => { + return axios3(originalRequest); + }) + .then((response) => { + resolve(response); + }) + .catch((e) => { + //console.log("error"); + return reject(error); + }); + }); + } +); + +export default axiosRetry; diff --git a/src/components/AddNewDropdown/AddNewDropdown.tsx b/src/components/AddNewDropdown/AddNewDropdown.tsx new file mode 100644 index 0000000..224c802 --- /dev/null +++ b/src/components/AddNewDropdown/AddNewDropdown.tsx @@ -0,0 +1,168 @@ +import { useParams } from "react-router-dom"; +import { createFolderAPI } from "../../api/foldersAPI"; +import { useClickOutOfBounds } from "../../hooks/utils"; +import { showCreateFolderPopup } from "../../popups/folder"; +import React, { RefObject, useEffect, useRef, useState } from "react"; +import { useUploader } from "../../hooks/files"; +import UploadFileIcon from "../../icons/UploadFileIcon"; +import CreateFolderIcon from "../../icons/CreateFolderIcon"; +import FolderUploadIcon from "../../icons/FolderUploadIcon"; +import Swal from "sweetalert2"; +import { useFolders } from "../../hooks/folders"; +import classNames from "classnames"; + +interface AddNewDropdownProps { + closeDropdown: () => void; + isDropdownOpen: boolean; +} + +const AddNewDropdown: React.FC = ({ + closeDropdown, + isDropdownOpen, +}) => { + const params = useParams(); + const { refetch: refetchFolders } = useFolders(false); + const [supportsWebkitDirectory, setSupportsWebkitDirectory] = useState(false); + const { wrapperRef } = useClickOutOfBounds(closeDropdown, true); + const uploadRef: RefObject = useRef(null); + const uploadFolderRef: RefObject = useRef(null); + const { uploadFiles, uploadFolder } = useUploader(); + + const createFolder = async () => { + closeDropdown(); + const folderName = await showCreateFolderPopup(); + + if (folderName === undefined || folderName === null) { + return; + } + + await createFolderAPI(folderName, params.id); + refetchFolders(); + }; + + const handleUpload = (e: React.FormEvent) => { + e.preventDefault(); + closeDropdown(); + + const files = uploadRef.current?.files; + if (!files) return; + + uploadFiles(files); + }; + + const checkForWebkitDirectory = (items: FileList) => { + for (let i = 0; i < items.length; i++) { + if (!items[i].webkitRelativePath) { + return false; + } + } + return true; + }; + + const handleFolderUpload = (e: React.FormEvent) => { + e.preventDefault(); + closeDropdown(); + + const items = uploadFolderRef.current?.files; + + if (!items || !items.length) { + Swal.fire({ + title: "No items selected", + icon: "error", + confirmButtonColor: "#3085d6", + confirmButtonText: "Okay", + }); + return; + } + + const hasWebkitDirectory = checkForWebkitDirectory(items); + + if (!hasWebkitDirectory) { + uploadFiles(items); + } else { + uploadFolder(items); + } + }; + + const triggerFileUpload = () => { + if (uploadRef.current) { + uploadRef.current.click(); + } + }; + + const triggerFolderUpload = () => { + if (uploadFolderRef.current) { + uploadFolderRef.current.click(); + } + }; + + useEffect(() => { + if (uploadFolderRef.current) { + setSupportsWebkitDirectory("webkitdirectory" in uploadFolderRef.current); + } + }, []); + + return ( +
+ + + +
+ ); +}; + +export default AddNewDropdown; diff --git a/src/components/ContextMenu/ContextMenu.tsx b/src/components/ContextMenu/ContextMenu.tsx new file mode 100644 index 0000000..08ffb71 --- /dev/null +++ b/src/components/ContextMenu/ContextMenu.tsx @@ -0,0 +1,255 @@ +import React, { memo, useEffect, useState } from "react"; +import { useClickOutOfBounds, useUtils } from "../../hooks/utils"; +import TrashIcon from "../../icons/TrashIcon"; +import MultiSelectIcon from "../../icons/MultiSelectIcon"; +import RenameIcon from "../../icons/RenameIcon"; +import ShareIcon from "../../icons/ShareIcon"; +import DownloadIcon from "../../icons/DownloadIcon"; +import MoveIcon from "../../icons/MoveIcon"; +import RestoreIcon from "../../icons/RestoreIcon"; +import { FileInterface } from "../../types/file"; +import { useNavigate } from "react-router-dom"; +import { useActions } from "../../hooks/actions"; +import { FolderInterface } from "../../types/folders"; +import classNames from "classnames"; + +export interface ContextMenuProps { + closeContext: () => void; + contextSelected: { + selected: boolean; + X: number; + Y: number; + }; + folderMode?: boolean; + quickItemMode?: boolean; + parentBarMode?: boolean; + file?: FileInterface | null; + folder?: FolderInterface | null; + stopPropagation?: () => void; +} + +const ContextMenu: React.FC = memo((props) => { + const [fixedCoords, setFixedCoords] = useState({ + X: 0, + Y: 0, + set: false, + }); + const [animate, setAnimate] = useState(false); + const { + closeContext, + contextSelected, + folderMode, + file, + quickItemMode, + stopPropagation, + folder, + parentBarMode, + } = props; + const { wrapperRef } = useClickOutOfBounds(closeContext); + const { isTrash, isMedia } = useUtils(); + const navigate = useNavigate(); + const { + renameItem, + trashItem, + deleteItem, + restoreItem, + openMoveItemModal, + openShareItemModal, + downloadItem, + selectItemMultiSelect, + } = useActions({ quickItemMode }); + + useEffect(() => { + if (!wrapperRef.current) return; + + const modalWidth = wrapperRef.current.clientWidth; + const modalHeight = wrapperRef.current.clientHeight; + + const { innerWidth: windowWidth, innerHeight: windowHeight } = window; + + let X = contextSelected.X; + let Y = contextSelected.Y; + + if (X + modalWidth > windowWidth) { + X = windowWidth - modalWidth - 10; + } + + if (Y + modalHeight > windowHeight) { + Y = windowHeight - modalHeight - 10; + } + + setFixedCoords({ + X, + Y, + set: true, + }); + }, [wrapperRef, contextSelected.X, contextSelected.Y]); + + const onAction = async ( + action: + | "rename" + | "trash" + | "delete" + | "restore" + | "move" + | "share" + | "download" + | "multi-select" + ) => { + closeContext(); + switch (action) { + case "rename": + await renameItem(file, folder); + break; + case "trash": + await trashItem(file, folder); + break; + case "delete": + await deleteItem(file, folder); + break; + case "restore": + await restoreItem(file, folder); + break; + case "move": + await openMoveItemModal(file, folder); + break; + case "share": + openShareItemModal(file); + break; + case "download": + downloadItem(file, folder); + break; + case "multi-select": + selectItemMultiSelect(file, folder); + } + + if ( + folder && + parentBarMode && + ["trash", "delete", "restore"].includes(action) + ) { + if (folder.parent === "/") { + navigate("/trash"); + } else { + navigate(`/folder-trash/${folder.parent}`); + } + } + }; + + useEffect(() => { + setAnimate(true); + }, []); + + const outterWrapperClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if ((e.target as HTMLDivElement).id !== "context-wrapper") { + return; + } + closeContext(); + }; + + return ( +
+
+
+ {!parentBarMode && ( +
onAction("multi-select")} + className="text-gray-primary flex flex-row p-4 hover:bg-white-hover hover:text-primary rounded-t-md" + > + +

Multi-select

+
+ )} + {!isTrash && !isMedia && ( +
onAction("rename")} + className="text-gray-primary flex flex-row p-4 hover:bg-white-hover hover:text-primary" + > + +

Rename

+
+ )} + {!folderMode && !isTrash && ( +
onAction("share")} + className="text-gray-primary flex flex-row p-4 hover:bg-white-hover hover:text-primary" + > + +

Share

+
+ )} + {!isTrash && ( +
onAction("download")} + className="text-gray-primary flex flex-row p-4 hover:bg-white-hover hover:text-primary" + > + +

Download

+
+ )} + {!isTrash && !isMedia && ( +
onAction("move")} + className="text-gray-primary flex flex-row p-4 hover:bg-white-hover hover:text-primary" + > + +

Move

+
+ )} + {!isTrash && ( +
onAction("trash")} + className="text-gray-primary flex flex-row p-4 hover:bg-white-hover hover:text-primary rounded-b-md" + > + +

Trash

+
+ )} + {isTrash && ( +
onAction("restore")} + className="text-gray-primary flex flex-row p-4 hover:bg-white-hover hover:text-primary" + > + +

Restore

+
+ )} + {isTrash && ( +
onAction("delete")} + className="text-gray-primary flex flex-row p-4 hover:bg-white-hover hover:text-red-500 rounded-b-md" + > + +

Delete

+
+ )} +
+
+
+ ); +}); + +export default ContextMenu; diff --git a/src/components/Dataform/Dataform.tsx b/src/components/Dataform/Dataform.tsx new file mode 100644 index 0000000..dec6344 --- /dev/null +++ b/src/components/Dataform/Dataform.tsx @@ -0,0 +1,156 @@ +import QuickAccess from "../QuickAccess/QuickAccess"; +import Folders from "../Folders/Folders"; +import { useFiles, useQuickFiles, useUploader } from "../../hooks/files"; +import { useInfiniteScroll } from "../../hooks/infiniteScroll"; +import Files from "../Files/Files"; +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import Spinner from "../Spinner/Spinner"; +import { useAppDispatch, useAppSelector } from "../../hooks/store"; +import { useLocation, useParams } from "react-router-dom"; +import classNames from "classnames"; +import { useDragAndDrop, useUtils } from "../../hooks/utils"; +import MultiSelectBar from "../MultiSelectBar/MultiSelectBar"; +import { useFolder, useFolders } from "../../hooks/folders"; +import { removeNavigationMap } from "../../reducers/selected"; +import AlertIcon from "../../icons/AlertIcon"; + +const DataForm = memo( + ({ scrollDivRef }: { scrollDivRef: React.RefObject }) => { + const { + fetchNextPage: filesFetchNextPage, + isFetchingNextPage: isFetchingNextPageState, + data: fileList, + isLoading: isLoadingFiles, + } = useFiles(); + const { isLoading: isLoadingFolder } = useFolder(true); + const { isLoading: isLoadingFolders } = useFolders(); + const { isLoading: isLoadingQuickItems } = useQuickFiles(); + const dispatch = useAppDispatch(); + const { sentinelRef, reachedIntersect } = useInfiniteScroll(); + const [initialLoad, setInitialLoad] = useState(true); + const params = useParams(); + const { uploadFiles } = useUploader(); + const isFetchingNextPage = useRef(false); + const prevPathname = useRef(""); + const location = useLocation(); + const navigationMap = useAppSelector((state) => { + return state.selected.navigationMap[location.pathname]; + }); + const { isTrash } = useUtils(); + + const isLoading = + isLoadingFiles || + isLoadingFolders || + isLoadingQuickItems || + isLoadingFolder; + + useEffect(() => { + if (initialLoad) { + setInitialLoad(false); + return; + } else if (!fileList || isFetchingNextPage.current) { + return; + } + if (reachedIntersect && !isLoadingFiles) { + isFetchingNextPage.current = true; + filesFetchNextPage().then(() => { + isFetchingNextPage.current = false; + }); + } + }, [reachedIntersect, initialLoad, isLoadingFiles]); + + useEffect(() => { + if (!isLoading && navigationMap) { + const scrollTop = navigationMap.scrollTop; + scrollDivRef.current?.scrollTo(0, scrollTop); + dispatch(removeNavigationMap(location.pathname)); + prevPathname.current = location.pathname; + } else if (!isLoading && prevPathname.current !== location.pathname) { + scrollDivRef.current?.scrollTo(0, 0); + prevPathname.current = location.pathname; + } + }, [isLoading, navigationMap, location.pathname]); + + const addFile = useCallback( + (files: FileList) => { + uploadFiles(files); + }, + [params.id] + ); + + const { + isDraggingFile, + onDragDropEvent, + onDragEvent, + onDragEnterEvent, + stopDrag, + } = useDragAndDrop(addFile); + + return ( +
+ {!isLoading && ( +
+
+ +
+ + + + {isTrash && ( +
+ + + Items in the trash may be automatically deleted depending on + the servers settings + +
+ )} + + + + +
+ )} + + {isLoading && ( +
+ +
+ )} + {/* @ts-ignore */} +
+ + {isFetchingNextPageState && ( +
+ +
+ )} +
+ ); + } +); + +export default DataForm; diff --git a/src/components/DownloadPage/DownloadPage.tsx b/src/components/DownloadPage/DownloadPage.tsx new file mode 100644 index 0000000..9c17657 --- /dev/null +++ b/src/components/DownloadPage/DownloadPage.tsx @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { + downloadPublicFileAPI, + getPublicFileInfoAPI, +} from "../../api/filesAPI"; +import { toast, ToastContainer } from "react-toastify"; +import Spinner from "../Spinner/Spinner"; +import dayjs from "dayjs"; +import { getFileColor, getFileExtension } from "../../utils/files"; +import { FileInterface } from "../../types/file"; +import bytes from "bytes"; +import LockIcon from "../../icons/LockIcon"; +import OneIcon from "../../icons/OneIcon"; +import StorageIcon from "../../icons/StorageIcon"; +import CalendarIcon from "../../icons/CalendarIcon"; +import DownloadIcon from "../../icons/DownloadIcon"; +import PublicIcon from "../../icons/PublicIcon"; + +const PublicDownloadPage = () => { + const [file, setFile] = useState(null); + const params = useParams(); + + const getFile = useCallback(async () => { + try { + const id = params.id!; + const tempToken = params.tempToken!; + const fileResponse = await getPublicFileInfoAPI(id, tempToken); + setFile(fileResponse); + } catch (e) { + console.log("Error getting publicfile info", e); + toast.error("Error getting public file"); + } + }, [params.id, params.tempToken]); + + const downloadItem = () => { + const id = params.id!; + const tempToken = params.tempToken!; + downloadPublicFileAPI(id, tempToken); + }; + + const permissionText = (() => { + if (!file) return ""; + if (file.metadata.linkType === "one") { + return `Temporary`; + } else if (file.metadata.linkType === "public") { + return "Public"; + } else { + return "Private"; + } + })(); + + const copyName = () => { + navigator.clipboard.writeText(file!.filename); + toast.success("Filename Copied"); + }; + + useEffect(() => { + getFile(); + }, [getFile]); + + if (!file) { + return ( +
+ + +
+ ); + } + + const fileExtension = getFileExtension(file.filename, 3); + + const imageColor = getFileColor(file.filename); + + const formattedDate = dayjs(file.uploadDate).format("MM/DD/YYYY hh:mma"); + + const fileSize = bytes(file.metadata.size); + + return ( +
+
+
+
+ +
+ + {fileExtension} + +
+
+

+ {file.filename} +

+
+
+
+
+ + +
+

File details

+
+
+ {!file.metadata.linkType && } + {file.metadata.linkType === "one" && ( + + )} + {file.metadata.linkType === "public" && ( + + )} +

{permissionText}

+
+
+ +

{fileSize}

+
+
+ +

{formattedDate}

+
+
+ +
+
+
+
+ +
+ ); +}; + +export default PublicDownloadPage; diff --git a/src/components/FileInfoPopup/FileInfoPopup.tsx b/src/components/FileInfoPopup/FileInfoPopup.tsx new file mode 100644 index 0000000..6523326 --- /dev/null +++ b/src/components/FileInfoPopup/FileInfoPopup.tsx @@ -0,0 +1,197 @@ +import { useEffect, useMemo, useState } from "react"; +import { useAppDispatch, useAppSelector } from "../../hooks/store"; +import { downloadFileAPI } from "../../api/filesAPI"; +import CloseIcon from "../../icons/CloseIcon"; +import ActionsIcon from "../../icons/ActionsIcon"; +import { useContextMenu } from "../../hooks/contextMenu"; +import ContextMenu from "../ContextMenu/ContextMenu"; +import { resetPopupSelect } from "../../reducers/selected"; +import { getFileColor, getFileExtension } from "../../utils/files"; +import bytes from "bytes"; +import dayjs from "dayjs"; +import LockIcon from "../../icons/LockIcon"; +import OneIcon from "../../icons/OneIcon"; +import PublicIcon from "../../icons/PublicIcon"; +import StorageIcon from "../../icons/StorageIcon"; +import CalendarIcon from "../../icons/CalendarIcon"; +import DownloadIcon from "../../icons/DownloadIcon"; +import { toast } from "react-toastify"; + +const FileInfoPopup = () => { + const file = useAppSelector((state) => state.selected.popupModal.file)!; + const dispatch = useAppDispatch(); + const { + onContextMenu, + closeContextMenu, + onTouchStart, + onTouchMove, + onTouchEnd, + clickStopPropagation, + ...contextMenuState + } = useContextMenu(); + const [animate, setAnimate] = useState(false); + + const fileExtension = getFileExtension(file.filename, 3); + + const imageColor = getFileColor(file.filename); + + const formattedDate = useMemo( + () => dayjs(file.uploadDate).format("MM/DD/YYYY hh:mma"), + [file.uploadDate] + ); + + const fileSize = bytes(file.metadata.size); + + const closePhotoViewer = () => { + setAnimate(false); + setTimeout(() => dispatch(resetPopupSelect()), 200); + }; + + const downloadItem = () => { + downloadFileAPI(file._id); + }; + + const outterWrapperClick = (e: React.MouseEvent) => { + if ( + (e.target as HTMLDivElement).id !== "outer-wrapper" || + contextMenuState.selected + ) { + return; + } + setAnimate(false); + setTimeout(() => dispatch(resetPopupSelect()), 200); + }; + + const permissionText = (() => { + if (file.metadata.linkType === "one") { + return `Temporary`; + } else if (file.metadata.linkType === "public") { + return "Public"; + } else { + return "Private"; + } + })(); + + const copyName = () => { + navigator.clipboard.writeText(file.filename); + toast.success("Filename Copied"); + }; + + useEffect(() => { + setAnimate(true); + }, []); + + useEffect(() => { + const handleBack = () => { + dispatch(resetPopupSelect()); + }; + window.addEventListener("popstate", handleBack); + + return () => { + window.removeEventListener("popstate", handleBack); + }; + }, []); + + return ( +
+ {contextMenuState.selected && ( +
+ +
+ )} +
+
+ +
+ + {fileExtension} + +
+
+

+ {file.filename} +

+
+
+
+ +
+ +
+ +
+
+
+
+
+ + +
+

File details

+
+
+ {!file.metadata.linkType && } + {file.metadata.linkType === "one" && ( + + )} + {file.metadata.linkType === "public" && ( + + )} +

{permissionText}

+
+
+ +

{fileSize}

+
+
+ +

{formattedDate}

+
+
+ +
+
+
+
+ ); +}; + +export default FileInfoPopup; diff --git a/src/components/FileItem/FileItem.tsx b/src/components/FileItem/FileItem.tsx new file mode 100644 index 0000000..2e94038 --- /dev/null +++ b/src/components/FileItem/FileItem.tsx @@ -0,0 +1,282 @@ +import capitalize from "../../utils/capitalize"; +import React, { memo, useMemo, useRef, useState } from "react"; +import ContextMenu from "../ContextMenu/ContextMenu"; +import { useContextMenu } from "../../hooks/contextMenu"; +import classNames from "classnames"; +import { getFileColor, getFileExtension } from "../../utils/files"; +import bytes from "bytes"; +import { useAppDispatch, useAppSelector } from "../../hooks/store"; +import { setMainSelect, setMultiSelectMode } from "../../reducers/selected"; +import PlayButtonIcon from "../../icons/PlayIcon"; +import { setPopupSelect } from "../../reducers/selected"; +import ActionsIcon from "../../icons/ActionsIcon"; +import { FileInterface } from "../../types/file"; +import getBackendURL from "../../utils/getBackendURL"; +import dayjs from "dayjs"; +import CalendarIcon from "../../icons/CalendarIcon"; +import ClockIcon from "../../icons/ClockIcon"; + +interface FileItemProps { + file: FileInterface; +} + +const FileItem: React.FC = memo((props) => { + const { file } = props; + const elementSelected = useAppSelector((state) => { + if (state.selected.mainSection.type !== "file") return false; + return state.selected.mainSection.id === file._id; + }); + const elementMultiSelected = useAppSelector((state) => { + if (!state.selected.multiSelectMode) return false; + const selected = state.selected.multiSelectMap[file._id]; + return selected && selected.type === "file"; + }); + const multiSelectMode = useAppSelector( + (state) => state.selected.multiSelectMode + ); + const listView = useAppSelector((state) => state.general.listView); + const thumbnailURL = `${getBackendURL()}/file-service/thumbnail/${ + file.metadata.thumbnailID + }`; + const hasThumbnail = file.metadata.hasThumbnail; + const [thumbnailLoaded, setThumbnailLoaded] = useState(false); + const dispatch = useAppDispatch(); + const lastSelected = useRef(0); + const { + onContextMenu, + closeContextMenu, + onTouchStart, + onTouchMove, + onTouchEnd, + clickStopPropagation, + ...contextMenuState + } = useContextMenu(); + const fileExtension = getFileExtension(file.filename, listView ? 3 : 4); + + const imageColor = getFileColor(file.filename); + + const formattedFilename = capitalize(file.filename); + + const formattedCreatedDate = useMemo( + () => dayjs(file.uploadDate).format("MM/DD/YY hh:mma"), + [file.uploadDate] + ); + + const fileClick = (e: React.MouseEvent) => { + const multiSelectKey = e.metaKey || e.ctrlKey; + + if (multiSelectMode || multiSelectKey) { + dispatch( + setMultiSelectMode([ + { + type: "file", + id: file._id, + file: file, + folder: null, + }, + ]) + ); + return; + } + + const currentDate = Date.now(); + + if (!elementSelected) { + dispatch( + setMainSelect({ file, id: file._id, type: "file", folder: null }) + ); + lastSelected.current = Date.now(); + return; + } + + if (currentDate - lastSelected.current < 1500) { + dispatch(setPopupSelect({ type: "file", file })); + } + + lastSelected.current = Date.now(); + }; + + if (listView) { + return ( + + {/* */} + +
+ +
+ + {fileExtension} + +
+
+

+ {formattedFilename} +

+
+ + +

{bytes(props.file.length)}

+ + +

+ {formattedCreatedDate} +

+ + +
+ {contextMenuState.selected && ( +
+ +
+ )} + + {/* */} + + + + +
+ + + ); + } else { + return ( +
+ {contextMenuState.selected && ( +
+ +
+ )} +
+ {hasThumbnail && ( +
+ setThumbnailLoaded(true)} + /> + {file.metadata.isVideo && ( +
+ +
+ )} +
+ )} + {!thumbnailLoaded && ( + <> + + + +
+

{fileExtension}

+
+ + )} +
+
+

+ {formattedFilename} +

+
+ +

+ {formattedCreatedDate} +

+
+
+
+ ); + } +}); + +export default FileItem; diff --git a/src/components/Files/Files.tsx b/src/components/Files/Files.tsx new file mode 100644 index 0000000..7f7526b --- /dev/null +++ b/src/components/Files/Files.tsx @@ -0,0 +1,136 @@ +import { useFiles } from "../../hooks/files"; +import { useUtils } from "../../hooks/utils"; +import React, { memo } from "react"; +import FileItem from "../FileItem/FileItem"; +import classNames from "classnames"; +import { useAppDispatch, useAppSelector } from "../../hooks/store"; +import { toggleListView } from "../../reducers/general"; + +const Files = memo(() => { + const { data: files } = useFiles(false); + const listView = useAppSelector((state) => state.general.listView); + const { isHome } = useUtils(); + + const dispatch = useAppDispatch(); + + const changeListViewMode = () => { + dispatch(toggleListView()); + }; + + return ( +
+
+
+

+ {isHome ? "Home" : ""} Files +

+
+ +
+
+ + {!listView ? ( +
+ {files?.pages.map((filePage, index) => ( + + {filePage.map((file) => ( + + ))} + + ))} +
+ ) : ( + + + + + + + + + + + {files?.pages.map((filePage, index) => ( + + {filePage.map((file) => ( + + ))} + + ))} + +
+
+

Name

+
+
+

Size

+
+

Created

+
+

Actions

+
+ )} +
+
+ ); +}); + +export default Files; diff --git a/src/components/FolderItem/FolderItem.tsx b/src/components/FolderItem/FolderItem.tsx new file mode 100644 index 0000000..ce9cc15 --- /dev/null +++ b/src/components/FolderItem/FolderItem.tsx @@ -0,0 +1,185 @@ +import React, { memo, useRef } from "react"; +import ContextMenu from "../ContextMenu/ContextMenu"; +import { useContextMenu } from "../../hooks/contextMenu"; +import classNames from "classnames"; +import { useNavigate } from "react-router-dom"; +import { useAppDispatch, useAppSelector } from "../../hooks/store"; +import { + addNavigationMap, + setMainSelect, + setMultiSelectMode, +} from "../../reducers/selected"; +import { useUtils } from "../../hooks/utils"; +import { FolderInterface } from "../../types/folders"; +import dayjs from "dayjs"; +import ClockIcon from "../../icons/ClockIcon"; + +interface FolderItemProps { + folder: FolderInterface; + scrollDivRef: React.RefObject; +} + +const FolderItem: React.FC = memo((props) => { + const { folder, scrollDivRef } = props; + const elementSelected = useAppSelector((state) => { + if (state.selected.mainSection.type !== "folder") return false; + return state.selected.mainSection.id === folder._id; + }); + const elementMultiSelected = useAppSelector((state) => { + if (!state.selected.multiSelectMode) return false; + return state.selected.multiSelectMap[folder._id]; + }); + const multiSelectMode = useAppSelector( + (state) => state.selected.multiSelectMode + ); + const singleClickFolders = useAppSelector( + (state) => state.general.singleClickFolders + ); + const { isTrash } = useUtils(); + const lastSelected = useRef(0); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const { + onContextMenu, + closeContextMenu, + onTouchStart, + onTouchMove, + onTouchEnd, + clickStopPropagation, + ...contextMenuState + } = useContextMenu(); + + const folderClick = (e: React.MouseEvent) => { + const multiSelectKey = e.metaKey || e.ctrlKey; + if (multiSelectMode || multiSelectKey) { + dispatch( + setMultiSelectMode([ + { + type: "folder", + id: folder._id, + file: null, + folder: folder, + }, + ]) + ); + return; + } + const currentDate = Date.now(); + + if (!elementSelected) { + dispatch( + setMainSelect({ + file: null, + id: folder._id, + type: "folder", + folder: folder, + }) + ); + lastSelected.current = Date.now(); + + if (!singleClickFolders) return; + } + + if (singleClickFolders || currentDate - lastSelected.current < 1500) { + dispatch( + addNavigationMap({ + url: window.location.pathname, + scrollTop: scrollDivRef.current?.scrollTop || 0, + }) + ); + if (isTrash) { + navigate(`/folder-trash/${folder._id}`); + } else { + navigate(`/folder/${folder._id}`); + } + } + + lastSelected.current = Date.now(); + }; + + return ( +
+ {contextMenuState.selected && ( +
+ +
+ )} + +
+ +
+
+

+ {folder.name} +

+
+ +

+ {dayjs(folder.createdAt).format("MM/DD/YY hh:mma")} +

+
+
+
+ ); +}); + +export default FolderItem; diff --git a/src/components/Folders/Folders.tsx b/src/components/Folders/Folders.tsx new file mode 100644 index 0000000..3f9cddf --- /dev/null +++ b/src/components/Folders/Folders.tsx @@ -0,0 +1,157 @@ +import { useFolders } from "../../hooks/folders"; +import FolderItem from "../FolderItem/FolderItem"; +import React, { memo } from "react"; +import classNames from "classnames"; +import { useUtils } from "../../hooks/utils"; +import { useAppDispatch, useAppSelector } from "../../hooks/store"; +import { setSortBy } from "../../reducers/filter"; +import ParentBar from "../ParentBar/ParentBar"; + +const Folders = memo( + ({ scrollDivRef }: { scrollDivRef: React.RefObject }) => { + const { data: folders } = useFolders(false); + const { isTrash, isSearch, isHome } = useUtils(); + const sortBy = useAppSelector((state) => state.filter.sortBy); + const dispatch = useAppDispatch(); + + const switchOrderSortBy = () => { + let newSortBy = ""; + switch (sortBy) { + case "date_asc": { + newSortBy = "date_desc"; + break; + } + case "date_desc": { + newSortBy = "date_asc"; + break; + } + case "alp_asc": { + newSortBy = "alp_desc"; + break; + } + case "alp_desc": { + newSortBy = "alp_asc"; + break; + } + default: { + newSortBy = "date_desc"; + break; + } + } + + dispatch(setSortBy(newSortBy)); + }; + + const switchTypeOrderBy = (e: React.ChangeEvent) => { + const value = e.target.value; + + let newSortBy = "date_desc"; + + if (value === "date") { + if (sortBy.includes("asc")) { + newSortBy = "date_asc"; + } else { + newSortBy = "date_desc"; + } + } else if (value === "name") { + if (sortBy.includes("asc")) { + newSortBy = "alp_asc"; + } else { + newSortBy = "alp_desc"; + } + } + + dispatch(setSortBy(newSortBy)); + }; + + const title = (() => { + if (isTrash) { + return "Trash"; + } else if (isSearch) { + return "Search"; + } else if (folders?.length === 0) { + return "No Folders"; + } else if (isHome) { + return "Home Folders"; + } else { + return "Folders"; + } + })(); + + return ( +
+ {!isHome && ( + +
+ +
+
+ )} +
+

+ {title} +

+
+ + + + + + + + +
+
+ +
+ {folders?.map((folder) => ( + + ))} +
+
+ ); + } +); + +export default Folders; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000..983884d --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,75 @@ +import { useNavigate } from "react-router-dom"; +import SearchBar from "../SearchBar/SearchBar"; +import MenuIcon from "../../icons/MenuIcon"; +import { useAppDispatch, useAppSelector } from "../../hooks/store"; +import { closeDrawer, openDrawer } from "../../reducers/leftSection"; +import { useUtils } from "../../hooks/utils"; +import ChevronOutline from "../../icons/ChevronOutline"; +import SettingsIconSolid from "../../icons/SettingsIconSolid"; + +const Header = () => { + const drawerOpen = useAppSelector((state) => state.leftSection.drawOpen); + const { isSettings } = useUtils(); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + const openDrawerClick = () => { + dispatch(openDrawer()); + }; + + const closeDrawerClick = () => { + dispatch(closeDrawer()); + }; + + return ( + + ); +}; + +export default Header; diff --git a/src/components/Homepage/Homepage.tsx b/src/components/Homepage/Homepage.tsx new file mode 100644 index 0000000..9347d48 --- /dev/null +++ b/src/components/Homepage/Homepage.tsx @@ -0,0 +1,27 @@ +import Header from "../Header/Header"; +import MainSection from "../MainSection/MainSection"; +import Uploader from "../Uploader/Uploader"; +import { useAppSelector } from "../../hooks/store"; +import { ToastContainer } from "react-toastify"; + +const Homepage = () => { + const showUploader = useAppSelector( + (state) => state.uploader.uploads.length !== 0 + ); + + return ( +
+
+
+
+ + {showUploader && } +
+
+ + +
+ ); +}; + +export default Homepage; diff --git a/src/components/LandingPage/LandingPage.css b/src/components/LandingPage/LandingPage.css new file mode 100644 index 0000000..cb497ad --- /dev/null +++ b/src/components/LandingPage/LandingPage.css @@ -0,0 +1,1344 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + height: 100%; + overflow-x: hidden; + scroll-behavior: smooth; +} + +body { + height: 100%; + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + overflow-x: hidden; + overflow-y: auto; + background: #ffffff; + color: #2d3748; +} + +#root { + height: 100%; +} + +/* Main Layout */ +.landing-page { + min-height: 100vh; + background: #ffffff; + color: #2d3748; + position: relative; + line-height: 1.6; + overflow: visible; +} + + +.main-content { + position: relative; + z-index: 1; + margin-top: 80px; /* Match header height */ +} + +.landing-page { + min-height: 100vh; + position: relative; +} + + + +/* Header */ +.header { + background: #ffffff; + padding: 20px 40px; + display: flex; + justify-content: space-between; + align-items: center; + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + box-shadow: 0 2px 20px rgba(0, 0, 0, 0.08); + height: 80px; + border-bottom: 1px solid #e2e8f0; +} + +.header .logo { + color: #3182ce; + font-weight: 800; + font-size: 28px; + letter-spacing: -0.5px; +} + +.header-buttons { + display: flex; + gap: 15px; +} + +.btn-primary, .btn-secondary { + padding: 12px 24px; + border: none; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + font-size: 14px; + transition: all 0.3s ease; + font-family: inherit; +} + +.btn-secondary { + background: transparent; + color: #4a5568; + border: 2px solid #cbd5e0; +} + +.btn-secondary:hover { + border-color: #3182ce; + color: #3182ce; + transform: translateY(-1px); +} + +.btn-primary { + background: #3182ce; + color: white; + box-shadow: 0 2px 10px rgba(49, 130, 206, 0.3); +} + +.btn-primary:hover { + background: #2c5aa0; + transform: translateY(-1px); + box-shadow: 0 4px 15px rgba(49, 130, 206, 0.4); +} + +/* Hero Section - UPDATED TO MOVE CONTENT UP */ +.hero-section { + display: flex; + gap: 60px; + align-items: flex-start; /* CHANGED from center to flex-start */ + padding: 120px 40px 60px; /* CHANGED padding to move content up */ + max-width: 1400px; + margin: 0 auto; + min-height: auto; /* CHANGED from 100vh to auto */ +} +.hero-content { + flex: 1; + max-width: 600px; +} + +.hero-title { + font-size: 64px; + font-weight: 800; + line-height: 1.1; + margin-bottom: 30px; + letter-spacing: -1px; + color: #1a202c; +} + +.highlight { + color: #3182ce; + background: linear-gradient(135deg, #3182ce, #2c5aa0); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + font-weight: 800; +} + +.hero-description { + font-size: 20px; + line-height: 1.7; + margin-bottom: 40px; + color: #4a5568; + font-weight: 400; +} + +.hero-stats { + display: flex; + gap: 50px; + margin-bottom: 40px; +} + +.stat { + text-align: left; +} + +.stat-number { + font-size: 32px; + font-weight: 800; + margin-bottom: 8px; + color: #3182ce; +} + +.stat-label { + font-size: 16px; + color: #718096; + font-weight: 500; +} + +.divider { + height: 3px; + background: linear-gradient(90deg, #3182ce 0%, transparent 100%); + margin: 40px 0; + width: 120px; + border-radius: 2px; +} + +.cta-container h2 { + font-size: 28px; + margin-bottom: 12px; + font-weight: 700; + color: #1a202c; +} + +.cta-subtitle { + font-size: 18px; + margin-bottom: 30px; + color: #718096; + font-weight: 500; +} + +.cta-btn { + background: #3182ce; + color: white; + border: none; + padding: 18px 40px; + font-size: 18px; + border-radius: 12px; + cursor: pointer; + font-weight: 600; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(49, 130, 206, 0.3); + font-family: inherit; +} + +.cta-btn:hover { + background: #2c5aa0; + transform: translateY(-3px); + box-shadow: 0 8px 25px rgba(49, 130, 206, 0.4); +} + +.cta-btn.large { + padding: 20px 50px; + font-size: 20px; +} + +.cta-btn.primary { + background: #3182ce; +} + +.cta-note { + margin-top: 15px; + font-size: 16px; + color: #718096; + font-weight: 500; +} + +/* Hero Visual */ +.hero-visual { + flex: 1; + display: flex; + justify-content: center; + align-items: center; +} + +.storage-preview { + width: 100%; + max-width: 480px; +} + +.preview-card { + background: #f7fafc; + border: 1px solid #e2e8f0; + border-radius: 20px; + padding: 35px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease; +} + +.preview-card:hover { + transform: translateY(-8px); + box-shadow: 0 30px 60px rgba(0, 0, 0, 0.12); +} + +.preview-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; +} + +.preview-header h3 { + font-size: 20px; + font-weight: 700; + color: #1a202c; +} + +.storage-usage { + font-size: 14px; + color: #718096; + font-weight: 600; + background: #e2e8f0; + padding: 6px 12px; + border-radius: 20px; +} + +.file-categories { + display: flex; + flex-direction: column; + gap: 20px; +} + +.category-item { + display: flex; + align-items: center; + gap: 15px; + padding: 18px; + background: white; + border-radius: 14px; + border: 1px solid #e2e8f0; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.category-item:hover { + border-color: #3182ce; + transform: translateX(8px); + box-shadow: 0 8px 20px rgba(49, 130, 206, 0.15); +} + +.category-icon { + font-size: 22px; + width: 45px; + height: 45px; + display: flex; + align-items: center; + justify-content: center; + background: #ebf8ff; + border-radius: 10px; + flex-shrink: 0; +} + +.category-info { + flex: 1; + min-width: 0; +} + +.category-name { + font-weight: 600; + font-size: 16px; + margin-bottom: 6px; + color: #2d3748; +} + +.category-size { + font-size: 14px; + color: #718096; + font-weight: 500; +} + +.category-bar { + position: absolute; + bottom: 0; + left: 0; + height: 4px; + background: linear-gradient(90deg, #3182ce, #2c5aa0); + border-radius: 0 0 2px 2px; + transition: width 0.3s ease; +} + +/* Sections */ +.section { + padding: 120px 40px; + width: 100%; + overflow: visible; +} + +.section-header { + text-align: center; + margin-bottom: 80px; +} + +.section-header h2 { + font-size: 48px; + margin-bottom: 20px; + font-weight: 800; + color: #1a202c; + letter-spacing: -0.5px; +} + +.section-header p { + font-size: 20px; + color: #718096; + font-weight: 400; + max-width: 600px; + margin: 0 auto; +} + +/* Features Section */ +.features-section { + background: #f7fafc; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 40px; + max-width: 1200px; + margin: 0 auto; +} + +.feature-card { + text-align: center; + padding: 60px 30px; + background: white; + border-radius: 20px; + border: 1px solid #e2e8f0; + transition: all 0.3s ease; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.02); +} + +.feature-card:hover { + transform: translateY(-12px); + border-color: #3182ce; + box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15); +} + +.feature-icon { + font-size: 56px; + margin-bottom: 30px; +} + +.feature-card h3 { + font-size: 24px; + margin-bottom: 20px; + font-weight: 700; + color: #2d3748; +} + +.feature-card p { + color: #718096; + line-height: 1.7; + font-size: 17px; +} + +/* Timeline Section */ +.timeline-section { + background: white; +} + +.timeline-steps { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 40px; + max-width: 1200px; + margin: 0 auto; +} + +.timeline-step { + text-align: center; + padding: 60px 30px; + background: #f7fafc; + border-radius: 20px; + border: 1px solid #e2e8f0; + transition: all 0.3s ease; +} + +.timeline-step:hover { + transform: translateY(-12px); + background: white; + border-color: #3182ce; + box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15); +} + +.step-icon { + font-size: 56px; + margin-bottom: 30px; +} + +.timeline-step h3 { + font-size: 24px; + margin-bottom: 20px; + font-weight: 700; + color: #2d3748; +} + +.timeline-step p { + color: #718096; + line-height: 1.7; + font-size: 17px; +} + +/* File Types Section */ +.file-types-section { + background: #f7fafc; +} + +.file-types-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 30px; + max-width: 1200px; + margin: 0 auto; +} + +.file-type-card { + text-align: center; + padding: 50px 30px; + background: white; + border-radius: 20px; + border: 1px solid #e2e8f0; + transition: all 0.3s ease; +} + +.file-type-card:hover { + transform: translateY(-10px); + background: white; + border-color: #3182ce; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); +} + +.file-icon { + font-size: 56px; + margin-bottom: 25px; +} + +.file-type-card h4 { + font-size: 22px; + margin-bottom: 18px; + font-weight: 700; + color: #2d3748; +} + +.file-type-card p { + color: #718096; + line-height: 1.7; + font-size: 16px; +} + +/* Use Cases Section */ +.use-cases-section { + background: white; +} + +.use-cases-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 40px; + max-width: 1200px; + margin: 0 auto; +} + +.use-case-card { + padding: 50px 35px; + background: #f7fafc; + border-radius: 20px; + border: 1px solid #e2e8f0; + transition: all 0.3s ease; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.02); + position: relative; +} + +.use-case-card:hover { + transform: translateY(-12px); + border-color: #3182ce; + box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15); +} + +.use-case-card h3 { + font-size: 26px; + margin-bottom: 20px; + font-weight: 700; + color: #2d3748; +} + +.use-case-card > p { + color: #718096; + margin-bottom: 25px; + font-size: 17px; + line-height: 1.6; + font-weight: 500; +} + +.use-case-card ul { + list-style: none; + text-align: left; +} + +.use-case-card ul li { + margin-bottom: 12px; + padding-left: 30px; + position: relative; + color: #718096; + line-height: 1.6; + font-size: 16px; +} + +.use-case-card ul li:before { + content: "✓"; + position: absolute; + left: 0; + color: #3182ce; + font-weight: bold; + font-size: 18px; +} + +/* Pricing Section */ +.pricing-section { + background: #f7fafc; +} + +.pricing-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 30px; + max-width: 1200px; + margin: 0 auto; +} + +.pricing-card { + padding: 50px 35px; + background: white; + border-radius: 20px; + border: 2px solid #e2e8f0; + transition: all 0.3s ease; + text-align: center; + position: relative; +} + +.pricing-card.featured { + border-color: #3182ce; + transform: scale(1.05); + box-shadow: 0 20px 40px rgba(49, 130, 206, 0.15); +} + +.pricing-badge { + position: absolute; + top: -12px; + left: 50%; + transform: translateX(-50%); + background: #3182ce; + color: white; + padding: 8px 20px; + border-radius: 20px; + font-size: 14px; + font-weight: 600; +} + +.pricing-header { + margin-bottom: 30px; +} + +.pricing-header h3 { + font-size: 24px; + margin-bottom: 15px; + font-weight: 700; + color: #2d3748; +} + +.price { + font-size: 48px; + font-weight: 800; + color: #3182ce; +} + +.price span { + font-size: 18px; + color: #718096; + font-weight: 500; +} + +.pricing-card ul { + list-style: none; + margin-bottom: 40px; + text-align: left; +} + +.pricing-card ul li { + margin-bottom: 15px; + padding-left: 30px; + position: relative; + color: #718096; + font-size: 16px; +} + +.pricing-card ul li:before { + content: "✓"; + position: absolute; + left: 0; + color: #3182ce; + font-weight: bold; + font-size: 18px; +} + +/* Final CTA */ +.final-cta { + text-align: center; + padding: 120px 40px; + background: linear-gradient(135deg, #1a202c, #2d3748); + color: black; +} + +.final-cta h2 { + font-size: 52px; + margin-bottom: 20px; + font-weight: 800; + color: #3182ce; +} + +.final-cta p { + font-size: 20px; + margin-bottom: 40px; + color: #cbd5e0; + max-width: 600px; + margin-left: auto; + margin-right: auto; +} + +.final-cta .cta-btn { + background: #3182ce; + color: white; +} + +.final-cta .cta-btn:hover { + background: #2c5aa0; +} + +.final-cta .cta-note { + color: #a0aec0; +} + +/* Footer */ +.footer { + background: #1a202c; + padding: 80px 40px 40px; + color: white; +} + +.footer-content { + display: flex; + justify-content: space-between; + align-items: flex-start; + max-width: 1200px; + margin: 0 auto 50px; + gap: 80px; +} + +.footer-brand { + flex: 1; +} + +.footer-brand .logo { + font-size: 28px; + font-weight: 800; + margin-bottom: 15px; + color: #bee3f8; +} + +.footer-brand p { + opacity: 0.8; + font-size: 16px; + margin-bottom: 8px; + line-height: 1.6; +} + +.footer-tagline { + margin-top: 15px !important; + font-style: italic; + opacity: 0.7; +} + +.footer-links { + display: flex; + gap: 80px; +} + +.footer-column h4 { + margin-bottom: 25px; + font-size: 18px; + color: #bee3f8; + font-weight: 600; +} + +.footer-column ul { + list-style: none; +} + +.footer-column ul li { + margin-bottom: 15px; + cursor: pointer; + opacity: 0.8; + transition: all 0.3s ease; + font-size: 15px; +} + +.footer-column ul li:hover { + opacity: 1; + color: #bee3f8; + transform: translateX(5px); +} + +.footer-bottom { + text-align: center; + padding-top: 40px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + max-width: 1200px; + margin: 0 auto; +} + +.footer-bottom p { + margin-bottom: 10px; + font-size: 15px; + opacity: 0.7; +} + +/* Ensure all content is visible */ +.landing-page * { + box-sizing: border-box; +} + +/* Make sure sections don't get cut off */ +.section:nth-child(even) { + background: #f7fafc; +} + +.section:nth-child(odd) { + background: white; +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .hero-section { + flex-direction: column; + text-align: center; + padding: 140px 30px 80px; + gap: 60px; + } + + .hero-title { + font-size: 52px; + } + + .hero-stats { + justify-content: center; + } + + .divider { + margin: 40px auto; + } + + .footer-content { + gap: 60px; + } + + .footer-links { + gap: 60px; + } +} + +@media (max-width: 768px) { + .header { + padding: 15px 20px; + height: 70px; + } + + .header .logo { + font-size: 24px; + } + + .hero-section { + padding: 120px 20px 60px; + } + + .hero-title { + font-size: 42px; + } + + .hero-description { + font-size: 18px; + } + + .hero-stats { + flex-direction: column; + gap: 25px; + } + + .section { + padding: 80px 20px; + } + + .section-header h2 { + font-size: 36px; + } + + .section-header p { + font-size: 18px; + } + + .features-grid, + .timeline-steps, + .use-cases-grid, + .file-types-grid, + .pricing-grid { + grid-template-columns: 1fr; + gap: 30px; + } + + .feature-card, + .timeline-step, + .use-case-card, + .file-type-card, + .pricing-card { + padding: 40px 25px; + } + + .pricing-card.featured { + transform: none; + } + + .footer-content { + flex-direction: column; + text-align: center; + gap: 50px; + } + + .footer-links { + flex-direction: column; + gap: 40px; + } +} +/* ... all your existing CSS above ... */ + +@media (max-width: 480px) { + .header { + flex-direction: column; + height: auto; + padding: 15px 20px; + } + + .header-buttons { + margin-top: 10px; + width: 100%; + justify-content: center; + } +} + +/* ========== MOVE ALL THESE STYLES OUTSIDE THE MEDIA QUERY ========== */ + +/* NEW: Integrations Section */ +.integrations-section { + background: #f7fafc; +} + +.integrations-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 30px; + max-width: 1200px; + margin: 0 auto; +} + +.integration-card { + text-align: center; + padding: 40px 25px; + background: white; + border-radius: 16px; + border: 2px dashed #e2e8f0; + transition: all 0.3s ease; +} + +.integration-card.placeholder { + opacity: 0.7; + background: #f8fafc; +} + +.integration-card:hover { + transform: translateY(-5px); + border-color: #3182ce; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); +} + +.integration-icon { + font-size: 48px; + margin-bottom: 20px; + opacity: 0.6; +} + +.integration-card h4 { + font-size: 20px; + margin-bottom: 15px; + font-weight: 600; + color: #2d3748; +} + +.integration-card p { + color: #718096; + line-height: 1.6; + font-size: 15px; +} + +/* NEW: Testimonials Section */ +.testimonials-section { + background: white; +} + +.testimonials-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 30px; + max-width: 1200px; + margin: 0 auto; +} + +.testimonial-card { + padding: 40px 30px; + background: #f7fafc; + border-radius: 16px; + border: 1px solid #e2e8f0; + transition: all 0.3s ease; +} + +.testimonial-card.placeholder { + opacity: 0.6; + border-style: dashed; + background: #fafbfc; +} + +.testimonial-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); +} + +.testimonial-content p { + color: #718096; + line-height: 1.7; + font-size: 16px; + font-style: italic; + text-align: center; +} + +/* NEW: Roadmap Section */ +.roadmap-section { + background: #f7fafc; +} + +.roadmap-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 30px; + max-width: 1200px; + margin: 0 auto; +} + +.roadmap-item { + padding: 35px 25px; + background: white; + border-radius: 16px; + border: 1px solid #e2e8f0; + transition: all 0.3s ease; + text-align: center; +} + +.roadmap-item.placeholder { + opacity: 0.7; + border-style: dashed; + background: #f8fafc; +} + +.roadmap-item:hover { + transform: translateY(-5px); + border-color: #3182ce; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); +} + +.roadmap-item h4 { + font-size: 20px; + margin-bottom: 15px; + font-weight: 600; + color: #2d3748; +} + +.roadmap-item p { + color: #718096; + line-height: 1.6; + font-size: 15px; +} + +/* Security Section */ +.security-section { + background: white; +} + +.security-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 30px; + max-width: 1200px; + margin: 0 auto; +} + +.security-card { + text-align: center; + padding: 40px 25px; + background: #f7fafc; + border-radius: 16px; + border: 1px solid #e2e8f0; + transition: all 0.3s ease; +} + +.security-card:hover { + transform: translateY(-5px); + border-color: #3182ce; + box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1); +} + +.security-icon { + font-size: 48px; + margin-bottom: 20px; +} + +.security-card h3 { + font-size: 20px; + margin-bottom: 15px; + font-weight: 600; + color: #2d3748; +} + +.security-card p { + color: #718096; + line-height: 1.6; + font-size: 15px; +} + +/* Sharing Section */ +.sharing-section { + background: #f7fafc; +} + +.sharing-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 30px; + max-width: 1200px; + margin: 0 auto; +} + +.sharing-card { + text-align: center; + padding: 40px 25px; + background: white; + border-radius: 16px; + border: 1px solid #e2e8f0; + transition: all 0.3s ease; +} + +.sharing-card:hover { + transform: translateY(-5px); + border-color: #3182ce; + box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1); +} + +.sharing-icon { + font-size: 48px; + margin-bottom: 20px; +} + +.sharing-card h3 { + font-size: 20px; + margin-bottom: 15px; + font-weight: 600; + color: #2d3748; +} + +.sharing-card p { + color: #718096; + line-height: 1.6; + font-size: 15px; +} + +/* Global Section */ +.global-section { + background: white; +} + +.global-content { + max-width: 1200px; + margin: 0 auto; +} + +.countries-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 30px; + margin-bottom: 60px; +} + +.country-item { + text-align: center; + padding: 30px 20px; + background: #f7fafc; + border-radius: 12px; + border: 1px solid #e2e8f0; + transition: all 0.3s ease; +} + +.country-item:hover { + transform: translateY(-3px); + border-color: #3182ce; +} + +.flag { + font-size: 36px; + margin-bottom: 15px; +} + +.country-item h4 { + font-size: 18px; + margin-bottom: 8px; + font-weight: 600; + color: #2d3748; +} + +.country-item p { + color: #718096; + font-size: 14px; +} + +.contact-info { + text-align: center; +} + +.contact-card { + background: linear-gradient(135deg, #3182ce, #2c5aa0); + color: white; + padding: 50px 40px; + border-radius: 20px; + max-width: 600px; + margin: 0 auto; +} + +.contact-card h3 { + font-size: 28px; + margin-bottom: 30px; + font-weight: 700; +} + +.phone-numbers { + display: flex; + flex-direction: column; + gap: 15px; + margin-bottom: 25px; +} + +.phone-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 20px; + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; + font-size: 16px; +} + +.country { + font-weight: 600; +} + +.number { + font-family: 'Courier New', monospace; + font-weight: 700; +} + +.support-hours { + opacity: 0.9; + font-size: 14px; + margin-top: 20px; +} + +/* Compliance Section */ +.compliance-section { + background: #f7fafc; +} + +.compliance-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 30px; + max-width: 1200px; + margin: 0 auto; +} + +.compliance-item { + text-align: center; + padding: 40px 25px; + background: white; + border-radius: 16px; + border: 1px solid #e2e8f0; + transition: all 0.3s ease; +} + +.compliance-item:hover { + transform: translateY(-5px); + box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1); +} + +.compliance-badge { + display: inline-block; + background: #3182ce; + color: white; + padding: 10px 20px; + border-radius: 25px; + font-weight: 700; + font-size: 18px; + margin-bottom: 20px; +} + +.compliance-item p { + color: #718096; + line-height: 1.6; + font-size: 15px; +} + +/* Responsive design for new sections */ +@media (max-width: 768px) { + .integrations-grid, + .testimonials-grid, + .roadmap-grid, + .security-grid, + .sharing-grid, + .compliance-grid { + grid-template-columns: 1fr; + gap: 20px; + } + + .countries-grid { + grid-template-columns: repeat(2, 1fr); + gap: 20px; + } + + .integration-card, + .testimonial-card, + .roadmap-item, + .security-card, + .sharing-card, + .compliance-item { + padding: 30px 20px; + } + + .contact-card { + padding: 30px 20px; + } + + .phone-item { + flex-direction: column; + gap: 5px; + text-align: center; + } +} + +@media (max-width: 480px) { + .countries-grid { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/src/components/LandingPage/LandingPage.tsx b/src/components/LandingPage/LandingPage.tsx new file mode 100644 index 0000000..ca01a7e --- /dev/null +++ b/src/components/LandingPage/LandingPage.tsx @@ -0,0 +1,573 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import './LandingPage.css'; + +const LandingPage = () => { + const navigate = useNavigate(); + + return ( +
+ {/* Header */} +
+
🌊 FlowSync
+
+ + +
+
+ + {/* Main Content */} +
+ {/* Hero Section */} +
+
+

Find anything.
Protect everything.

+

+ Find, organize, and protect your work with FlowSpace. Store, share, and collaborate with enterprise-grade security and seamless access across all your devices. +

+ + {/* UPDATED STATISTICS */} +
+
+
15GB
+
Free Storage
+
+
+
100%
+
Uptime
+
+
+
256-bit
+
Encryption
+
+
+ +
+ +
+

Start with 15GB Free

+

No credit card required

+ +
Free forever • No commitment
+
+
+ +
+
+
+
+

Storage Overview

+
15GB • 100% Secure
+
+
+
+
📁
+
+
Project Documents
+
2.4 GB
+
+
+
+ +
+
🖼️
+
+
Design Assets
+
1.8 GB
+
+
+
+ +
+
📊
+
+
Reports
+
866 MB
+
+
+
+ +
+
📹
+
+
Videos
+
4.2 GB
+
+
+
+
+
+
+
+
+ + {/* Features Section */} +
+
+

🌟 Why Choose FlowSpace?

+

Enterprise-grade features for everyone

+
+
+
+
+

Lightning Fast

+

Upload and access your files instantly with our optimized global CDN

+
+
+
🔐
+

Bank-Level Security

+

256-bit encryption keeps your files safe from unauthorized access

+
+ +
+
👥
+

Smart Collaboration

+

Share files securely with advanced permission controls

+
+
+
+ + {/* Timeline Section */} +
+
+

🚀 How FlowSpace Works

+

Simple, secure, and seamless file management in 4 easy steps

+
+
+
+
📥
+

Upload

+

Drag & drop or click to upload instantly from any device

+
+
+
🔒
+

Secure

+

Automatic encryption the moment your files arrive

+
+
+
🔄
+

Sync

+

Available across all your devices in seconds

+
+
+
👥
+

Share

+

Collaborate securely with anyone, anywhere

+
+
+
+ + {/* File Types Section */} +
+
+

📁 Smart File Management

+

Optimized for every file type you work with

+
+
+
+
🖼️
+

Photos & Videos

+

Automatic thumbnail generation and instant preview

+
+
+
📄
+

Documents

+

Text search within PDFs, DOCs, and presentations

+
+
+
🎵
+

Audio Files

+

Built-in audio player with playlist support

+
+
+
📁
+

Any File Type

+

Universal support with smart organization

+
+
+
+ + {/* Use Cases Section */} +
+
+

🎯 Perfect for Everyone

+

Tailored solutions for different needs

+
+
+
+

🎨 For Creatives

+

Designers, photographers, and video editors

+
    +
  • High-resolution previews
  • +
  • Version history for iterations
  • +
  • Client sharing with watermarks
  • +
  • Large file support up to 10GB
  • +
+
+
+

👥 For Teams

+

Businesses and remote collaborators

+
    +
  • Real-time collaboration
  • +
  • Advanced permission controls
  • +
  • Activity tracking and audit logs
  • +
  • Team management dashboard
  • +
+
+
+

🏠 For Personal Use

+

Students, families, and individuals

+
    +
  • Simple, intuitive interface
  • +
  • Automatic mobile backup
  • +
  • Family sharing options
  • +
  • Cross-platform compatibility
  • +
+
+
+
+ + {/* NEW: Integration Section */} +
+
+

🔌 Integrations

+

Connect with your favorite tools and platforms

+
+
+
+
📊
+

Coming Soon

+

More integrations with popular productivity tools

+
+
+
🤖
+

AI Features

+

Smart organization and search powered by AI

+
+
+
🔄
+

API Access

+

Build custom integrations with our developer API

+
+
+
+ + {/* NEW: Testimonials Section */} +
+
+

💬 What Our Users Say

+

Join thousands of satisfied users worldwide

+
+
+
+
+

"Space for future customer testimonials and success stories..."

+
+
+
+
+

"Area for user feedback and case studies..."

+
+
+
+
+ + {/* NEW: Roadmap Section */} + {/*
+
+

🛣️ Our Roadmap

+

Exciting features coming soon

+
+
+
+

Mobile App Updates

+

Enhanced mobile experience with new features

+
+
+

Advanced Analytics

+

Detailed insights into your storage usage

+
+
+

Team Management

+

Better tools for team collaboration

+
+
+
*/} + + {/* NEW: Security & Trust Section */} +
+
+

🔒 Enterprise-Grade Security

+

Your files are protected with military-grade encryption and zero-knowledge architecture

+
+
+
+
🛡️
+

Zero-Knowledge Encryption

+

We never have access to your encryption keys. Only you can decrypt and view your files.

+
+
+
🔐
+

End-to-End Encryption

+

Files are encrypted on your device before upload and remain encrypted during storage and transfer.

+
+
+
🌐
+

SOC 2 Compliant

+

Our infrastructure meets rigorous security standards for data protection and availability.

+
+ {/*
+
📊
+

Activity Monitoring

+

Real-time monitoring and alerting for suspicious activities with detailed audit logs.

+
*/} +
+
+ + {/* NEW: Advanced Sharing Features */} +
+
+

🚀 Advanced Sharing & Collaboration

+

Share files securely with granular control and real-time collaboration

+
+
+
+
+

Expiring Links

+

Set automatic expiration dates for shared links to maintain control over your content.

+
+
+
🔑
+

Password Protection

+

Add an extra layer of security with password-protected sharing links.

+
+ {/*
+
👁️
+

Download Controls

+

Choose whether recipients can download files or only view them online.

+
*/} +
+
📈
+

Share Analytics

+

Track how your shared files are being accessed and by whom.

+
+
+
+ + {/* NEW: Global Presence & Support */} +
+
+

🌍 Trusted Worldwide

+

Supporting users and businesses across the globe with local expertise

+
+
+
+
+
🇪🇬
+

Egypt

+

Cairo Data Center

+
+
+
🇨🇦
+

Canada

+

Toronto & Montreal

+
+
+
🇺🇸
+

United States

+

Multiple locations

+
+
+
🇸🇦
+

Saudi Arabia

+

Riyadh Data Center

+
+
+
🇦🇪
+

UAE

+

Dubai Infrastructure

+
+ {/*
+
🇬🇧
+

United Kingdom

+

London Hub

+
*/} +
+ +
+
+

📞 Contact Our Team

+
+
+ Syria: + +963 937 689 736 +
+ +
+ Canada: + +1 514 506 4526 +
+ +
+

24/7 Customer Support • Available in Multiple Languages

+
+
+
+
+ + {/* NEW: Compliance & Certifications */} + {/*
+
+

🏆 Industry Compliance

+

Meeting the highest standards for data protection and privacy

+
+
+
+
GDPR
+

Fully compliant with EU General Data Protection Regulation

+
+
+
CCPA
+

California Consumer Privacy Act compliance

+
+
+
HIPAA
+

Ready for healthcare data protection requirements

+
+
+
ISO 27001
+

International security management certification

+
+
+
*/} + + {/* Pricing Section */} +
+
+

💰 Simple, Transparent Pricing

+

No hidden fees. No surprises.

+
+
+
+
+

Free

+
$0/month
+
+
    +
  • ✓ 15GB Storage
  • +
  • ✓ Basic Security
  • +
  • ✓ 2 Devices
  • +
  • ✓ File Sharing
  • +
  • ✓ 30-day History
  • +
+ +
+
+
Most Popular
+
+

Pro

+
$9/month
+
+
    +
  • ✓ 500GB Storage
  • +
  • ✓ Advanced Security
  • +
  • ✓ 5 Devices
  • +
  • ✓ Team Collaboration
  • +
  • ✓ 1-year History
  • +
+ +
+
+
+

Business

+
$25/month
+
+
    +
  • ✓ 3TB Storage
  • +
  • ✓ Enterprise Security
  • +
  • ✓ Unlimited Devices
  • +
  • ✓ Admin Controls
  • +
  • ✓ Unlimited History
  • +
+ +
+
+
+ + {/* Final CTA */} +
+

Ready to Get Started?

+

Join thousands of users who trust FlowSpace with their files

+ +
No credit card required • Free 15GB forever
+
+ + {/* Footer */} +
+
+
+
🌊 FlowSpace
+

Innovating the future of digital storage and collaboration

+

Secure. Simple. Seamless.

+
+
+
+

Product

+
    +
  • Features
  • +
  • Pricing
  • +
  • Security
  • +
  • Integrations
  • +
+
+
+

Company

+
    +
  • About Us
  • +
  • Careers
  • +
  • Blog
  • +
  • Contact
  • +
+
+
+

Support

+
    +
  • Help Center
  • +
  • Community
  • +
  • Status
  • +
  • Documentation
  • +
+
+
+

Legal

+
    +
  • Privacy Policy
  • +
  • Terms of Service
  • +
  • Cookie Policy
  • +
  • GDPR
  • +
+
+
+
+
+

FlowSpace - A product of Ghaymah Company

+

Innovating digital storage and collaboration since 2024

+

© 2024 All rights reserved

+
+
+
+
+ ); +}; + +export default LandingPage; \ No newline at end of file diff --git a/src/components/LeftSection/LeftSection.tsx b/src/components/LeftSection/LeftSection.tsx new file mode 100644 index 0000000..034cbc9 --- /dev/null +++ b/src/components/LeftSection/LeftSection.tsx @@ -0,0 +1,189 @@ +import { useCallback, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useClickOutOfBounds, useUtils } from "../../hooks/utils"; +import AddNewDropdown from "../AddNewDropdown/AddNewDropdown"; +import TrashIcon from "../../icons/TrashIcon"; +import classNames from "classnames"; +import PhotoIcon from "../../icons/PhotoIcon"; +import { useAppDispatch, useAppSelector } from "../../hooks/store"; +import { closeDrawer } from "../../reducers/leftSection"; +import SettingsIcon from "../../icons/SettingsIcon"; +import ChevronSolid from "../../icons/ChevronSolid"; +import HomeIconOutline from "../../icons/HomeIconOutline"; +import { addNavigationMap } from "../../reducers/selected"; + +const LeftSection = ({ + scrollDivRef, +}: { + scrollDivRef: React.RefObject; +}) => { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const leftSectionOpen = useAppSelector((state) => state.leftSection.drawOpen); + const { isHome, isHomeFolder, isTrash, isMedia, isSettings } = useUtils(); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const addNewDisabled = useRef(false); + const closeDropdownDisabled = useRef(true); + + const openDropdown = () => { + if (addNewDisabled.current) return; + addNewDisabled.current = true; + closeDropdownDisabled.current = true; + setIsDropdownOpen(true); + setTimeout(() => (closeDropdownDisabled.current = false), 300); + }; + + const closeDropdown = useCallback(() => { + if (closeDropdownDisabled.current) return; + addNewDisabled.current = true; + closeDropdownDisabled.current = true; + setIsDropdownOpen(false); + + // Clicking out of bounds on the add new button will cause it to reopen + setTimeout(() => (addNewDisabled.current = false), 300); + }, []); + + const goHome = () => { + dispatch(closeDrawer()); + dispatch( + addNavigationMap({ + url: window.location.pathname, + scrollTop: scrollDivRef.current?.scrollTop || 0, + }) + ); + navigate("/home"); + }; + + const goTrash = () => { + dispatch(closeDrawer()); + dispatch( + addNavigationMap({ + url: window.location.pathname, + scrollTop: scrollDivRef.current?.scrollTop || 0, + }) + ); + navigate("/trash"); + }; + + const goMedia = () => { + dispatch(closeDrawer()); + dispatch( + addNavigationMap({ + url: window.location.pathname, + scrollTop: scrollDivRef.current?.scrollTop || 0, + }) + ); + navigate("/media"); + }; + + const goSettings = () => { + dispatch(closeDrawer()); + dispatch( + addNavigationMap({ + url: window.location.pathname, + scrollTop: scrollDivRef.current?.scrollTop || 0, + }) + ); + navigate("/settings"); + }; + + const closeDrawerEvent = useCallback( + (e: MouseEvent | TouchEvent) => { + if (!leftSectionOpen) return; + + const target = e.target as HTMLElement; + + const idsToIgnore = ["search-bar", "menu-icon", "header"]; + + if (!target || idsToIgnore.includes(target.id)) { + return; + } + + dispatch(closeDrawer()); + }, + [leftSectionOpen] + ); + + const { wrapperRef } = useClickOutOfBounds(closeDrawerEvent, leftSectionOpen); + + return ( +
+
+ + +
+ +

Home

+
+ +
+ +

Media

+
+ +
+ +

Settings

+
+ +
+ +

Trash

+
+
+
+ ); +}; + +export default LeftSection; diff --git a/src/components/LoginPage/LoginPage.tsx b/src/components/LoginPage/LoginPage.tsx new file mode 100644 index 0000000..6bd4372 --- /dev/null +++ b/src/components/LoginPage/LoginPage.tsx @@ -0,0 +1,379 @@ +import { useEffect, useRef, useState } from "react"; +import { + createAccountAPI, + getUserAPI, + loginAPI, + sendPasswordResetAPI, +} from "../../api/userAPI"; +import { useLocation, useNavigate } from "react-router-dom"; +import { setUser } from "../../reducers/user"; +import { useAppDispatch } from "../../hooks/store"; +import capitalize from "lodash/capitalize"; +import AlertIcon from "../../icons/AlertIcon"; +import Spinner from "../Spinner/Spinner"; +import { toast, ToastContainer } from "react-toastify"; +import Swal from "sweetalert2"; +import { AxiosError } from "axios"; +import isEmail from "validator/es/lib/isEmail"; + +// ADD THE defaultView PROP HERE +const LoginPage = ({ defaultView = "login" }) => { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [verifyPassword, setVerifyPassword] = useState(""); + const [mode, setMode] = useState<"login" | "create" | "reset">("login"); + const [attemptingLogin, setAttemptingLogin] = useState(true); + const [loadingLogin, setLoadingLogin] = useState(false); + const [error, setError] = useState(""); + const dispatch = useAppDispatch(); + const location = useLocation(); + const navigate = useNavigate(); + const lastSentPassowordReset = useRef(0); + + const attemptLoginWithToken = async () => { + setAttemptingLogin(true); + + try { + const userResponse = await getUserAPI(); + + const redirectPath = location.state?.from?.pathname || "/home"; + dispatch(setUser(userResponse)); + navigate(redirectPath); + window.localStorage.setItem("hasPreviouslyLoggedIn", "true"); + } catch (e) { + setAttemptingLogin(false); + if (window.localStorage.getItem("hasPreviouslyLoggedIn")) { + setError("Login Expired"); + window.localStorage.removeItem("hasPreviouslyLoggedIn"); + } + } + }; + + const login = async () => { + try { + setLoadingLogin(true); + const loginResponse = await loginAPI(email, password); + window.localStorage.setItem("hasPreviouslyLoggedIn", "true"); + dispatch(setUser(loginResponse)); + navigate("/home"); + setLoadingLogin(false); + } catch (e) { + if ( + e instanceof AxiosError && + [400, 401].includes(e.response?.status || 0) + ) { + setError("Incorrect email or password"); + } else { + setError("Login Error"); + } + console.log("Login Error", e); + setLoadingLogin(false); + } + }; + + const createAccount = async () => { + try { + setLoadingLogin(true); + const createAccountResponse = await createAccountAPI(email, password); + window.localStorage.setItem("hasPreviouslyLoggedIn", "true"); + + if (createAccountResponse.emailSent) { + toast.success("Email Verification Sent"); + } + + dispatch(setUser(createAccountResponse.user)); + navigate("/home"); + setLoadingLogin(false); + } catch (e) { + if (e instanceof AxiosError && e.response?.status === 409) { + setError("Email Already Exists"); + } else if (e instanceof AxiosError && e.response?.status === 400) { + setError("Validation Error"); + } else { + setError("Create Account Error"); + } + console.log("Create Account Error", e); + setLoadingLogin(false); + } + }; + + const resetPassword = async () => { + try { + const currentDate = Date.now(); + if (currentDate - lastSentPassowordReset.current < 1000 * 60 * 1) { + await Swal.fire({ + title: "Please wait 1 minute before resending", + icon: "warning", + confirmButtonColor: "#3085d6", + confirmButtonText: "Okay", + }); + return; + } + + lastSentPassowordReset.current = Date.now(); + + setLoadingLogin(true); + + await toast.promise(sendPasswordResetAPI(email), { + pending: "Sending password reset...", + success: "Password Reset Sent", + error: "Error Sending Password Reset", + }); + + setLoadingLogin(false); + } catch (e) { + console.log("Create Account Error", e); + setLoadingLogin(false); + if (e instanceof AxiosError && e.response?.status === 404) { + setError("Email does not exist"); + } else if (e instanceof AxiosError && e.response?.status === 403) { + setError("Email Verification Not Enabled"); + } else { + setError("Create Account Failed"); + } + } + }; + + const isSubmitDisabled = (() => { + switch (mode) { + case "login": + return !email || !password; + case "create": + return !email || !password || !verifyPassword; + case "reset": + return !email; + default: + return false; + } + })(); + + const onSubmit = (e: any) => { + e.preventDefault(); + if (mode === "login") { + login(); + } else if (mode === "create") { + createAccount(); + } else if (mode === "reset") { + resetPassword(); + } + }; + + const headerTitle = (() => { + switch (mode) { + case "login": + return "Login to your account"; + case "create": + return "Create an account"; + case "reset": + return "Reset Password"; + default: + return "Login to your account"; + } + })(); + + const validationError = (() => { + if (mode === "login" || mode === "reset") return ""; + + if (mode === "create") { + if (password.length) { + if (password.length < 6) { + return "Password must be at least 6 characters"; + } else if (password.length > 256) { + return "Password must be less than 256 characters"; + } + } + + if ( + password.length && + verifyPassword.length && + password !== verifyPassword + ) { + return "Passwords do not match"; + } + + if (email.length) { + const isValidEmail = isEmail(email); + + if (email.length < 3) { + return "Email must be at least 3 characters"; + } else if (email.length > 320) { + return "Email must be less than 320 characters"; + } else if (!isValidEmail) { + return "Email is invalid"; + } + } + } + + return ""; + })(); + + useEffect(() => { + setError(""); + }, [email.length, password.length, verifyPassword.length]); + + useEffect(() => { + const loggedIn = window.localStorage.getItem("hasPreviouslyLoggedIn"); + if (loggedIn === "true") { + attemptLoginWithToken(); + } else { + setAttemptingLogin(false); + // SET THE MODE BASED ON THE defaultView PROP + if (defaultView === "create") { + setMode("create"); + } else { + setMode("login"); + } + } + }, [attemptLoginWithToken, defaultView]); + + if (attemptingLogin) { + return ( +
+
+ +

Loading...

+
+
+ ); + } + + return ( +
+ {/* Header matching landing page */} +
+
FlowSync
+
+ +
+
+ + {/* Main Content */} +
+
+
+
+
+

+ {headerTitle} +

+
+ +
+ {/* Email Address */} +
+ setEmail(e.target.value)} + value={email} + /> +
+ + {/* Password */} + {(mode === "login" || mode === "create") && ( +
+ setPassword(e.target.value)} + value={password} + /> + {mode === "login" && ( +
+ +
+ )} +
+ )} + + {/* Verify Password */} + {mode === "create" && ( + setVerifyPassword(e.target.value)} + value={verifyPassword} + /> + )} + + {/* Submit Button */} +
+ +
+ + {/* Mode Switch Links */} +
+ {mode === "login" && ( +

+ Don't have an account?{" "} + +

+ )} + {(mode === "create" || mode === "reset") && ( +

+ Back to{" "} + +

+ )} +
+ + {/* Error Message */} + {(validationError || error) && ( +
+
+ +

+ {validationError || error} +

+
+
+ )} +
+
+
+
+
+ +
+ ); +}; + +export default LoginPage; \ No newline at end of file diff --git a/src/components/MainSection/MainSection.tsx b/src/components/MainSection/MainSection.tsx new file mode 100644 index 0000000..7d72d94 --- /dev/null +++ b/src/components/MainSection/MainSection.tsx @@ -0,0 +1,59 @@ +import DataForm from "../Dataform/Dataform"; +import RightSection from "../RightSection/RightSection"; +import { memo, useRef } from "react"; +import LeftSection from "../LeftSection/LeftSection"; +import { useUtils } from "../../hooks/utils"; +import Medias from "../Medias/Medias"; +import { useAppSelector } from "../../hooks/store"; +import PhotoViewerPopup from "../PhotoViewerPopup/PhotoViewerPopup"; +import FileInfoPopup from "../FileInfoPopup/FileInfoPopup"; +import SharePopup from "../SharePopup/SharePopup"; +import MoverPopup from "../MoverPopup/MoverPopup"; + +const MainSection = memo(() => { + const popupModalItem = useAppSelector( + (state) => state.selected.popupModal.file + ); + const shareModalItem = useAppSelector( + (state) => state.selected.shareModal.file + ); + const moveModalItemType = useAppSelector( + (state) => state.selected.moveModal.type + ); + const scrollDivRef = useRef(null); + + const isMediaSelected = + popupModalItem?.metadata.isVideo || popupModalItem?.metadata.hasThumbnail; + const isFileInfoSelected = !isMediaSelected && popupModalItem; + + const { isMedia } = useUtils(); + return ( +
+
+ {isMediaSelected && ( + + )} + + {isFileInfoSelected && } + + {shareModalItem && } + + {moveModalItemType && } + +
+ + + {!isMedia ? ( + + ) : ( + + )} + + +
+
+
+ ); +}); + +export default MainSection; diff --git a/src/components/MediaItem/MediaItem.tsx b/src/components/MediaItem/MediaItem.tsx new file mode 100644 index 0000000..8f8af24 --- /dev/null +++ b/src/components/MediaItem/MediaItem.tsx @@ -0,0 +1,128 @@ +import React, { memo, useRef, useState } from "react"; +import { FileInterface } from "../../types/file"; +import { useAppDispatch, useAppSelector } from "../../hooks/store"; +import { + setMainSelect, + setMultiSelectMode, + setPopupSelect, +} from "../../reducers/selected"; +import mobilecheck from "../../utils/mobileCheck"; +import classNames from "classnames"; +import { useContextMenu } from "../../hooks/contextMenu"; +import ContextMenu from "../ContextMenu/ContextMenu"; +import PlayButtonIcon from "../../icons/PlayIcon"; +import getBackendURL from "../../utils/getBackendURL"; +import AlertIcon from "../../icons/AlertIcon"; + +type MediaItemType = { + file: FileInterface; +}; + +const MediaItem: React.FC = memo(({ file }) => { + const [thumbnailError, setThumbnailError] = useState(false); + const elementSelected = useAppSelector((state) => { + if (state.selected.mainSection.type !== "file") return false; + return state.selected.mainSection.id === file._id; + }); + const elementMultiSelected = useAppSelector((state) => { + if (!state.selected.multiSelectMode) return false; + const selected = state.selected.multiSelectMap[file._id]; + return selected && selected.type === "file"; + }); + const multiSelectMode = useAppSelector( + (state) => state.selected.multiSelectMode + ); + const { + onContextMenu, + closeContextMenu, + onTouchStart, + onTouchMove, + onTouchEnd, + clickStopPropagation, + ...contextMenuState + } = useContextMenu(); + const lastSelected = useRef(0); + const dispatch = useAppDispatch(); + const thumbnail = `${getBackendURL()}/file-service/thumbnail/${ + file.metadata.thumbnailID + }`; + + // TODO: See if we can memoize this and remove any + const mediaItemClick = (e: React.MouseEvent) => { + const multiSelectKey = e.metaKey || e.ctrlKey; + if (multiSelectMode || multiSelectKey) { + dispatch( + setMultiSelectMode([ + { + type: "file", + id: file._id, + file: file, + folder: null, + }, + ]) + ); + return; + } + const currentDate = Date.now(); + + if (!elementSelected) { + // dispatch(startSetSelectedItem(file._id, true, true)); + dispatch( + setMainSelect({ file, id: file._id, type: "file", folder: null }) + ); + lastSelected.current = Date.now(); + return; + } + + const isMobile = mobilecheck(); + + if (isMobile || currentDate - lastSelected.current < 1500) { + dispatch(setPopupSelect({ type: "file", file })); + } + + lastSelected.current = Date.now(); + }; + + return ( +
+ {contextMenuState.selected && ( +
+ +
+ )} + {file.metadata.isVideo && !thumbnailError && ( +
+ +
+ )} + {!thumbnailError && ( + setThumbnailError(true)} + /> + )} + {thumbnailError && ( +
+ +
+ )} +
+ ); +}); + +export default MediaItem; diff --git a/src/components/Medias/Medias.tsx b/src/components/Medias/Medias.tsx new file mode 100644 index 0000000..9cd174a --- /dev/null +++ b/src/components/Medias/Medias.tsx @@ -0,0 +1,171 @@ +import classNames from "classnames"; +import React, { memo, useEffect, useRef, useState } from "react"; +import MediaItem from "../MediaItem/MediaItem"; +import { useFiles } from "../../hooks/files"; +import MultiSelectBar from "../MultiSelectBar/MultiSelectBar"; +import { useInfiniteScroll } from "../../hooks/infiniteScroll"; +import { useAppDispatch, useAppSelector } from "../../hooks/store"; +import { setMediaFilter, setSortBy } from "../../reducers/filter"; +import Spinner from "../Spinner/Spinner"; +import { removeNavigationMap } from "../../reducers/selected"; + +const Medias = memo( + ({ scrollDivRef }: { scrollDivRef: React.RefObject }) => { + const { + data: files, + fetchNextPage: filesFetchNextPage, + isFetchingNextPage: isFetchingNextPageState, + isLoading: isLoadingFiles, + } = useFiles(); + const [initialLoad, setInitialLoad] = useState(true); + const { sentinelRef, reachedIntersect } = useInfiniteScroll(); + const sortBy = useAppSelector((state) => state.filter.sortBy); + const mediaFilter = useAppSelector((state) => state.filter.mediaFilter); + const navigationMap = useAppSelector((state) => { + return state.selected.navigationMap[window.location.pathname]; + }); + const isFetchingNextPage = useRef(false); + + const dispatch = useAppDispatch(); + + useEffect(() => { + if (initialLoad) { + setInitialLoad(false); + return; + } else if (!files || isFetchingNextPage.current) { + return; + } + if (reachedIntersect && !isLoadingFiles) { + isFetchingNextPage.current = true; + filesFetchNextPage().then(() => { + isFetchingNextPage.current = false; + }); + } + }, [reachedIntersect, initialLoad, isLoadingFiles]); + + useEffect(() => { + if (!initialLoad && navigationMap) { + scrollDivRef.current?.scrollTo(0, navigationMap.scrollTop); + dispatch(removeNavigationMap(window.location.pathname)); + } + }, [initialLoad, navigationMap, window.location.pathname]); + + const switchOrderSortBy = () => { + let newSortBy = ""; + switch (sortBy) { + case "date_asc": { + newSortBy = "date_desc"; + break; + } + case "date_desc": { + newSortBy = "date_asc"; + break; + } + case "alp_asc": { + newSortBy = "alp_desc"; + break; + } + case "alp_desc": { + newSortBy = "alp_asc"; + break; + } + default: { + newSortBy = "date_desc"; + break; + } + } + + dispatch(setSortBy(newSortBy)); + }; + + const mediaFilterOnChange = (e: React.ChangeEvent) => { + const value = e.target.value; + + dispatch(setMediaFilter(value)); + }; + + const title = (() => { + if (mediaFilter === "all") { + return "Photos and Videos"; + } else if (mediaFilter === "photos") { + return "Photos"; + } else if (mediaFilter === "videos") { + return "Videos"; + } + })(); + + return ( +
+
+

{title}

+
+ + + + + + + + +
+
+ {!isLoadingFiles && ( +
+
+ +
+ {files?.pages.map((filePage, index) => ( + + {filePage.map((file) => ( + + ))} + + ))} +
+ )} + {isLoadingFiles && ( +
+ +
+ )} + {isFetchingNextPageState && ( +
+ +
+ )} + {/* @ts-ignore */} +
+
+ ); + } +); + +export default Medias; diff --git a/src/components/MoverPopup/MoverPopup.tsx b/src/components/MoverPopup/MoverPopup.tsx new file mode 100644 index 0000000..f653dff --- /dev/null +++ b/src/components/MoverPopup/MoverPopup.tsx @@ -0,0 +1,301 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useAppDispatch, useAppSelector } from "../../hooks/store"; +import { useFolders, useMoveFolders } from "../../hooks/folders"; +import { FolderInterface } from "../../types/folders"; +import CloseIcon from "../../icons/CloseIcon"; +import { resetMoveModal } from "../../reducers/selected"; +import debounce from "lodash/debounce"; +import Spinner from "../Spinner/Spinner"; +import HomeIconOutline from "../../icons/HomeIconOutline"; +import ArrowBackIcon from "../../icons/ArrowBackIcon"; +import classNames from "classnames"; +import FolderIcon from "../../icons/FolderIcon"; +import { toast } from "react-toastify"; +import { moveFileAPI, moveMultiAPI } from "../../api/filesAPI"; +import { useFiles } from "../../hooks/files"; +import { moveFolderAPI } from "../../api/foldersAPI"; + +const MoverPopup = () => { + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [parent, setParent] = useState(null); + const [parentList, setParentList] = useState([]); + const [selectedFolder, setSelectedFolder] = useState( + null + ); + const [animate, setAnimate] = useState(false); + const multiSelectMode = useAppSelector( + (state) => state.selected.multiSelectMode + ); + const multiSelectMap = useAppSelector( + (state) => state.selected.multiSelectMap + ); + const [isLoadingMove, setIsLoadingMove] = useState(false); + const file = useAppSelector((state) => state.selected.moveModal.file); + const folder = useAppSelector((state) => state.selected.moveModal.folder); + const dispatch = useAppDispatch(); + const { refetch: refetchFiles } = useFiles(false); + const { refetch: refetchFolders } = useFolders(false); + const lastSelected = useRef({ + timestamp: 0, + folderID: "", + }); + + const foldersToMove = useAppSelector((state) => { + if (state.selected.multiSelectMode) { + return Object.keys(state.selected.multiSelectMap); + } else { + return [state.selected.mainSection.id]; + } + }); + + const { data: folderList, isLoading: isLoadingFolders } = useMoveFolders( + parent?._id || "/", + debouncedSearch, + foldersToMove + ); + + const debouncedSetSearchText = useMemo( + () => debounce(setDebouncedSearch, 500), + [] + ); + + useEffect(() => { + debouncedSetSearchText(search); + return () => { + debouncedSetSearchText.cancel(); + }; + }, [search, debouncedSetSearchText]); + + const onFolderClick = (folder: FolderInterface) => { + const currentDate = Date.now(); + + if ( + lastSelected.current.folderID === folder._id && + currentDate - lastSelected.current.timestamp < 1500 + ) { + setSearch(""); + setDebouncedSearch(""); + setParentList([...parentList, folder]); + setParent(folder); + setSelectedFolder(null); + } else { + setSelectedFolder(folder); + } + + lastSelected.current.timestamp = Date.now(); + lastSelected.current.folderID = folder._id; + }; + + const onBackClick = () => { + if (!parent) return; + setSearch(""); + setDebouncedSearch(""); + const newParentList = parentList.slice(0, parentList.length - 1); + if (newParentList.length === 0) { + setParent(null); + setParentList([]); + } else { + setParentList(newParentList); + setParent(newParentList[newParentList.length - 1]); + } + }; + + const moveText = (() => { + if (selectedFolder?._id && selectedFolder?.name) { + return `Move to ${selectedFolder.name}`; + } else if (!parent) { + return "Move to home"; + } else { + const lastParent = parentList[parentList.length - 1]; + return `Move to ${lastParent.name}`; + } + })(); + + const headerText = (() => { + if (parent) { + return parent.name; + } else { + return "Home"; + } + })(); + + useEffect(() => { + setAnimate(true); + }, []); + + const onHomeClick = () => { + setSearch(""); + setDebouncedSearch(""); + setParent(null); + setParentList([]); + setSelectedFolder(null); + }; + + const onMoveClick = async () => { + setIsLoadingMove(true); + const moveTo = selectedFolder?._id + ? selectedFolder?._id + : parent?._id || "/"; + try { + if (multiSelectMode) { + const itemsToMove = Object.values(multiSelectMap); + await toast.promise(moveMultiAPI(itemsToMove, moveTo), { + pending: "Moving items...", + success: "Items Moved", + error: "Error Moving Items", + }); + refetchFiles(); + refetchFolders(); + dispatch(resetMoveModal()); + } else if (file) { + await toast.promise(moveFileAPI(file._id, moveTo), { + pending: "Moving File...", + success: "File Moved", + error: "Error Moving File", + }); + refetchFiles(); + dispatch(resetMoveModal()); + } else if (folder) { + await toast.promise(moveFolderAPI(folder._id, moveTo), { + pending: "Moving Folder...", + success: "Folder Moved", + error: "Error Moving Folder", + }); + refetchFolders(); + dispatch(resetMoveModal()); + } + console.log("move to", moveTo); + } catch (e) { + console.log("move error", e); + } finally { + setIsLoadingMove(false); + } + }; + + const onTitleClick = () => { + setSelectedFolder(parentList[parentList.length - 1]); + }; + + const closeModal = () => { + setAnimate(false); + setTimeout(() => dispatch(resetMoveModal()), 200); + }; + + const wrapperClick = (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (target.id !== "outer-wrapper") return; + closeModal(); + }; + + return ( +
+
+
+ +
+
+
+
+ + {!multiSelectMode && ( + setSearch(e.target.value)} + /> + )} + +
+
+

+ {headerText} +

+
+
+ {!isLoadingFolders && ( + + {folderList?.map((folder: FolderInterface) => ( +
onFolderClick(folder)} + > + +

+ {folder.name} +

+
+ ))} +
+ )} + {isLoadingFolders && ( +
+ +
+ )} +
+
+

+ {moveText} +

+
+
+ +
+
+
+ ); +}; + +export default MoverPopup; diff --git a/src/components/MultiSelectBar/MultiSelectBar.tsx b/src/components/MultiSelectBar/MultiSelectBar.tsx new file mode 100644 index 0000000..a1bdc63 --- /dev/null +++ b/src/components/MultiSelectBar/MultiSelectBar.tsx @@ -0,0 +1,207 @@ +import React, { useCallback, useEffect } from "react"; +import { useAppDispatch, useAppSelector } from "../../hooks/store"; +import { resetMultiSelect, setMoveModal } from "../../reducers/selected"; +import { + deleteMultiAPI, + restoreMultiAPI, + trashMultiAPI, +} from "../../api/filesAPI"; +import { useFiles, useQuickFiles } from "../../hooks/files"; +import TrashIcon from "../../icons/TrashIcon"; +import Moveicon from "../../icons/MoveIcon"; +import { + deleteItemsPopup, + restoreItemsPopup, + trashItemsPopup, +} from "../../popups/file"; +import RestoreIcon from "../../icons/RestoreIcon"; +import { useUtils } from "../../hooks/utils"; +import { toast } from "react-toastify"; +import DownloadIcon from "../../icons/DownloadIcon"; +import { downloadZIPAPI } from "../../api/foldersAPI"; +import CloseIcon from "../../icons/CloseIcon"; +import { useLocation } from "react-router-dom"; +import { useFolders } from "../../hooks/folders"; + +const MultiSelectBar: React.FC = () => { + const dispatch = useAppDispatch(); + // const ignoreFirstMount = useRef(true); + const multiSelectMode = useAppSelector( + (state) => state.selected.multiSelectMode + ); + const multiSelectMap = useAppSelector( + (state) => state.selected.multiSelectMap + ); + const multiSelectCount = useAppSelector( + (state) => state.selected.multiSelectCount + ); + const { refetch: refetchFiles } = useFiles(false); + const { refetch: refetchFolders } = useFolders(false); + const { refetch: refetchQuickFiles } = useQuickFiles(false); + + const { isTrash, isMedia } = useUtils(); + + const location = useLocation(); + + // useEffect(() => { + // if (ignoreFirstMount.current) { + // ignoreFirstMount.current = false; + // } else { + // closeMultiSelect(); + // } + // }, [isTrash]); + + const closeMultiSelect = useCallback(() => { + dispatch(resetMultiSelect()); + }, []); + + useEffect(() => { + closeMultiSelect(); + }, [location.pathname, closeMultiSelect]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape" || e.key === "Esc") { + closeMultiSelect(); + } + }, + [closeMultiSelect] + ); + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, []); + + const trashItems = async () => { + try { + const result = await trashItemsPopup(); + if (!result) return; + + const itemsToTrash = Object.values(multiSelectMap); + await toast.promise(trashMultiAPI(itemsToTrash), { + pending: "Trashing...", + success: "Trashed", + error: "Error Trashing", + }); + refetchFiles(); + refetchFolders(); + refetchQuickFiles(); + closeMultiSelect(); + } catch (e) { + console.log("Error Trashing Items", e); + } + }; + + const deleteItems = async () => { + try { + const result = await deleteItemsPopup(); + if (!result) return; + + const itesmsToDelete = Object.values(multiSelectMap); + await toast.promise(deleteMultiAPI(itesmsToDelete), { + pending: "Deleting...", + success: "Deleted", + error: "Error Deleting", + }); + refetchFiles(); + refetchFolders(); + refetchQuickFiles(); + closeMultiSelect(); + } catch (e) { + console.log("Error Deleting Items", e); + } + }; + + const restoreItems = async () => { + const result = await restoreItemsPopup(); + if (!result) return; + + const itemsToRestore = Object.values(multiSelectMap); + await toast.promise(restoreMultiAPI(itemsToRestore), { + pending: "Restoring...", + success: "Restored", + error: "Error Restoring", + }); + refetchFiles(); + refetchFolders(); + refetchQuickFiles(); + closeMultiSelect(); + }; + + const moveItems = () => { + dispatch(setMoveModal({ type: "multi-select", file: null, folder: null })); + }; + + const downloadItems = () => { + const folders = []; + const files = []; + + for (const key of Object.keys(multiSelectMap)) { + const item = multiSelectMap[key]; + if (item.type === "folder") { + folders.push(item.id); + } else { + files.push(item.id); + } + } + + downloadZIPAPI(folders, files); + }; + + if (!multiSelectMode) return
; + + return ( +
+
+
+
+ +

{multiSelectCount} selected

+
+ +
+ {!isTrash && ( + + + {!isMedia && ( + + )} + + + )} + {isTrash && ( + + + + + )} +
+
+
+
+ ); +}; + +export default MultiSelectBar; diff --git a/src/components/ParentBar/ParentBar.tsx b/src/components/ParentBar/ParentBar.tsx new file mode 100644 index 0000000..6acefcc --- /dev/null +++ b/src/components/ParentBar/ParentBar.tsx @@ -0,0 +1,91 @@ +import { memo } from "react"; +import { useNavigate } from "react-router-dom"; +import { useUtils } from "../../hooks/utils"; +import { useFolder } from "../../hooks/folders"; +import SpacerIcon from "../../icons/SpacerIcon"; +import ArrowBackIcon from "../../icons/ArrowBackIcon"; +import { useContextMenu } from "../../hooks/contextMenu"; +import ContextMenu from "../ContextMenu/ContextMenu"; + +const ParentBar = memo(() => { + const { data: folder } = useFolder(false); + const navigate = useNavigate(); + const { isHome, isTrash } = useUtils(); + const { + onContextMenu, + closeContextMenu, + onTouchStart, + onTouchMove, + onTouchEnd, + clickStopPropagation, + ...contextMenuState + } = useContextMenu(); + + if (isHome || !folder) { + return
; + } + + const goHomeOrTrash = () => { + if (!isTrash) { + navigate("/home"); + } else { + navigate("/trash"); + } + }; + + const goToFolder = () => { + navigate(`/folder/${folder?._id}`); + }; + + const goBackAFolder = () => { + if (folder?.parent === "/") { + navigate("/home"); + } else { + navigate(`/folder/${folder.parent}`); + } + }; + + return ( +
+ {contextMenuState.selected && ( +
+ +
+ )} + +
+
+ +
+ + {!isTrash ? "Home" : "Trash"} + + +

+ {folder.name} +

+
+
+ ); +}); + +export default ParentBar; diff --git a/src/components/PhotoViewerPopup/PhotoViewerPopup.tsx b/src/components/PhotoViewerPopup/PhotoViewerPopup.tsx new file mode 100644 index 0000000..a88f55e --- /dev/null +++ b/src/components/PhotoViewerPopup/PhotoViewerPopup.tsx @@ -0,0 +1,378 @@ +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { useAppDispatch, useAppSelector } from "../../hooks/store"; +import { deleteVideoTokenAPI, getVideoTokenAPI } from "../../api/filesAPI"; +import CloseIcon from "../../icons/CloseIcon"; +import ActionsIcon from "../../icons/ActionsIcon"; +import { useContextMenu } from "../../hooks/contextMenu"; +import ContextMenu from "../ContextMenu/ContextMenu"; +import { + resetPopupSelect, + setMainSelect, + setPopupSelect, +} from "../../reducers/selected"; +import CircleLeftIcon from "../../icons/CircleLeftIcon"; +import CircleRightIcon from "../../icons/CircleRightIcon"; +import { useFiles, useQuickFiles } from "../../hooks/files"; +import { FileInterface } from "../../types/file"; +import { InfiniteData } from "react-query"; +import { getFileColor, getFileExtension } from "../../utils/files"; +import Spinner from "../Spinner/Spinner"; +import { toast } from "react-toastify"; +import getBackendURL from "../../utils/getBackendURL"; +import classNames from "classnames"; + +interface PhotoViewerPopupProps { + file: FileInterface; +} + +const PhotoViewerPopup: React.FC = memo((props) => { + const { file } = props; + const [video, setVideo] = useState(""); + const [isVideoLoading, setIsVideoLoading] = useState(false); + const [isThumbnailLoading, setIsThumbnailLoading] = useState( + file.metadata.hasThumbnail && !file.metadata.isVideo + ); + const [thumbnailError, setThumbnailError] = useState(false); + const videoRef = useRef(null); + const type = useAppSelector((state) => state.selected.popupModal.type)!; + const thumbnailURL = `${getBackendURL()}/file-service/full-thumbnail/${ + file._id + }`; + const finalLastPageLoaded = useRef(false); + const loadingNextPage = useRef(false); + const { data: quickFiles } = useQuickFiles(false); + const { data: files, fetchNextPage } = useFiles(false); + const dispatch = useAppDispatch(); + const { + onContextMenu, + closeContextMenu, + onTouchStart, + onTouchMove, + onTouchEnd, + clickStopPropagation, + ...contextMenuState + } = useContextMenu(); + + const fileExtension = getFileExtension(file.filename, 3); + + const imageColor = getFileColor(file.filename); + + const getVideo = useCallback(async () => { + try { + setIsVideoLoading(true); + setVideo(""); + await getVideoTokenAPI(); + const videoURL = `${getBackendURL()}/file-service/stream-video/${ + file._id + }`; + setVideo(videoURL); + setIsVideoLoading(false); + } catch (e) { + console.log("Error getting video", e); + toast.error("Error getting video"); + } + }, [file._id]); + + const cleanUpVideo = useCallback(async () => { + if (!file.metadata.isVideo || !videoRef.current) return; + + deleteVideoTokenAPI(); + + videoRef.current.pause(); + videoRef.current.src = ""; + setVideo(""); + }, [file._id, deleteVideoTokenAPI]); + + const findPrevFilesItem = (newFiles?: InfiniteData) => { + if (newFiles) { + if (!newFiles?.pages) return 0; + const filesFiltered = newFiles.pages + .flat() + .filter( + (currentFile) => + currentFile.metadata.hasThumbnail || currentFile.metadata.isVideo + ); + const index = filesFiltered.findIndex( + (currentFile) => currentFile._id === file._id + ); + const prevItem = filesFiltered[index - 1]; + return prevItem; + } else { + if (!files?.pages) return 0; + const filesFiltered = files.pages + .flat() + .filter( + (currentFile) => + currentFile.metadata.hasThumbnail || currentFile.metadata.isVideo + ); + const index = filesFiltered.findIndex( + (currentFile) => currentFile._id === file._id + ); + const prevItem = filesFiltered[index - 1]; + return prevItem; + } + }; + + const goToPreviousItem = async () => { + if (type === "quick-item") { + if (!quickFiles?.length) return 0; + const filteredQuickFiles = quickFiles.filter( + (currentFile) => + currentFile.metadata.hasThumbnail || currentFile.metadata.isVideo + ); + const index = filteredQuickFiles.findIndex( + (currentFile) => currentFile._id === file._id + ); + const prevItem = filteredQuickFiles[index - 1]; + if (prevItem) { + dispatch(setPopupSelect({ type: "quick-item", file: prevItem })); + dispatch( + setMainSelect({ + file: prevItem, + id: prevItem._id, + type: "file", + folder: null, + }) + ); + } + } else { + if (!files?.pages) return 0; + const prevItem = findPrevFilesItem(); + if (prevItem) { + dispatch(setPopupSelect({ type: "file", file: prevItem })); + dispatch( + setMainSelect({ + file: prevItem, + id: prevItem._id, + type: "file", + folder: null, + }) + ); + } + // TODO: Perhaps implement this if needed in the future + // else { + // console.log("fetch prev"); + // const response = await fetchPreviousPage(); + // if (!response.data?.pages) return; + // const fetchedPrevItem = findPrevFilesItem(response.data); + // if (fetchedPrevItem) { + // dispatch(setPopupSelect({ type: "file", file: fetchedPrevItem })); + // } + // } + } + }; + + const findNextFilesItem = (newFiles?: InfiniteData) => { + if (newFiles) { + if (!newFiles?.pages) return 0; + const filesFiltered = newFiles.pages + .flat() + .filter( + (currentFile) => + currentFile.metadata.hasThumbnail || currentFile.metadata.isVideo + ); + const index = filesFiltered.findIndex( + (currentFile) => currentFile._id === file._id + ); + const nextItem = filesFiltered[index + 1]; + return nextItem; + } else { + if (!files?.pages) return 0; + const filesFiltered = files.pages + .flat() + .filter( + (currentFile) => + currentFile.metadata.hasThumbnail || currentFile.metadata.isVideo + ); + const index = filesFiltered.findIndex( + (currentFile) => currentFile._id === file._id + ); + const nextItem = filesFiltered[index + 1]; + return nextItem; + } + }; + + const goToNextItem = async () => { + if (type === "quick-item") { + if (!quickFiles?.length) return; + const filteredQuickFiles = quickFiles.filter( + (currentFile) => + currentFile.metadata.hasThumbnail || currentFile.metadata.isVideo + ); + const index = filteredQuickFiles.findIndex( + (currentFile) => currentFile._id === file._id + ); + const nextItem = filteredQuickFiles[index + 1]; + if (nextItem) { + dispatch(setPopupSelect({ type: "quick-item", file: nextItem })); + dispatch( + setMainSelect({ + file: nextItem, + id: nextItem._id, + type: "file", + folder: null, + }) + ); + } + } else { + if (!files?.pages) return; + const nextItem = findNextFilesItem(); + if (nextItem) { + dispatch(setPopupSelect({ type: "file", file: nextItem })); + dispatch( + setMainSelect({ + file: nextItem, + id: nextItem._id, + type: "file", + folder: null, + }) + ); + } else if (!finalLastPageLoaded.current && !loadingNextPage.current) { + loadingNextPage.current = true; + const newFilesResponse = await fetchNextPage(); + if (!newFilesResponse.data?.pages) return; + const fetchedNextItem = findNextFilesItem(newFilesResponse.data); + if (fetchedNextItem) { + dispatch(setPopupSelect({ type: "file", file: fetchedNextItem })); + dispatch( + setMainSelect({ + file: fetchedNextItem, + id: fetchedNextItem._id, + type: "file", + folder: null, + }) + ); + } else { + finalLastPageLoaded.current = true; + } + loadingNextPage.current = false; + } + } + }; + + const closePhotoViewer = () => { + dispatch(resetPopupSelect()); + }; + + const outterWrapperClick = (e: React.MouseEvent) => { + if ((e.target as HTMLDivElement).id !== "outer-wrapper") { + return; + } + closePhotoViewer(); + }; + + useEffect(() => { + if (file.metadata.isVideo) { + getVideo(); + } + + return () => { + cleanUpVideo(); + }; + }, [file.metadata.isVideo, getVideo, cleanUpVideo]); + + useEffect(() => { + const handleBack = () => { + closePhotoViewer(); + }; + window.addEventListener("popstate", handleBack); + + return () => { + window.removeEventListener("popstate", handleBack); + }; + }, []); + + return ( +
+ {contextMenuState.selected && ( +
+ +
+ )} + +
+
+ +
+ + {fileExtension} + +
+
+

+ {file.filename} +

+
+
+
+ +
+ +
+ +
+
+
+ + +
+ {isThumbnailLoading && !thumbnailError && } + {!file.metadata.isVideo && ( + setIsThumbnailLoading(false)} + onError={() => setThumbnailError(true)} + /> + )} + {thumbnailError && ( +
+

Error loading image

+
+ )} + {file.metadata.isVideo && !isVideoLoading && ( + + )} +
+
+ ); +}); + +export default PhotoViewerPopup; diff --git a/src/components/QuickAccess/QuickAccess.tsx b/src/components/QuickAccess/QuickAccess.tsx new file mode 100644 index 0000000..bface97 --- /dev/null +++ b/src/components/QuickAccess/QuickAccess.tsx @@ -0,0 +1,52 @@ +import QuickAccessItem from "../QuickAccessItem/QuickAccessItem"; +import { memo, useState } from "react"; +import { useQuickFiles } from "../../hooks/files"; +import classNames from "classnames"; +import { useUtils } from "../../hooks/utils"; +import ChevronOutline from "../../icons/ChevronOutline"; + +const QuickAccess = memo(() => { + const { data: quickfilesList } = useQuickFiles(false); + const [quickAccessExpanded, setQuickAccessExpanded] = useState(false); + const { isHome } = useUtils(); + + return ( +
+
+

Quick Access

+ setQuickAccessExpanded(!quickAccessExpanded)} + className={classNames( + "cursor-pointer animate-movement text-primary", + { + "rotate-180": quickAccessExpanded, + } + )} + /> +
+ +
+ {quickfilesList?.map((file) => ( + + ))} +
+
+ ); +}); + +export default QuickAccess; diff --git a/src/components/QuickAccessItem/QuickAccessItem.tsx b/src/components/QuickAccessItem/QuickAccessItem.tsx new file mode 100644 index 0000000..221f442 --- /dev/null +++ b/src/components/QuickAccessItem/QuickAccessItem.tsx @@ -0,0 +1,210 @@ +import capitalize from "../../utils/capitalize"; +import React, { memo, useMemo, useRef, useState } from "react"; +import ContextMenu from "../ContextMenu/ContextMenu"; +import classNames from "classnames"; +import { getFileColor, getFileExtension } from "../../utils/files"; +import { useContextMenu } from "../../hooks/contextMenu"; +import mobilecheck from "../../utils/mobileCheck"; +import { FileInterface } from "../../types/file"; +import { useAppDispatch, useAppSelector } from "../../hooks/store"; +import { + setMainSelect, + setMultiSelectMode, + setPopupSelect, +} from "../../reducers/selected"; +import PlayButtonIcon from "../../icons/PlayIcon"; +import dayjs from "dayjs"; +import getBackendURL from "../../utils/getBackendURL"; +import ClockIcon from "../../icons/ClockIcon"; + +interface QuickAccessItemProps { + file: FileInterface; +} + +const QuickAccessItem = memo((props: QuickAccessItemProps) => { + const { file } = props; + const elementSelected = useAppSelector((state) => { + if (state.selected.mainSection.type !== "quick-item") return false; + return state.selected.mainSection.id === file._id; + }); + const elementMultiSelected = useAppSelector((state) => { + if (!state.selected.multiSelectMode) return false; + const selected = state.selected.multiSelectMap[file._id]; + return selected && selected.type === "quick-item"; + }); + const multiSelectMode = useAppSelector( + (state) => state.selected.multiSelectMode + ); + const thumbnailURL = `${getBackendURL()}/file-service/thumbnail/${ + file.metadata.thumbnailID + }`; + const hasThumbnail = file.metadata.hasThumbnail; + const [thumbnailLoaded, setThumbnailLoaded] = useState(false); + const dispatch = useAppDispatch(); + const lastSelected = useRef(0); + + const { + onContextMenu, + closeContextMenu, + onTouchStart, + onTouchMove, + onTouchEnd, + clickStopPropagation, + ...contextMenuState + } = useContextMenu(); + + const fileExtension = getFileExtension(file.filename); + const imageColor = getFileColor(file.filename); + const formattedDate = useMemo( + () => dayjs(file.uploadDate).format("MM/DD/YY hh:mma"), + [file.uploadDate] + ); + const formattedFilename = capitalize(file.filename); + + const quickItemClick = (e: React.MouseEvent) => { + const multiSelectKey = e.metaKey || e.ctrlKey; + if (multiSelectMode || multiSelectKey) { + dispatch( + setMultiSelectMode([ + { + type: "quick-item", + id: file._id, + file: file, + folder: null, + }, + ]) + ); + return; + } + const currentDate = Date.now(); + + if (!elementSelected) { + // dispatch(startSetSelectedItem(file._id, true, true)); + dispatch( + setMainSelect({ file, id: file._id, type: "quick-item", folder: null }) + ); + lastSelected.current = Date.now(); + return; + } + + const isMobile = mobilecheck(); + + if (isMobile || currentDate - lastSelected.current < 1500) { + dispatch(setPopupSelect({ type: "quick-item", file })); + } + + lastSelected.current = Date.now(); + }; + + return ( +
+ {contextMenuState.selected && ( +
+ +
+ )} +
+ {hasThumbnail && ( +
+ setThumbnailLoaded(true)} + /> + {file.metadata.isVideo && ( +
+ +
+ )} +
+ )} + {!thumbnailLoaded && ( + <> + + + +
+

{fileExtension}

+
+ + )} +
+
+

+ {formattedFilename} +

+
+ +

+ {formattedDate} +

+
+
+
+ ); +}); + +export default QuickAccessItem; diff --git a/src/components/ResetPasswordPage/ResetPasswordPage.tsx b/src/components/ResetPasswordPage/ResetPasswordPage.tsx new file mode 100644 index 0000000..9f16947 --- /dev/null +++ b/src/components/ResetPasswordPage/ResetPasswordPage.tsx @@ -0,0 +1,123 @@ +import { useNavigate, useParams } from "react-router-dom"; +import Spinner from "../Spinner/Spinner"; +import { ToastContainer, toast } from "react-toastify"; +import { useState } from "react"; +import { resetPasswordAPI } from "../../api/userAPI"; +import AlertIcon from "../../icons/AlertIcon"; +import { AxiosError } from "axios"; + +const ResetPasswordPage = () => { + const token = useParams().token!; + const [password, setPassword] = useState(""); + const [verifyPassword, setVerifyPassword] = useState(""); + const [loadingLogin, setLoadingLogin] = useState(false); + const [error, setError] = useState(""); + const navigate = useNavigate(); + + const errorMessage = (() => { + if (password.length === 0) { + return ""; + } + + if (password.length < 6) { + return "Password must be at least 6 characters"; + } else if (password.length > 256) { + return "Password must be less than 256 characters"; + } + + if ( + password.length && + verifyPassword.length && + password !== verifyPassword + ) { + return "Passwords do not match"; + } + + return ""; + })(); + + const isSubmitDisabled = + !password.length || password !== verifyPassword || errorMessage; + + const onSubmit = async (e: any) => { + try { + e.preventDefault(); + setLoadingLogin(true); + await toast.promise(resetPasswordAPI(password, token), { + pending: "Resetting password...", + success: "Password Reset", + }); + setTimeout(() => { + navigate("/"); + }, 1500); + } catch (e) { + if (e instanceof AxiosError && e.response?.status === 401) { + setError("Invalid token error"); + } else { + setError("Reset Password Failed"); + } + console.log("Reset Password Error", e); + setLoadingLogin(false); + } + }; + + return ( +
+
+
+
+
+ {!loadingLogin && ( + logo + )} + {loadingLogin && } +
+
+
+

+ Reset password +

+ + setPassword(e.target.value)} + value={password} + /> + setVerifyPassword(e.target.value)} + value={verifyPassword} + /> + +
+ +
+ + {(error || errorMessage) && ( +
+
+ +

+ {error ? error : errorMessage} +

+
+
+ )} +
+
+
+ +
+ ); +}; + +export default ResetPasswordPage; diff --git a/src/components/RightSection/RightSection.tsx b/src/components/RightSection/RightSection.tsx new file mode 100644 index 0000000..33f326a --- /dev/null +++ b/src/components/RightSection/RightSection.tsx @@ -0,0 +1,261 @@ +import bytes from "bytes"; +import { memo, useMemo } from "react"; +import classNames from "classnames"; +import { useDispatch } from "react-redux"; +import { getFileColor, getFileExtension } from "../../utils/files"; +import { useAppSelector } from "../../hooks/store"; +import { resetSelected } from "../../reducers/selected"; +import CloseIcon from "../../icons/CloseIcon"; +import FileDetailsIcon from "../../icons/FileDetailsIcon"; +import dayjs from "dayjs"; +import DownloadIcon from "../../icons/DownloadIcon"; +import ShareIcon from "../../icons/ShareIcon"; +import TrashIcon from "../../icons/TrashIcon"; +import RenameIcon from "../../icons/RenameIcon"; +import { useActions } from "../../hooks/actions"; +import RestoreIcon from "../../icons/RestoreIcon"; + +const RightSection = memo(() => { + const selectedItem = useAppSelector((state) => state.selected.mainSection); + const dispatch = useDispatch(); + const { + renameItem, + trashItem, + deleteItem, + restoreItem, + openShareItemModal, + downloadItem, + } = useActions({ + quickItemMode: false, + }); + + const onAction = async ( + action: "rename" | "trash" | "delete" | "restore" | "download" | "share" + ) => { + const file = selectedItem.file; + const folder = selectedItem.folder; + + switch (action) { + case "rename": + await renameItem(file, folder); + break; + case "trash": + await trashItem(file, folder); + break; + case "delete": + await deleteItem(file, folder); + break; + case "restore": + await restoreItem(file, folder); + break; + case "download": + downloadItem(file, folder); + break; + case "share": + openShareItemModal(file); + } + }; + + const itemTrashed = + selectedItem?.file?.metadata.trashed || selectedItem?.folder?.trashed; + + const formattedName = useMemo(() => { + if (!selectedItem.id) return ""; + const name = selectedItem.file?.filename || selectedItem.folder?.name || ""; + const maxLength = 66; + const ellipsis = "..."; + if (name.length <= maxLength) { + return name; + } + + const startLength = Math.ceil((maxLength - ellipsis.length) / 2); + const endLength = Math.floor((maxLength - ellipsis.length) / 2); + + const start = name.slice(0, startLength); + const end = name.slice(-endLength); + + return `${start}${ellipsis}${end}`; + }, [ + selectedItem?.id, + selectedItem?.file?.filename, + selectedItem?.folder?.name, + ]); + + const formattedDate = useMemo(() => { + const date = + selectedItem.file?.uploadDate || selectedItem.folder?.createdAt; + return dayjs(date).format("MM/DD/YYYY"); + }, [selectedItem?.file?.uploadDate, selectedItem.folder?.createdAt]); + + const fileSize = bytes(selectedItem.file?.length || 0); + + const fileExtension = (() => { + if (!selectedItem?.file?.filename) return null; + return getFileExtension(selectedItem.file.filename); + })(); + + const reset = () => { + dispatch(resetSelected()); + }; + + const bannerBackgroundColor = (() => { + if (selectedItem.file) { + return getFileColor(selectedItem.file.filename); + } else if (selectedItem.folder) { + return "#3c85ee"; + } else { + return "#3c85ee"; + } + })(); + + const bannerText = (() => { + if (selectedItem.file) { + return getFileExtension(selectedItem.file.filename); + } else if (selectedItem.folder) { + return "Folder"; + } else { + return ""; + } + })(); + + return ( +
+ {selectedItem.id === "" ? ( +
+ +

+ Select a file or folder to view it’s details +

+
+ ) : ( +
+
+
+
+
+
+

{bannerText}

+
+ +
+
+

+ {formattedName} +

+
+ +
+

Information

+
+
+ + Type + + + {selectedItem.file ? fileExtension : "Folder"} + +
+
+ + Size + + + {fileSize} + +
+
+ + Created + + + {formattedDate} + +
+
+ + Access + + + {selectedItem.file?.metadata.link ? "Public" : "Private"} + +
+
+
+
+ + {/*
+
+ {!itemTrashed && ( +
onAction("download")} + > + +
+ )} + {selectedItem.file && !itemTrashed && ( +
onAction("share")} + > + +
+ )} + {itemTrashed && ( +
onAction("restore")} + > + +
+ )} +
onAction(itemTrashed ? "delete" : "trash")} + > + +
+ {!itemTrashed && ( +
onAction("rename")} + > + +
+ )} +
*/} +
+ )} +
+ ); +}); + +export default RightSection; diff --git a/src/components/SearchBar/SearchBar.tsx b/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000..94b4622 --- /dev/null +++ b/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,185 @@ +import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { useAppDispatch } from "../../hooks/store"; +import { useSearchSuggestions } from "../../hooks/files"; +import debounce from "lodash/debounce"; +import { useNavigate } from "react-router-dom"; +import { useClickOutOfBounds, useUtils } from "../../hooks/utils"; +import SearchBarItem from "../SearchBarItem/SearchBarItem"; +import { FolderInterface } from "../../types/folders"; +import { FileInterface } from "../../types/file"; +import classNames from "classnames"; +import { closeDrawer } from "../../reducers/leftSection"; +import { setPopupSelect } from "../../reducers/selected"; +import CloseIcon from "../../icons/CloseIcon"; +import Spinner from "../Spinner/Spinner"; +import SearchIcon from "../../icons/SearchIcon"; + +const SearchBar = memo(() => { + const [searchText, setSearchText] = useState(""); + const [showSuggestions, setShowSuggestions] = useState(false); + const dispatch = useAppDispatch(); + const [debouncedSearchText, setDebouncedSearchText] = useState(""); + const { data: searchSuggestions, isLoading: isLoadingSearchSuggestions } = + useSearchSuggestions(debouncedSearchText); + const navigate = useNavigate(); + const { isTrash, isMedia } = useUtils(); + + const debouncedSetSearchText = useMemo( + () => debounce(setDebouncedSearchText, 500), + [] + ); + + useEffect(() => { + debouncedSetSearchText(searchText); + return () => { + debouncedSetSearchText.cancel(); + }; + }, [searchText, debouncedSetSearchText]); + + const resetState = () => { + setSearchText(""); + setDebouncedSearchText(""); + }; + + const outOfContainerClick = useCallback(() => { + closeDrawer(); + setShowSuggestions(false); + }, []); + + const { wrapperRef } = useClickOutOfBounds(outOfContainerClick); + + const onSearch = (e: any) => { + e.preventDefault(); + setShowSuggestions(false); + if (isMedia) { + if (searchText.length) { + navigate(`/search-media/${searchText}`); + } else { + navigate("/media"); + } + } else if (isTrash) { + if (searchText.length) { + navigate(`/search-trash/${searchText}`); + } else { + navigate("/trash"); + } + } else { + if (searchText.length) { + navigate(`/search/${searchText}`); + } else { + navigate("/home"); + } + } + }; + + const onChangeSearch = (e: any) => { + setSearchText(e.target.value); + }; + + const fileClick = (file: FileInterface) => { + dispatch(setPopupSelect({ type: "file", file })); + resetState(); + }; + + const folderClick = (folder: FolderInterface) => { + if (!isTrash) { + navigate(`/folder/${folder?._id}`); + } else { + navigate(`/folder-trash/${folder?._id}`); + } + + resetState(); + }; + + const calculatedHeight = + 47 * + (searchSuggestions?.folderList.length + + searchSuggestions?.fileList.length) || 56; + + const onFocus = () => { + dispatch(closeDrawer()); + setShowSuggestions(true); + }; + + const searchTextPlaceholder = (() => { + if (isMedia) { + return "Search Media"; + } else if (isTrash) { + return "Search Trash"; + } else { + return "Search"; + } + })(); + + return ( +
+
+ {searchText.length !== 0 && !isLoadingSearchSuggestions && ( + + )} + {isLoadingSearchSuggestions &&
} + {searchText.length === 0 && } +
+ +
+ {searchSuggestions?.folderList.length === 0 && + searchSuggestions?.fileList.length === 0 ? ( +
+ No Results +
+ ) : undefined} + {searchSuggestions?.folderList.map((folder: FolderInterface) => ( + + ))} + {searchSuggestions?.fileList.map((file: FileInterface) => ( + + ))} +
+
+ ); +}); + +export default SearchBar; diff --git a/src/components/SearchBarItem/SearchBarItem.tsx b/src/components/SearchBarItem/SearchBarItem.tsx new file mode 100644 index 0000000..7b1d487 --- /dev/null +++ b/src/components/SearchBarItem/SearchBarItem.tsx @@ -0,0 +1,78 @@ +import { FileInterface } from "../../types/file"; +import { FolderInterface } from "../../types/folders"; +import { getFileColor, getFileExtension } from "../../utils/files"; + +interface SearchBarItemProps { + file?: FileInterface; + folder?: FolderInterface; + type: "file" | "folder"; + fileClick: (file: FileInterface) => void; + folderClick: (folder: FolderInterface) => void; +} + +const SearchBarItem = (props: SearchBarItemProps) => { + const { type, folder, file, fileClick, folderClick } = props; + + const fileExtension = file ? getFileExtension(file.filename, 3) : ""; + + const imageColor = file ? getFileColor(file.filename) : ""; + + if (type === "folder" && folder) { + return ( +
folderClick(folder)} + > +
+ +
+ + {folder.name} + +
+ ); + } else if (type === "file" && file) { + return ( +
fileClick(file)} + > +
+ +
+ + {fileExtension} + +
+
+
+ + {file.filename} + +
+ ); + } + return
; +}; + +export default SearchBarItem; diff --git a/src/components/SettingsPage/SettingsAccountSection.tsx b/src/components/SettingsPage/SettingsAccountSection.tsx new file mode 100644 index 0000000..ca8c92f --- /dev/null +++ b/src/components/SettingsPage/SettingsAccountSection.tsx @@ -0,0 +1,166 @@ +import React, { useRef, useState } from "react"; +import SettingsChangePasswordPopup from "./SettingsChangePasswordPopup"; +import { toast } from "react-toastify"; +import { logoutAPI, resendVerifyEmailAPI } from "../../api/userAPI"; +import Swal from "sweetalert2"; +import { useNavigate } from "react-router-dom"; + +interface SettingsPageAccountProps { + user: { + _id: string; + email: string; + emailVerified: boolean; + }; + getUser: () => void; +} + +const SettingsPageAccount: React.FC = ({ + user, + getUser, +}) => { + const [showChangePasswordPopup, setShowChangePasswordPopup] = useState(false); + const lastSentEmailVerifiation = useRef(0); + const navigate = useNavigate(); + + const logoutClick = async () => { + try { + const result = await Swal.fire({ + title: "Logout?", + icon: "info", + showCancelButton: true, + confirmButtonColor: "#3085d6", + cancelButtonColor: "#d33", + confirmButtonText: "Yes, logout", + }); + + if (!result.value) return; + + await toast.promise(logoutAPI(), { + pending: "Logging out...", + success: "Logged out", + error: "Error Logging Out", + }); + + window.localStorage.removeItem("hasPreviouslyLoggedIn"); + + navigate("/"); + } catch (e) { + console.log("Error logging out", e); + } + }; + + const logoutAllClick = async () => { + try { + const result = await Swal.fire({ + title: "Logout all?", + icon: "info", + showCancelButton: true, + confirmButtonColor: "#3085d6", + cancelButtonColor: "#d33", + confirmButtonText: "Yes, logout all", + }); + + if (!result.value) return; + + await toast.promise(logoutAPI(), { + pending: "Logging out all...", + success: "Logged out all", + error: "Error Logging Out all", + }); + + window.localStorage.removeItem("hasPreviouslyLoggedIn"); + + navigate("/"); + } catch (e) { + console.log("Error logging out", e); + } + }; + + const resendEmailVerification = async () => { + try { + const currentDate = Date.now(); + if (currentDate - lastSentEmailVerifiation.current < 1000 * 60 * 1) { + await Swal.fire({ + title: "Please wait 1 minute before resending", + icon: "warning", + confirmButtonColor: "#3085d6", + confirmButtonText: "Okay", + }); + return; + } + lastSentEmailVerifiation.current = Date.now(); + + await toast.promise(resendVerifyEmailAPI(), { + pending: "Resending email verification...", + success: "Email Verification Resent", + error: "Error Resending Email Verification", + }); + + getUser(); + } catch (e) { + console.log("Error resending email verification", e); + } + }; + + return ( +
+ {showChangePasswordPopup && ( + setShowChangePasswordPopup(false)} + /> + )} + +
+

Account

+
+
+
+

Email

+

{user.email}

+
+ {"emailVerified" in user && !user.emailVerified && ( +
+

Email not verified

+ {!user.emailVerified && ( + + )} +
+ )} +
+

Change password

+ +
+
+

Logout account

+ +
+
+

Logout all sessions

+ +
+
+
+ ); +}; + +export default SettingsPageAccount; diff --git a/src/components/SettingsPage/SettingsChangePasswordPopup.tsx b/src/components/SettingsPage/SettingsChangePasswordPopup.tsx new file mode 100644 index 0000000..77e4325 --- /dev/null +++ b/src/components/SettingsPage/SettingsChangePasswordPopup.tsx @@ -0,0 +1,158 @@ +import { useState } from "react"; +import CloseIcon from "../../icons/CloseIcon"; +import classNames from "classnames"; +import { toast } from "react-toastify"; +import { changePasswordAPI } from "../../api/userAPI"; +import { AxiosError } from "axios"; + +interface SettingsChangePasswordPopupProps { + closePopup: () => void; +} + +const SettingsChangePasswordPopup: React.FC< + SettingsChangePasswordPopupProps +> = ({ closePopup }) => { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [verifyNewPassword, setVerifyNewPassword] = useState(""); + const [loadingChangePassword, setLoadingChangePassword] = useState(false); + + const inputDisabled = (() => { + if ( + loadingChangePassword || + currentPassword.length === 0 || + newPassword.length === 0 || + verifyNewPassword.length === 0 + ) { + return true; + } + + if (newPassword !== verifyNewPassword) { + return true; + } + + if (newPassword.length < 6) { + return true; + } + + return false; + })(); + + const errorMessage = (() => { + if (newPassword.length === 0) { + return ""; + } + + if (newPassword.length < 6) { + return "Password must be at least 6 characters"; + } else if (newPassword.length > 256) { + return "Password must be less than 256 characters"; + } + + if ( + newPassword.length && + verifyNewPassword.length && + newPassword !== verifyNewPassword + ) { + return "Passwords do not match"; + } + + return ""; + })(); + + const submitPasswordChange = async (e: any) => { + e.preventDefault(); + setLoadingChangePassword(true); + try { + await toast.promise(changePasswordAPI(currentPassword, newPassword), { + pending: "Changing password...", + success: "Password Changed", + }); + closePopup(); + } catch (e) { + if (e instanceof AxiosError && e.response?.status === 401) { + toast.error("Incorrect password"); + } else { + toast.error("Error changing password"); + } + console.log("Error changing password", e); + } finally { + setLoadingChangePassword(false); + } + }; + + const outterWrapperClick = (e: any) => { + if (e.target.id !== "outer-wrapper") return; + closePopup(); + }; + + return ( +
+
+
+

Change password

+ +
+
+ + + + + + + {errorMessage && ( +
+

{errorMessage}

+
+ )} + +
+ +
+
+
+
+ ); +}; + +export default SettingsChangePasswordPopup; diff --git a/src/components/SettingsPage/SettingsGeneralSection.tsx b/src/components/SettingsPage/SettingsGeneralSection.tsx new file mode 100644 index 0000000..552eb53 --- /dev/null +++ b/src/components/SettingsPage/SettingsGeneralSection.tsx @@ -0,0 +1,165 @@ +import { useEffect, useState } from "react"; +import { usePreferenceSetter } from "../../hooks/preferenceSetter"; + +const SettingsPageGeneral = () => { + const [listViewStyle, setListViewStyle] = useState("list"); + const [sortBy, setSortBy] = useState("date"); + const [orderBy, setOrderBy] = useState("descending"); + const [singleClickFolders, setSingleClickFolders] = useState("disabled"); + const [loadThumbnails, setLoadThumbnails] = useState("enabled"); + const { setPreferences } = usePreferenceSetter(); + + const fileListStyleChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setListViewStyle(value); + if (value === "list") { + window.localStorage.setItem("list-mode", "true"); + } else { + window.localStorage.removeItem("list-mode"); + } + setPreferences(); + }; + + const sortByChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setSortBy(value); + if (value === "name") { + window.localStorage.setItem("sort-name", "true"); + } else { + window.localStorage.removeItem("sort-name"); + } + setPreferences(); + }; + + const orderByChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setOrderBy(value); + + if (value === "ascending") { + window.localStorage.setItem("order-asc", "true"); + } else { + window.localStorage.removeItem("order-asc"); + } + setPreferences(); + }; + + const singleClickFoldersChange = ( + e: React.ChangeEvent + ) => { + const value = e.target.value; + setSingleClickFolders(value); + + if (value === "enabled") { + window.localStorage.setItem("single-click-folders", "true"); + } else { + window.localStorage.removeItem("single-click-folders"); + } + setPreferences(); + }; + + const loadThumbnailsChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setLoadThumbnails(value); + + if (value === "disabled") { + window.localStorage.setItem("not-load-thumbnails", "true"); + } else { + window.localStorage.removeItem("not-load-thumbnails"); + } + setPreferences(); + }; + + useEffect(() => { + const listModeLocalStorage = window.localStorage.getItem("list-mode"); + const listModeEnabled = listModeLocalStorage === "true"; + + const sortByLocalStorage = window.localStorage.getItem("sort-name"); + const sortByNameEnabled = sortByLocalStorage === "true"; + + const orderByLocalStorage = window.localStorage.getItem("order-asc"); + const orderByAscendingEnabled = orderByLocalStorage === "true"; + + const singleClickFoldersLocalStorage = window.localStorage.getItem( + "single-click-folders" + ); + const singleClickFoldersEnabled = singleClickFoldersLocalStorage === "true"; + + const loadThumbnailsLocalStorage = window.localStorage.getItem( + "not-load-thumbnails" + ); + const loadThumbnailsDisabled = loadThumbnailsLocalStorage === "true"; + + setListViewStyle(listModeEnabled ? "list" : "grid"); + setSortBy(sortByNameEnabled ? "name" : "date"); + setOrderBy(orderByAscendingEnabled ? "ascending" : "descending"); + setSingleClickFolders(singleClickFoldersEnabled ? "enabled" : "disabled"); + setLoadThumbnails(loadThumbnailsDisabled ? "disabled" : "enabled"); + }, []); + + return ( +
+
+

General settings

+
+
+
+

File list style

+ +
+
+

Sort by

+ +
+
+

Order by

+ +
+
+

Single click to enter folders

+ +
+
+

Load thumbnails

+ +
+
+
+ ); +}; + +export default SettingsPageGeneral; diff --git a/src/components/SettingsPage/SettingsPage.tsx b/src/components/SettingsPage/SettingsPage.tsx new file mode 100644 index 0000000..3518a0c --- /dev/null +++ b/src/components/SettingsPage/SettingsPage.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from "react"; +import ChevronOutline from "../../icons/ChevronOutline"; +import Header from "../Header/Header"; +import classNames from "classnames"; +import AccountIcon from "../../icons/AccountIcon"; +import TuneIcon from "../../icons/TuneIcon"; +import { useNavigate } from "react-router-dom"; +import SettingsAccountSection from "./SettingsAccountSection"; +import { getUserDetailedAPI, logoutAPI } from "../../api/userAPI"; +import Spinner from "../Spinner/Spinner"; +import Swal from "sweetalert2"; +import SettingsGeneralSection from "./SettingsGeneralSection"; +import { useClickOutOfBounds } from "../../hooks/utils"; +import MenuIcon from "../../icons/MenuIcon"; +import { ToastContainer } from "react-toastify"; +import { toast } from "react-toastify"; + +const SettingsPage = () => { + const [user, setUser] = useState(null); + const [tab, setTab] = useState("account"); + const [showSidebarMobile, setShowSidebarMobile] = useState(false); + const navigate = useNavigate(); + + const getUser = async () => { + try { + const userResponse = await getUserDetailedAPI(); + setUser(userResponse); + } catch (e) { + console.log("Loading user details error", e); + const result = await Swal.fire({ + title: "Error loading user account", + text: "There was an error loading your account, would you like to logout?", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#3085d6", + cancelButtonColor: "#d33", + confirmButtonText: "Yes, logout", + }); + if (result.value) { + await toast.promise(logoutAPI(), { + pending: "Logging out...", + success: "Logged out", + error: "Error Logging Out", + }); + + window.localStorage.removeItem("hasPreviouslyLoggedIn"); + + navigate("/"); + } else { + navigate("/home"); + } + } + }; + + useEffect(() => { + getUser(); + }, []); + + const { wrapperRef } = useClickOutOfBounds(() => setShowSidebarMobile(false)); + + const changeTab = (tab: string) => { + setTab(tab); + setShowSidebarMobile(false); + }; + + return ( +
+
+
+
+
+ + {user && ( +
+
+ setShowSidebarMobile(!showSidebarMobile)} + /> +
+ {tab === "account" && ( + + )} + {tab === "general" && } +
+ )} + {!user && ( +
+ +
+ )} +
+ +
+ ); +}; + +export default SettingsPage; diff --git a/src/components/SharePopup/SharePopup.tsx b/src/components/SharePopup/SharePopup.tsx new file mode 100644 index 0000000..bd770b1 --- /dev/null +++ b/src/components/SharePopup/SharePopup.tsx @@ -0,0 +1,258 @@ +import { memo, useEffect, useMemo, useState } from "react"; +import CloseIcon from "../../icons/CloseIcon"; +import { useAppDispatch, useAppSelector } from "../../hooks/store"; +import { getFileColor, getFileExtension } from "../../utils/files"; +import bytes from "bytes"; +import { + makeOneTimePublicAPI, + makePublicAPI, + removeLinkAPI, +} from "../../api/filesAPI"; +import { useFiles, useQuickFiles } from "../../hooks/files"; +import { + resetShareModal, + setMainSelect, + setShareModal, +} from "../../reducers/selected"; +import { toast } from "react-toastify"; +import LockIcon from "../../icons/LockIcon"; +import OneIcon from "../../icons/OneIcon"; +import PublicIcon from "../../icons/PublicIcon"; + +const SharePopup = memo(() => { + const file = useAppSelector((state) => state.selected.shareModal.file)!; + const [updating, setUpdating] = useState(false); + const [shareLink, setShareLink] = useState(""); + const [shareType, setShareType] = useState<"private" | "public" | "one">( + "private" + ); + const dispatch = useAppDispatch(); + const { refetch: refetchFiles } = useFiles(false); + const { refetch: refetchQuickFiles } = useQuickFiles(false); + const [animate, setAnimate] = useState(false); + + const imageColor = getFileColor(file.filename); + + const fileExtension = getFileExtension(file.filename, 3); + + const makePublic = async () => { + try { + setUpdating(true); + const { file: updatedFile } = await toast.promise( + makePublicAPI(file._id), + { + pending: "Making Public...", + success: "Public Link Generated", + error: "Error Making Public", + } + ); + dispatch( + setMainSelect({ + file: updatedFile, + id: updatedFile._id, + type: "file", + folder: null, + }) + ); + dispatch(setShareModal(updatedFile)); + refetchFiles(); + refetchQuickFiles(); + } catch (e) { + console.log("Error making file public", e); + } finally { + setUpdating(false); + } + }; + + const makeOneTimePublic = async () => { + try { + setUpdating(true); + const { file: updatedFile } = await toast.promise( + makeOneTimePublicAPI(file._id), + { + pending: "Making Public...", + success: "Public Link Generated", + error: "Error Making Public", + } + ); + dispatch( + setMainSelect({ + file: updatedFile, + id: updatedFile._id, + type: "file", + folder: null, + }) + ); + dispatch(setShareModal(updatedFile)); + refetchFiles(); + refetchQuickFiles(); + } catch (e) { + console.log("Error making file public", e); + } finally { + setUpdating(false); + } + }; + + const removeLink = async () => { + try { + setUpdating(true); + const updatedFile = await toast.promise(removeLinkAPI(file._id), { + pending: "Removing Link...", + success: "Link Removed", + error: "Error Removing Link", + }); + dispatch( + setMainSelect({ + file: updatedFile, + id: updatedFile._id, + type: "file", + folder: null, + }) + ); + dispatch(setShareModal(updatedFile)); + refetchFiles(); + refetchQuickFiles(); + setShareLink(""); + } catch (e) { + console.log("Error removing link", e); + } finally { + setUpdating(false); + } + }; + + const copyLink = () => { + if (shareType === "private") return; + navigator.clipboard.writeText(shareLink); + toast.success("Link Copied"); + }; + + const closeShareModal = () => { + setAnimate(false); + setTimeout(() => dispatch(resetShareModal()), 200); + }; + + const outterWrapperClick = (e: any) => { + if (e.target.id !== "outer-wrapper") return; + closeShareModal(); + }; + + const permissionText = (() => { + if (shareType === "one") { + return `This file will be available for download one time, + after it is downloaded once the file will then automatically be marked as private.`; + } else if (shareType === "public") { + return "Anyone with the link can view and download this file"; + } else { + return "Only you can view and download this file"; + } + })(); + + const linkPreviewText = (() => { + if (shareType === "private") { + return "Document is private"; + } else { + return shareLink; + } + })(); + + useEffect(() => { + if (!file.metadata.link) return; + const url = `${window.location.origin}/public-download/${file._id}/${file.metadata.link}`; + setShareLink(url); + setShareType(file.metadata.linkType ? file.metadata.linkType : "private"); + }, [file._id, file.metadata.link, file.metadata.linkType]); + + useEffect(() => { + setAnimate(true); + }, []); + + const handleSelectChange = async (value: string) => { + if (value === "private") { + await removeLink(); + } else if (value === "one") { + await makeOneTimePublic(); + } else if (value === "public") { + await makePublic(); + } + }; + + const selectOnChange = (e: any) => { + const value = e.target.value; + setShareType(value); + handleSelectChange(value); + }; + + return ( +
+
+
+ +
+ + {fileExtension} + +
+
+

+ {file.filename} +

+
+
+
+ +
+
+
+
+

Share file

+
+ + +
+

Permission

+
+ {shareType === "private" && } + {shareType === "one" && } + {shareType === "public" && } + + {updating && ( +
+ )} +
+

{permissionText}

+
+
+ ); +}); + +export default SharePopup; diff --git a/src/components/Spinner/Spinner.tsx b/src/components/Spinner/Spinner.tsx new file mode 100644 index 0000000..ec8343a --- /dev/null +++ b/src/components/Spinner/Spinner.tsx @@ -0,0 +1 @@ +export default () =>
; diff --git a/src/components/UploadItem/UploadItem.tsx b/src/components/UploadItem/UploadItem.tsx new file mode 100644 index 0000000..100ba2e --- /dev/null +++ b/src/components/UploadItem/UploadItem.tsx @@ -0,0 +1,67 @@ +import { connect } from "react-redux"; +import React, { memo } from "react"; +import { getCancelToken } from "../../utils/cancelTokenManager"; +import CloseIcon from "../../icons/CloseIcon"; +import CheckCircleIcon from "../../icons/CheckCircleIcon"; +import AlertIcon from "../../icons/AlertIcon"; +import { UploadItemType } from "../../reducers/uploader"; + +const UploadItem: React.FC = (props) => { + const { completed, canceled, progress, name, id, type } = props; + const cancelToken = getCancelToken(id); + + const cancelUpload = () => { + cancelToken.cancel(); + }; + + const ProgressIcon = memo(() => { + if (completed) { + return ; + } else if (canceled) { + return ; + } else { + return ( + + ); + } + }); + + const ProgressBar = memo(() => { + if (completed) { + return
; + } else if (canceled) { + return
; + } else if (type === "file") { + return ( + + ); + } else { + return ; + } + }); + + return ( +
+
+
+
+

+ {name} +

+
+
+ +
+
+
+ +
+
+
+ ); +}; + +export default connect()(UploadItem); diff --git a/src/components/Uploader/Uploader.tsx b/src/components/Uploader/Uploader.tsx new file mode 100644 index 0000000..dd6fd7e --- /dev/null +++ b/src/components/Uploader/Uploader.tsx @@ -0,0 +1,61 @@ +import { useAppDispatch, useAppSelector } from "../../hooks/store"; +import CloseIcon from "../../icons/CloseIcon"; +import MinimizeIcon from "../../icons/MinimizeIcon"; +import { resetUploads } from "../../reducers/uploader"; +import { cancelAllFileUploads } from "../../utils/cancelTokenManager"; +import UploadItem from "../UploadItem/UploadItem"; +import { memo, useMemo, useState } from "react"; + +const Uploader = memo(() => { + const [minimized, setMinimized] = useState(false); + const uploads = useAppSelector((state) => state.uploader.uploads); + const dispatch = useAppDispatch(); + + const toggleMinimize = () => { + setMinimized((val) => !val); + }; + + const uploadTitle = useMemo(() => { + const uploadedCount = uploads.filter((upload) => upload.completed).length; + const currentlyUploadingCount = uploads.filter( + (upload) => !upload.completed + ).length; + + if (currentlyUploadingCount) { + return `Uploading ${currentlyUploadingCount} file${ + currentlyUploadingCount > 1 ? "s" : "" + }`; + } else { + return `Uploaded ${uploadedCount} file${uploadedCount > 1 ? "s" : ""}`; + } + }, [uploads]); + + const closeUploader = () => { + cancelAllFileUploads(); + dispatch(resetUploads()); + }; + + return ( +
+
+

{uploadTitle}

+ +
+
+ {!minimized && + uploads.map((upload) => { + return ; + })} +
+
+ ); +}); + +export default Uploader; diff --git a/src/components/VerifyEmailPage/VerifyEmailPage.tsx b/src/components/VerifyEmailPage/VerifyEmailPage.tsx new file mode 100644 index 0000000..bc222a9 --- /dev/null +++ b/src/components/VerifyEmailPage/VerifyEmailPage.tsx @@ -0,0 +1,37 @@ +import { useParams } from "react-router-dom"; +import { verifyEmailAPI } from "../../api/userAPI"; +import { toast, ToastContainer } from "react-toastify"; +import { useEffect } from "react"; + +const VerifyEmailPage = () => { + const token = useParams().token!; + + const verifyEmail = async () => { + try { + await toast.promise(verifyEmailAPI(token), { + pending: "Verifying email...", + success: "Email Verified", + error: "Error verifying email", + }); + + setTimeout(() => { + window.location.assign("/"); + }, 1500); + } catch (e) { + console.log("Error verifying email", e); + } + }; + + useEffect(() => { + verifyEmail(); + }, []); + + return ( +
+

Verifying email...

+ +
+ ); +}; + +export default VerifyEmailPage; diff --git a/src/config/.env.development.example b/src/config/.env.development.example new file mode 100644 index 0000000..fe4427f --- /dev/null +++ b/src/config/.env.development.example @@ -0,0 +1,11 @@ +# Optional, useful for development or if your BE is on a different domain +# Example: http://localhost:5173/api +VITE_BACKEND_URL= + +# Optional, this one is only used in development +# /api will be appended to this URL, so for example +# Here our BE is on localhost:3000, and we are telling +# Vite to proxy all requests to /api to our BE +# Proxy, and URL will become http://localhost:5173/api in this case. +# Example: http://localhost:3000 +VITE_PROXY_URL= \ No newline at end of file diff --git a/src/config/.env.production.example b/src/config/.env.production.example new file mode 100644 index 0000000..c8fc3ca --- /dev/null +++ b/src/config/.env.production.example @@ -0,0 +1,16 @@ +# NOTE: You most likely do not need these unless you running in development mode. + +# Either remove the .example from the end of this filename. +# Or create a new file with the same name, but without the .example extension. + +# Optional, useful for development or if your BE is on a different domain +# Example: http://localhost:5173/api +VITE_BACKEND_URL= + +# Optional, this one is only used in development +# /api will be appended to this URL, so for example +# Here our BE is on localhost:3000, and we are telling +# Vite to proxy all requests to /api to our BE +# Proxy, and URL will become http://localhost:5173/api in this case. +# Example: http://localhost:3000 +VITE_PROXY_URL= \ No newline at end of file diff --git a/src/enviroment/envFrontEnd.js b/src/enviroment/envFrontEnd.js new file mode 100644 index 0000000..6e27abf --- /dev/null +++ b/src/enviroment/envFrontEnd.js @@ -0,0 +1,15 @@ +const env = { + port: 5173, + url: "http://localhost", + // enableVideoTranscoding: process.env.ENABLE_VIDEO_TRANSCODING, + // disableStorage: process.env.DISABLE_STORAGE, + googleDriveEnabled: false, + s3Enabled: false, + activeSubscription: false, + // commercialMode: process.env.COMMERCIAL_MODE, + uploadMode: "", + emailAddress: "", + name: "", +}; + +export default env; diff --git a/src/hooks/actions.ts b/src/hooks/actions.ts new file mode 100644 index 0000000..c566b75 --- /dev/null +++ b/src/hooks/actions.ts @@ -0,0 +1,267 @@ +import { toast } from "react-toastify"; +import { + deleteFileAPI, + downloadFileAPI, + renameFileAPI, + restoreFileAPI, + trashFileAPI, +} from "../api/filesAPI"; +import { + deleteFilePopup, + renameFilePopup, + restoreItemPopup, + trashItemsPopup, +} from "../popups/file"; +import { FileInterface } from "../types/file"; +import { FolderInterface } from "../types/folders"; +import { useFiles, useQuickFiles } from "./files"; +import { useFolder, useFolders } from "./folders"; +import { useAppDispatch } from "./store"; +import { + resetSelected, + setMoveModal, + setMultiSelectMode, + setShareModal, +} from "../reducers/selected"; +import { deleteFolderPopup, renameFolderPopup } from "../popups/folder"; +import { + deleteFolderAPI, + downloadZIPAPI, + renameFolder, + restoreFolderAPI, + trashFolderAPI, +} from "../api/foldersAPI"; + +type UseActionsProps = { + quickItemMode?: boolean; +}; + +export const useActions = ({ quickItemMode }: UseActionsProps) => { + const { refetch: refetchFiles } = useFiles(false); + const { refetch: refetchFolders } = useFolders(false); + const { refetch: refetchFolder } = useFolder(false); + const { refetch: refetchQuickFiles } = useQuickFiles(false); + + const dispatch = useAppDispatch(); + + const reloadItems = () => { + refetchFiles(); + refetchQuickFiles(); + refetchFolders(); + refetchFolder(); + dispatch(resetSelected()); + }; + + const renameItem = async ( + file?: FileInterface | null, + folder?: FolderInterface | null + ) => { + if (file) { + try { + const filename = await renameFilePopup(file.filename); + if (!filename || filename === file.filename) return; + await toast.promise(renameFileAPI(file._id, filename), { + pending: "Renaming...", + success: "Renamed", + error: "Error Renaming", + }); + reloadItems(); + } catch (e) { + console.log("Error renaming file", e); + } + } else if (folder) { + try { + const folderName = await renameFolderPopup(folder.name); + if (!folderName || folderName === folder.name) return; + await toast.promise(renameFolder(folder._id, folderName), { + pending: "Renaming...", + success: "Renamed", + error: "Error Renaming", + }); + reloadItems(); + } catch (e) { + console.log("Error renaming folder", e); + } + } + }; + + const trashItem = async ( + file?: FileInterface | null, + folder?: FolderInterface | null + ) => { + if (file) { + try { + const result = await trashItemsPopup(); + if (!result) return; + + await toast.promise(trashFileAPI(file._id), { + pending: "Trashing...", + success: "Trashed", + error: "Error Trashing", + }); + reloadItems(); + } catch (e) { + console.log("Error trashing file", e); + } + } else if (folder) { + try { + const result = await trashItemsPopup(); + if (!result) return; + + await toast.promise(trashFolderAPI(folder._id), { + pending: "Trashing...", + success: "Trashed", + error: "Error Trashing", + }); + reloadItems(); + + // if (parentBarMode) { + // if (folder.parent === "/") { + // navigate("/home"); + // } else { + // navigate(`/folder/${folder.parent}`); + // } + // } + } catch (e) { + console.log("Error trashing folder", e); + } + } + }; + + const deleteItem = async ( + file?: FileInterface | null, + folder?: FolderInterface | null + ) => { + if (file) { + try { + const result = await deleteFilePopup(); + if (!result) return; + + await toast.promise(deleteFileAPI(file._id), { + pending: "Deleting...", + success: "Deleted", + error: "Error Deleting", + }); + reloadItems(); + } catch (e) { + console.log("Error deleting file", e); + } + } else if (folder) { + try { + const result = await deleteFolderPopup(); + if (!result) return; + + await toast.promise(deleteFolderAPI(folder._id), { + pending: "Deleting...", + success: "Deleted", + error: "Error Deleting", + }); + reloadItems(); + + // if (parentBarMode) { + // if (folder.parent === "/") { + // navigate("/trash"); + // } else { + // navigate(`/folder-trash/${folder.parent}`); + // } + // } + } catch (e) { + console.log("Error deleting folder", e); + } + } + }; + + const restoreItem = async ( + file?: FileInterface | null, + folder?: FolderInterface | null + ) => { + const result = await restoreItemPopup(); + if (!result) return; + if (file) { + try { + await toast.promise(restoreFileAPI(file._id), { + pending: "Restoring...", + success: "Restored", + error: "Error Restoring", + }); + reloadItems(); + } catch (e) { + console.log("Error restoring file", e); + } + } else if (folder) { + try { + await toast.promise(restoreFolderAPI(folder._id), { + pending: "Restoring...", + success: "Restored", + error: "Error Restoring", + }); + reloadItems(); + } catch (e) { + console.log("Error restoring folder", e); + } + } + }; + + const openMoveItemModal = async ( + file?: FileInterface | null, + folder?: FolderInterface | null + ) => { + if (file) { + dispatch(setMoveModal({ type: "file", file, folder: null })); + } else if (folder) { + dispatch(setMoveModal({ type: "folder", file: null, folder })); + } + }; + + const openShareItemModal = (file?: FileInterface | null) => { + dispatch(setShareModal(file!)); + }; + + const downloadItem = ( + file?: FileInterface | null, + folder?: FolderInterface | null + ) => { + if (file) downloadFileAPI(file._id); + if (folder) downloadZIPAPI([folder._id], []); + }; + + const selectItemMultiSelect = ( + file?: FileInterface | null, + folder?: FolderInterface | null + ) => { + if (folder) { + dispatch( + setMultiSelectMode([ + { + type: "folder", + id: folder._id, + file: null, + folder: folder, + }, + ]) + ); + } else if (file) { + dispatch( + setMultiSelectMode([ + { + type: quickItemMode ? "quick-item" : "file", + id: file._id, + file: file, + folder: null, + }, + ]) + ); + } + }; + + return { + renameItem, + trashItem, + deleteItem, + restoreItem, + openMoveItemModal, + openShareItemModal, + downloadItem, + selectItemMultiSelect, + }; +}; diff --git a/src/hooks/contextMenu.ts b/src/hooks/contextMenu.ts new file mode 100644 index 0000000..0290f57 --- /dev/null +++ b/src/hooks/contextMenu.ts @@ -0,0 +1,79 @@ +import { MouseEventHandler, useRef, useState } from "react"; + +export const useContextMenu = () => { + const [contextData, setContextData] = useState({ + selected: false, + X: 0, + Y: 0, + }); + const lastTouched = useRef(0); + const timeoutRef = useRef(null); + + const onContextMenu = (e: any) => { + if (e) e.stopPropagation(); + if (e) e.preventDefault(); + + let X = e.clientX; + let Y = e.clientY; + + setContextData({ + ...contextData, + selected: true, + X, + Y, + }); + }; + + const closeContextMenu = () => { + setContextData({ + ...contextData, + selected: false, + X: 0, + Y: 0, + }); + }; + + const onTouchStart = (e: any) => { + const touches = e.touches[0]; + let X = e.clientX || touches.clientX; + let Y = e.clientY || touches.clientY; + + if (contextData.selected) return; + + timeoutRef.current = setTimeout(() => { + console.log("timeout"); + setContextData({ + ...contextData, + selected: true, + X, + Y, + }); + }, 500); + }; + + const onTouchMove = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + + const onTouchEnd = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + + const clickStopPropagation = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + return { + ...contextData, + onContextMenu, + closeContextMenu, + onTouchStart, + onTouchMove, + onTouchEnd, + clickStopPropagation, + }; +}; diff --git a/src/hooks/files.ts b/src/hooks/files.ts new file mode 100644 index 0000000..0236557 --- /dev/null +++ b/src/hooks/files.ts @@ -0,0 +1,286 @@ +import { + UseInfiniteQueryResult, + UseQueryResult, + useInfiniteQuery, + useQuery, +} from "react-query"; +import { useParams } from "react-router-dom"; +import { + getFilesListAPI, + getQuickFilesListAPI, + getSuggestedListAPI, + uploadFileAPI, +} from "../api/filesAPI"; +import { useAppDispatch, useAppSelector } from "./store"; +import { useUtils } from "./utils"; +import { FileInterface } from "../types/file"; +import { v4 as uuid } from "uuid"; +import axiosNonInterceptor from "axios"; +import { + addFileUploadCancelToken, + removeFileUploadCancelToken, +} from "../utils/cancelTokenManager"; +import debounce from "lodash/debounce"; +import { addUpload, editUpload } from "../reducers/uploader"; +import { uploadFolderAPI } from "../api/foldersAPI"; +import { useFolders } from "./folders"; + +export const useFiles = (enabled = true) => { + const params = useParams(); + // TODO: Remove any + const sortBy = useAppSelector((state) => state.filter.sortBy); + const mediaFilter = useAppSelector((state) => state.filter.mediaFilter); + const { isTrash, isMedia } = useUtils(); + const limit = isMedia ? 100 : 50; + const filesReactQuery: UseInfiniteQueryResult = + useInfiniteQuery( + [ + "files", + { + parent: params.id || "/", + search: params.query || "", + sortBy, + limit, + trashMode: isTrash, + mediaMode: isMedia, + mediaFilter: mediaFilter, + }, + ], + getFilesListAPI, + { + getNextPageParam: (lastPage, pages) => { + const lastElement = lastPage[lastPage.length - 1]; + const hasPageWithoutMaxItemLength = pages.some( + (page) => page.length < limit + ); + if (!lastElement || hasPageWithoutMaxItemLength) return undefined; + return { + startAtDate: lastElement.uploadDate, + startAtName: lastElement.filename, + }; + }, + getPreviousPageParam: (firstPage, pages) => { + const firstElement = firstPage[0]; + if (!firstElement) return undefined; + return { + startAtDate: firstElement.uploadDate, + startAtName: firstElement.filename, + }; + }, + enabled, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + } + ); + + return { ...filesReactQuery }; +}; + +export const useQuickFiles = (enabled = true) => { + const quickFilesQuery: UseQueryResult = useQuery( + "quickFiles", + getQuickFilesListAPI, + { + enabled, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + } + ); + + return { ...quickFilesQuery }; +}; + +export const useSearchSuggestions = (searchText: string) => { + const { isTrash, isMedia } = useUtils(); + const searchQuery = useQuery( + [ + "search", + { + searchText, + trashMode: isTrash, + mediaMode: isMedia, + }, + ], + getSuggestedListAPI, + { enabled: searchText.length !== 0 } + ); + + return { ...searchQuery }; +}; + +export const useUploader = () => { + const dispatch = useAppDispatch(); + const params = useParams(); + const { refetch: refetchFiles } = useFiles(false); + const { refetch: refetchQuickFiles } = useQuickFiles(false); + const { refetch: refetchFolders } = useFolders(false); + + const debounceDispatch = debounce(dispatch, 200); + + const uploadFiles = (files: FileList) => { + for (let i = 0; i < files.length; i++) { + const parent = params.id || "/"; + + const currentFile = files[i]; + const currentID = uuid(); + + const CancelToken = axiosNonInterceptor.CancelToken; + const source = CancelToken.source(); + + addFileUploadCancelToken(currentID, source); + + const config = { + headers: { + "Content-Type": "multipart/form-data", + "Transfere-Encoding": "chunked", + }, + onUploadProgress: (progressEvent: ProgressEvent) => { + const currentProgress = Math.round( + (progressEvent.loaded / progressEvent.total) * 100 + ); + + if (currentProgress !== 100) { + debounceDispatch( + editUpload({ + id: currentID, + updateData: { progress: currentProgress }, + }) + ); + } + }, + cancelToken: source.token, + }; + + dispatch( + addUpload({ + id: currentID, + progress: 0, + name: currentFile.name, + completed: false, + canceled: false, + size: currentFile.size, + type: "file", + }) + ); + + const data = new FormData(); + + data.append("filename", currentFile.name); + data.append("parent", parent); + data.append("currentID", currentID); + data.append("size", currentFile.size.toString()); + data.append("file", currentFile); + + uploadFileAPI(data, config) + .then(() => { + dispatch( + editUpload({ + id: currentID, + updateData: { completed: true, progress: 100 }, + }) + ); + removeFileUploadCancelToken(currentID); + refetchFiles(); + refetchQuickFiles(); + }) + .catch((e) => { + console.log("Error uploading file", e); + dispatch( + editUpload({ + id: currentID, + updateData: { canceled: true }, + }) + ); + removeFileUploadCancelToken(currentID); + }); + } + }; + + const uploadFolder = (files: FileList) => { + const data = new FormData(); + + const parent = params.id || "/"; + + data.append("parent", parent); + + if (files.length === 0) return; + + for (let i = 0; i < files.length; i++) { + const currentFile = files[i]; + data.append( + "file-data", + JSON.stringify({ + name: currentFile.name, + size: currentFile.size, + type: currentFile.type, + path: currentFile.webkitRelativePath, + index: i, + }) + ); + } + + const firstItemPath = files[0].webkitRelativePath; + const firstItemPathSplit = firstItemPath.split("/"); + + const parentName = firstItemPathSplit[0]; + + data.append("total-files", files.length.toString()); + + for (let i = 0; i < files.length; i++) { + const currentFile = files[i]; + console.log("current file", currentFile.webkitRelativePath); + data.append("file", currentFile, i.toString()); + } + + const CancelToken = axiosNonInterceptor.CancelToken; + const source = CancelToken.source(); + + const config = { + headers: { + "Content-Type": "multipart/form-data", + "Transfere-Encoding": "chunked", + }, + cancelToken: source.token, + }; + + const currentID = uuid(); + + dispatch( + addUpload({ + id: currentID, + progress: 0, + name: parentName, + completed: false, + canceled: false, + size: 100, + type: "folder", + }) + ); + + uploadFolderAPI(data, config) + .then(() => { + dispatch( + editUpload({ + id: currentID, + updateData: { completed: true, progress: 100 }, + }) + ); + removeFileUploadCancelToken(currentID); + refetchFiles(); + refetchQuickFiles(); + refetchFolders(); + }) + .catch((e) => { + console.log("Error uploading folder", e); + dispatch( + editUpload({ + id: currentID, + updateData: { canceled: true }, + }) + ); + removeFileUploadCancelToken(currentID); + }); + }; + + return { uploadFiles, uploadFolder }; +}; diff --git a/src/hooks/folders.ts b/src/hooks/folders.ts new file mode 100644 index 0000000..05da9c6 --- /dev/null +++ b/src/hooks/folders.ts @@ -0,0 +1,70 @@ +import { UseQueryResult, useQuery, useQueryClient } from "react-query"; +import { useParams } from "react-router-dom"; +import { + getFolderInfoAPI, + getFoldersListAPI, + getMoveFolderListAPI, +} from "../api/foldersAPI"; +import { useUtils } from "./utils"; +import { FolderInterface } from "../types/folders"; +import { useAppSelector } from "./store"; + +export const useFolders = (enabled = true) => { + const params = useParams(); + const sortBy = useAppSelector((state) => state.filter.sortBy); + const { isTrash } = useUtils(); + const foldersReactQuery: UseQueryResult = useQuery( + [ + "folders", + { + parent: params.id || "/", + search: params.query || "", + sortBy, + limit: undefined, + trashMode: isTrash, + }, + ], + getFoldersListAPI, + { enabled, refetchOnWindowFocus: false, refetchOnReconnect: false } + ); + + return { ...foldersReactQuery }; +}; + +export const useFolder = (enabled = true) => { + const params = useParams(); + const folderQuery = useQuery( + [ + "folder", + { + id: params.id, + }, + ], + getFolderInfoAPI, + { enabled } + ); + + return { ...folderQuery }; +}; + +export const useMoveFolders = ( + parent: string, + search: string, + folderIDs?: string[] +) => { + const params = useParams(); + const moveFoldersQuery = useQuery( + [ + "move-folder-list", + { + parent, + search, + folderIDs, + currentParent: params.id || "/", + }, + ], + getMoveFolderListAPI + ); + + return { ...moveFoldersQuery }; +}; diff --git a/src/hooks/infiniteScroll.ts b/src/hooks/infiniteScroll.ts new file mode 100644 index 0000000..5fa7f66 --- /dev/null +++ b/src/hooks/infiniteScroll.ts @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +// TODO: Fix anys +export const useInfiniteScroll = () => { + const [reachedIntersect, setReachedIntersect] = useState(false); + const observer = useRef() as any; + const sentinelRef = useRef(); + + const handleObserver = useCallback( + (entries: any) => { + const target = entries[0]; + if (target.isIntersecting) { + setReachedIntersect(true); + } else if (reachedIntersect) { + setReachedIntersect(false); + } + }, + [reachedIntersect] + ); + + useEffect(() => { + if (observer.current) { + observer.current.disconnect(); + } + + observer.current = new IntersectionObserver(handleObserver, { + root: null, + rootMargin: undefined, + threshold: 0.1, + }); + if (sentinelRef.current) { + observer.current.observe(sentinelRef.current); + } + + return () => { + if (sentinelRef.current) { + observer.current.disconnect(); + } + }; + }, [handleObserver]); + + return { reachedIntersect, sentinelRef, observer }; +}; diff --git a/src/hooks/preferenceSetter.ts b/src/hooks/preferenceSetter.ts new file mode 100644 index 0000000..6c7b8aa --- /dev/null +++ b/src/hooks/preferenceSetter.ts @@ -0,0 +1,58 @@ +import { useCallback, useEffect } from "react"; +import { useAppDispatch } from "./store"; +import { setSortBy } from "../reducers/filter"; +import { + setSingleClickFolders, + setListView, + setLoadThumbnailsDisabled, +} from "../reducers/general"; + +export const usePreferenceSetter = () => { + const dispatch = useAppDispatch(); + + const setPreferences = useCallback(() => { + const listModeLocalStorage = window.localStorage.getItem("list-mode"); + const listModeEnabled = listModeLocalStorage === "true"; + + const sortByLocalStorage = window.localStorage.getItem("sort-name"); + const sortByNameEnabled = sortByLocalStorage === "true"; + + const orderByLocalStorage = window.localStorage.getItem("order-asc"); + const orderByAscendingEnabled = orderByLocalStorage === "true"; + + const singleClickFoldersLocalStorage = window.localStorage.getItem( + "single-click-folders" + ); + const singleClickFoldersEnabled = singleClickFoldersLocalStorage === "true"; + + const loadThumbnailsLocalStorage = window.localStorage.getItem( + "not-load-thumbnails" + ); + const loadThumbnailsDisabled = loadThumbnailsLocalStorage === "true"; + + let sortBy = ""; + + if (sortByNameEnabled) { + sortBy = "alp_"; + } else { + sortBy = "date_"; + } + + if (orderByAscendingEnabled) { + sortBy += "asc"; + } else { + sortBy += "desc"; + } + + dispatch(setListView(listModeEnabled)); + dispatch(setSortBy(sortBy)); + dispatch(setLoadThumbnailsDisabled(loadThumbnailsDisabled)); + dispatch(setSingleClickFolders(singleClickFoldersEnabled)); + }, []); + + useEffect(() => { + setPreferences(); + }, [setPreferences]); + + return { setPreferences }; +}; diff --git a/src/hooks/store.ts b/src/hooks/store.ts new file mode 100644 index 0000000..9246942 --- /dev/null +++ b/src/hooks/store.ts @@ -0,0 +1,6 @@ +import { useDispatch, useSelector } from "react-redux"; +import { AppDispatch, RootState } from "../store/configureStore"; + +// Use throughout your app instead of plain `useDispatch` and `useSelector` +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/src/hooks/user.ts b/src/hooks/user.ts new file mode 100644 index 0000000..f2a94d1 --- /dev/null +++ b/src/hooks/user.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect } from "react"; +import { getAccessToken } from "../api/userAPI"; +import uuid from "uuid"; + +const useAccessTokenHandler = () => { + const refreshAccessToken = useCallback(async () => { + try { + const browserID = localStorage.getItem("browser-id") || uuid.v4(); + if (!localStorage.getItem("browser-id")) { + localStorage.setItem("browser-id", browserID); + } + await getAccessToken(browserID); + } catch (e) { + console.log("Error refreshing access token", e); + } + }, []); + + const visibilityChange = useCallback(() => { + if (document.visibilityState === "visible") { + refreshAccessToken(); + } + }, [refreshAccessToken]); + + useEffect(() => { + refreshAccessToken(); + + const timer = setInterval(refreshAccessToken, 60 * 1000 * 20); + document.addEventListener("visibilitychange", visibilityChange); + + return () => { + clearInterval(timer); + document.removeEventListener("visibilitychange", visibilityChange); + }; + }, [refreshAccessToken, visibilityChange]); +}; + +export default useAccessTokenHandler; diff --git a/src/hooks/utils.ts b/src/hooks/utils.ts new file mode 100644 index 0000000..b93b667 --- /dev/null +++ b/src/hooks/utils.ts @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useLocation } from "react-router-dom"; + +export const useUtils = () => { + const location = useLocation(); + + const isHome = location.pathname === "/home"; + + const isTrash = + location.pathname === "/trash" || + location.pathname.includes("/folder-trash") || + location.pathname.includes("/search-trash"); + + const isMedia = + location.pathname === "/media" || + location.pathname.includes("/search-media"); + + const isSettings = location.pathname === "/settings"; + + const isHomeFolder = location.pathname.includes("/folder/"); + + const isSearch = location.pathname.includes("/search/"); + + return { isHome, isTrash, isMedia, isSettings, isHomeFolder, isSearch }; +}; + +export const useClickOutOfBounds = ( + outOfBoundsCallback: (e: any) => any, + shouldCheck = true +) => { + const wrapperRef = useRef(null); + // TODO: Remove this any + const outOfBoundsClickCheck = useCallback( + (e: MouseEvent | TouchEvent) => { + if ( + wrapperRef?.current && + !wrapperRef.current.contains(e.target as Node) + ) { + outOfBoundsCallback(e); + } + }, + [outOfBoundsCallback] + ); + + useEffect(() => { + if (shouldCheck) { + document.addEventListener("mousedown", outOfBoundsClickCheck); + document.addEventListener("touchstart", outOfBoundsClickCheck); + } else { + document.removeEventListener("mousedown", outOfBoundsClickCheck); + document.removeEventListener("touchstart", outOfBoundsClickCheck); + } + return () => { + if (shouldCheck) { + document.removeEventListener("mousedown", outOfBoundsClickCheck); + document.removeEventListener("touchstart", outOfBoundsClickCheck); + } + }; + }, [outOfBoundsCallback, outOfBoundsClickCheck, shouldCheck]); + + return { + wrapperRef, + }; +}; + +export const useDragAndDrop = (fileDroppedCallback: (file: any) => any) => { + const [isDraggingFile, setIsDraggingFile] = useState(false); + const isDraggingFileRef = useRef(false); + + const onDragDropEvent = useCallback( + (e: any) => { + e.preventDefault(); + e.stopPropagation(); + isDraggingFileRef.current = false; + setIsDraggingFile(false); + + fileDroppedCallback(e.dataTransfer.files); + }, + [fileDroppedCallback] + ); + const onDragEvent = useCallback((e: any) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + const onDragEnterEvent = useCallback((e: any) => { + e.preventDefault(); + e.stopPropagation(); + + if (isDraggingFileRef.current) return; + isDraggingFileRef.current = true; + setIsDraggingFile(true); + }, []); + const stopDrag = useCallback((e: any) => { + e?.preventDefault(); + e?.stopPropagation(); + + if (!isDraggingFileRef.current) return; + isDraggingFileRef.current = false; + setIsDraggingFile(false); + }, []); + + useEffect(() => { + window.addEventListener("dragover", stopDrag); + window.addEventListener("focus", stopDrag); + + return () => { + window.removeEventListener("dragover", stopDrag); + window.removeEventListener("focus", stopDrag); + }; + }, [stopDrag]); + + return { + isDraggingFile, + onDragDropEvent, + onDragEvent, + onDragEnterEvent, + stopDrag, + }; +}; diff --git a/src/icons/AccountIcon.tsx b/src/icons/AccountIcon.tsx new file mode 100644 index 0000000..1d3a976 --- /dev/null +++ b/src/icons/AccountIcon.tsx @@ -0,0 +1,15 @@ +type AccountIconType = React.SVGAttributes; + +const AccountIcon: React.FC = (props) => { + return ( + + account-box + + + ); +}; + +export default AccountIcon; diff --git a/src/icons/ActionsIcon.tsx b/src/icons/ActionsIcon.tsx new file mode 100644 index 0000000..a46c0f7 --- /dev/null +++ b/src/icons/ActionsIcon.tsx @@ -0,0 +1,24 @@ +type ActionsIconType = React.SVGAttributes; + +const ActionsIcon: React.FC = (props) => { + return ( + + ); +}; + +export default ActionsIcon; diff --git a/src/icons/AlertIcon.tsx b/src/icons/AlertIcon.tsx new file mode 100644 index 0000000..9c64752 --- /dev/null +++ b/src/icons/AlertIcon.tsx @@ -0,0 +1,15 @@ +type AlertIconType = React.SVGAttributes; + +const AlertIcon: React.FC = (props) => { + return ( + + alert-circle + + + ); +}; + +export default AlertIcon; diff --git a/src/icons/ArrowBackIcon.tsx b/src/icons/ArrowBackIcon.tsx new file mode 100644 index 0000000..76b8458 --- /dev/null +++ b/src/icons/ArrowBackIcon.tsx @@ -0,0 +1,15 @@ +type ArrowBackIconType = React.SVGAttributes; + +const ArrowBackIcon: React.FC = (props) => { + return ( + + arrow-left + + + ); +}; + +export default ArrowBackIcon; diff --git a/src/icons/CalendarIcon.tsx b/src/icons/CalendarIcon.tsx new file mode 100644 index 0000000..45288dc --- /dev/null +++ b/src/icons/CalendarIcon.tsx @@ -0,0 +1,17 @@ +type CalendarIconType = React.SVGAttributes; + +const CalendarIcon: React.FC = (props) => { + return ( + + calendar-range + + + ); +}; + +export default CalendarIcon; diff --git a/src/icons/CheckCircleIcon.tsx b/src/icons/CheckCircleIcon.tsx new file mode 100644 index 0000000..83110b1 --- /dev/null +++ b/src/icons/CheckCircleIcon.tsx @@ -0,0 +1,15 @@ +type CheckCircleIconType = React.SVGAttributes; + +const CheckCircleIcon: React.FC = (props) => { + return ( + + check-circle + + + ); +}; + +export default CheckCircleIcon; diff --git a/src/icons/ChevronOutline.tsx b/src/icons/ChevronOutline.tsx new file mode 100644 index 0000000..b99b665 --- /dev/null +++ b/src/icons/ChevronOutline.tsx @@ -0,0 +1,20 @@ +type ChevronOutlineType = React.SVGAttributes; + +const ChevronOutline: React.FC = (props) => { + return ( + + + + ); +}; + +export default ChevronOutline; diff --git a/src/icons/ChevronSolid.tsx b/src/icons/ChevronSolid.tsx new file mode 100644 index 0000000..501de86 --- /dev/null +++ b/src/icons/ChevronSolid.tsx @@ -0,0 +1,22 @@ +type ChevronSolidType = React.SVGAttributes; + +const ChevronSolid: React.FC = (props) => { + return ( + + + + ); +}; + +export default ChevronSolid; diff --git a/src/icons/CircleLeftIcon.tsx b/src/icons/CircleLeftIcon.tsx new file mode 100644 index 0000000..65550d8 --- /dev/null +++ b/src/icons/CircleLeftIcon.tsx @@ -0,0 +1,15 @@ +type CircleLeftIconType = React.SVGAttributes; + +const CircleLeftIcon: React.FC = (props) => { + return ( + + arrow-left-circle-outline + + + ); +}; + +export default CircleLeftIcon; diff --git a/src/icons/CircleRightIcon.tsx b/src/icons/CircleRightIcon.tsx new file mode 100644 index 0000000..91bc23d --- /dev/null +++ b/src/icons/CircleRightIcon.tsx @@ -0,0 +1,15 @@ +type CircleRightIconType = React.SVGAttributes; + +const CircleRightIcon: React.FC = (props) => { + return ( + + arrow-right-circle-outline + + + ); +}; + +export default CircleRightIcon; diff --git a/src/icons/ClockIcon.tsx b/src/icons/ClockIcon.tsx new file mode 100644 index 0000000..73a2861 --- /dev/null +++ b/src/icons/ClockIcon.tsx @@ -0,0 +1,17 @@ +type ClockIconType = React.SVGAttributes; + +const ClockIcon: React.FC = (props) => { + return ( + + clock-time-eight-outline + + + ); +}; + +export default ClockIcon; diff --git a/src/icons/CloseIcon.tsx b/src/icons/CloseIcon.tsx new file mode 100644 index 0000000..9fc6d55 --- /dev/null +++ b/src/icons/CloseIcon.tsx @@ -0,0 +1,15 @@ +type CloseIconType = React.SVGAttributes; + +const CloseIcon: React.FC = (props) => { + return ( + + window-close + + + ); +}; + +export default CloseIcon; diff --git a/src/icons/CreateFolderIcon.tsx b/src/icons/CreateFolderIcon.tsx new file mode 100644 index 0000000..94bd766 --- /dev/null +++ b/src/icons/CreateFolderIcon.tsx @@ -0,0 +1,22 @@ +type CreateFolderIconType = React.SVGAttributes; + +const CreateFolderIcon: React.FC = (props) => { + return ( + + + + ); +}; + +export default CreateFolderIcon; diff --git a/src/icons/DownloadIcon.tsx b/src/icons/DownloadIcon.tsx new file mode 100644 index 0000000..ddf882f --- /dev/null +++ b/src/icons/DownloadIcon.tsx @@ -0,0 +1,26 @@ +type DownloadIconType = React.SVGAttributes; + +const DownloadIcon: React.FC = (props) => { + return ( + + + + + + ); +}; + +export default DownloadIcon; diff --git a/src/icons/FileDetailsIcon.tsx b/src/icons/FileDetailsIcon.tsx new file mode 100644 index 0000000..b37a672 --- /dev/null +++ b/src/icons/FileDetailsIcon.tsx @@ -0,0 +1,68 @@ +type FileDetailsIconType = React.SVGAttributes; + +const FileDetailsIcon: React.FC = (props) => { + return ( + + + + + + + + + + + + + + ); +}; + +export default FileDetailsIcon; diff --git a/src/icons/FolderIcon.tsx b/src/icons/FolderIcon.tsx new file mode 100644 index 0000000..0e45fac --- /dev/null +++ b/src/icons/FolderIcon.tsx @@ -0,0 +1,14 @@ +type FolderIconType = React.SVGAttributes; + +const FolderIcon: React.FC = (props) => { + return ( + + + + ); +}; + +export default FolderIcon; diff --git a/src/icons/FolderUploadIcon.tsx b/src/icons/FolderUploadIcon.tsx new file mode 100644 index 0000000..c4cef29 --- /dev/null +++ b/src/icons/FolderUploadIcon.tsx @@ -0,0 +1,15 @@ +type FolderUploadIconType = React.SVGAttributes; + +const FolderUploadIcon: React.FC = (props) => { + return ( + + folder-upload-outline + + + ); +}; + +export default FolderUploadIcon; diff --git a/src/icons/HomeIconOutline.tsx b/src/icons/HomeIconOutline.tsx new file mode 100644 index 0000000..68f10b3 --- /dev/null +++ b/src/icons/HomeIconOutline.tsx @@ -0,0 +1,15 @@ +type HomeIconOutlineType = React.SVGAttributes; + +const HomeIconOutline: React.FC = (props) => { + return ( + + home-variant-outline + + + ); +}; + +export default HomeIconOutline; diff --git a/src/icons/HomeListIcon.tsx b/src/icons/HomeListIcon.tsx new file mode 100644 index 0000000..fd446fb --- /dev/null +++ b/src/icons/HomeListIcon.tsx @@ -0,0 +1,61 @@ +type HomeIconType = React.SVGAttributes; + +const HomeListIcon: React.FC = (props) => { + return ( + + + + + + + + + + + ); +}; + +export default HomeListIcon; diff --git a/src/icons/LockIcon.tsx b/src/icons/LockIcon.tsx new file mode 100644 index 0000000..433b800 --- /dev/null +++ b/src/icons/LockIcon.tsx @@ -0,0 +1,17 @@ +type LockIconType = React.SVGAttributes; + +const LockIcon: React.FC = (props) => { + return ( + + lock-outline + + + ); +}; + +export default LockIcon; diff --git a/src/icons/MenuIcon.tsx b/src/icons/MenuIcon.tsx new file mode 100644 index 0000000..13b6c05 --- /dev/null +++ b/src/icons/MenuIcon.tsx @@ -0,0 +1,15 @@ +type MenuIconType = React.SVGAttributes; + +const MenuIcon: React.FC = (props) => { + return ( + + menu + + + ); +}; + +export default MenuIcon; diff --git a/src/icons/MinimizeIcon.tsx b/src/icons/MinimizeIcon.tsx new file mode 100644 index 0000000..6c96276 --- /dev/null +++ b/src/icons/MinimizeIcon.tsx @@ -0,0 +1,12 @@ +type MinimizeIconType = React.SVGAttributes; + +const MinimizeIcon: React.FC = (props) => { + return ( + + minus + + + ); +}; + +export default MinimizeIcon; diff --git a/src/icons/MoveIcon.tsx b/src/icons/MoveIcon.tsx new file mode 100644 index 0000000..10b1014 --- /dev/null +++ b/src/icons/MoveIcon.tsx @@ -0,0 +1,24 @@ +type MoveIconType = React.SVGAttributes; + +const Moveicon: React.FC = (props) => { + return ( + + + + ); +}; + +export default Moveicon; diff --git a/src/icons/MultiSelectIcon.tsx b/src/icons/MultiSelectIcon.tsx new file mode 100644 index 0000000..dfc71c6 --- /dev/null +++ b/src/icons/MultiSelectIcon.tsx @@ -0,0 +1,19 @@ +type MultiSelectIconType = React.SVGAttributes; + +const MultiSelectIcon: React.FC = (props) => { + return ( + + + + ); +}; + +export default MultiSelectIcon; diff --git a/src/icons/OneIcon.tsx b/src/icons/OneIcon.tsx new file mode 100644 index 0000000..fdedaf7 --- /dev/null +++ b/src/icons/OneIcon.tsx @@ -0,0 +1,15 @@ +const OneIcon: React.FC> = (props) => { + return ( + + numeric-1-circle-outline + + + ); +}; + +export default OneIcon; diff --git a/src/icons/PhotoIcon.tsx b/src/icons/PhotoIcon.tsx new file mode 100644 index 0000000..c8c61ef --- /dev/null +++ b/src/icons/PhotoIcon.tsx @@ -0,0 +1,13 @@ +const PhotoIcon: React.FC> = (props) => { + return ( + + image + + + ); +}; + +export default PhotoIcon; diff --git a/src/icons/PlayIcon.tsx b/src/icons/PlayIcon.tsx new file mode 100644 index 0000000..3396abe --- /dev/null +++ b/src/icons/PlayIcon.tsx @@ -0,0 +1,12 @@ +type PlayIconType = React.SVGAttributes; + +const PlayIcon: React.FC = (props) => { + return ( + + play + + + ); +}; + +export default PlayIcon; diff --git a/src/icons/PublicIcon.tsx b/src/icons/PublicIcon.tsx new file mode 100644 index 0000000..1305762 --- /dev/null +++ b/src/icons/PublicIcon.tsx @@ -0,0 +1,15 @@ +const PublicIcon: React.FC> = (props) => { + return ( + + earth + + + ); +}; + +export default PublicIcon; diff --git a/src/icons/RenameIcon.tsx b/src/icons/RenameIcon.tsx new file mode 100644 index 0000000..c71a3e2 --- /dev/null +++ b/src/icons/RenameIcon.tsx @@ -0,0 +1,24 @@ +type RenameIconType = React.SVGAttributes; + +const RenameIcon: React.FC = (props) => { + return ( + + + + ); +}; + +export default RenameIcon; diff --git a/src/icons/RestoreIcon.tsx b/src/icons/RestoreIcon.tsx new file mode 100644 index 0000000..c3f6c6a --- /dev/null +++ b/src/icons/RestoreIcon.tsx @@ -0,0 +1,20 @@ +type RestoreIconType = React.SVGAttributes; + +const RestoreIcon: React.FC = (props) => { + return ( + + restore + + + ); +}; + +export default RestoreIcon; diff --git a/src/icons/SearchIcon.tsx b/src/icons/SearchIcon.tsx new file mode 100644 index 0000000..d1c16f4 --- /dev/null +++ b/src/icons/SearchIcon.tsx @@ -0,0 +1,30 @@ +type SearchIconType = React.SVGAttributes; + +const SearchIcon: React.FC = (props) => { + return ( + + + + + + + ); +}; + +export default SearchIcon; diff --git a/src/icons/SettingsIcon.tsx b/src/icons/SettingsIcon.tsx new file mode 100644 index 0000000..d05c9bb --- /dev/null +++ b/src/icons/SettingsIcon.tsx @@ -0,0 +1,15 @@ +type SettingsIconType = React.SVGAttributes; + +const SettingsIcon: React.FC = (props) => { + return ( + + cog-outline + + + ); +}; + +export default SettingsIcon; diff --git a/src/icons/SettingsIconSolid.tsx b/src/icons/SettingsIconSolid.tsx new file mode 100644 index 0000000..c3d01c2 --- /dev/null +++ b/src/icons/SettingsIconSolid.tsx @@ -0,0 +1,24 @@ +type SettingsIconSolidType = React.SVGAttributes; + +const SettingsIconSolid: React.FC = (props) => { + return ( + + + + ); +}; + +export default SettingsIconSolid; diff --git a/src/icons/ShareIcon.tsx b/src/icons/ShareIcon.tsx new file mode 100644 index 0000000..5465beb --- /dev/null +++ b/src/icons/ShareIcon.tsx @@ -0,0 +1,26 @@ +type ShareIconType = React.SVGAttributes; + +const ShareIcon: React.FC = (props) => { + return ( + + + + + + ); +}; + +export default ShareIcon; diff --git a/src/icons/SpacerIcon.tsx b/src/icons/SpacerIcon.tsx new file mode 100644 index 0000000..ec852b5 --- /dev/null +++ b/src/icons/SpacerIcon.tsx @@ -0,0 +1,22 @@ +type SpacerIconType = React.SVGAttributes; + +const SpacerIcon: React.FC = (props) => { + return ( + + + + ); +}; + +export default SpacerIcon; diff --git a/src/icons/StorageIcon.tsx b/src/icons/StorageIcon.tsx new file mode 100644 index 0000000..047b477 --- /dev/null +++ b/src/icons/StorageIcon.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +type StorageIconType = React.SVGAttributes; + +const StorageIcon: React.FC = (props) => { + return ( + + database-outline + + + ); +}; + +export default StorageIcon; diff --git a/src/icons/TrashIcon.tsx b/src/icons/TrashIcon.tsx new file mode 100644 index 0000000..4cca469 --- /dev/null +++ b/src/icons/TrashIcon.tsx @@ -0,0 +1,17 @@ +import React from "react"; + +type TrashIconType = React.SVGAttributes; + +const TrashIcon: React.FC = (props) => { + return ( + + trash-can-outline + + + ); +}; + +export default TrashIcon; diff --git a/src/icons/TuneIcon.tsx b/src/icons/TuneIcon.tsx new file mode 100644 index 0000000..c3b7bf8 --- /dev/null +++ b/src/icons/TuneIcon.tsx @@ -0,0 +1,15 @@ +type TuneIconType = React.SVGAttributes; + +const TuneIcon: React.FC = (props) => { + return ( + + tune + + + ); +}; + +export default TuneIcon; diff --git a/src/icons/UploadFileIcon.tsx b/src/icons/UploadFileIcon.tsx new file mode 100644 index 0000000..a6cdf76 --- /dev/null +++ b/src/icons/UploadFileIcon.tsx @@ -0,0 +1,17 @@ +type UploadFileIconType = React.SVGAttributes; + +const UploadFileIcon: React.FC = (props) => { + return ( + + + + ); +}; + +export default UploadFileIcon; diff --git a/src/popups/file.ts b/src/popups/file.ts new file mode 100644 index 0000000..66527c9 --- /dev/null +++ b/src/popups/file.ts @@ -0,0 +1,118 @@ +import Swal from "sweetalert2"; + +export const renameFilePopup = async (filename: string) => { + const result = await Swal.fire({ + title: "Enter A File Name", + input: "text", + inputValue: filename, + showCancelButton: true, + inputValidator: (value) => { + if (!value) { + return "Please Enter a Name"; + } + }, + }); + return result.value; +}; + +export const deleteFilePopup = async () => { + const result = await Swal.fire({ + title: "Delete file?", + text: "You will not be able to recover this file.", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#3085d6", + cancelButtonColor: "#d33", + confirmButtonText: "Yes", + }); + return result.value; +}; + +export const restoreItemPopup = async () => { + const result = await Swal.fire({ + title: "Restore item?", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#3085d6", + cancelButtonColor: "#d33", + confirmButtonText: "Yes", + }); + return result.value; +}; + +export const restoreItemsPopup = async () => { + const result = await Swal.fire({ + title: "Restore items?", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#3085d6", + cancelButtonColor: "#d33", + confirmButtonText: "Yes", + }); + return result.value; +}; + +export const deleteItemsPopup = async () => { + const result = await Swal.fire({ + title: "Delete items?", + text: "You will not be able to recover these items.", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#3085d6", + cancelButtonColor: "#d33", + confirmButtonText: "Yes", + }); + return result.value; +}; + +export const trashItemsPopup = async () => { + const result = await Swal.fire({ + title: "Move to trash?", + text: "Items in the trash will eventually be deleted.", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#3085d6", + cancelButtonColor: "#d33", + confirmButtonText: "Yes", + }); + return result.value; +}; + +export const makePublicPopup = async () => { + const result = await Swal.fire({ + title: "Make file public?", + text: "Anyone with the link will be able to download the file.", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#3085d6", + cancelButtonColor: "#d33", + confirmButtonText: "Yes", + }); + return result.value; +}; + +export const makeOneTimePublicPopup = async () => { + const result = await Swal.fire({ + title: "Make file temporarly public?", + text: "Anyone with the link will be able to downoad the file once.", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#3085d6", + cancelButtonColor: "#d33", + confirmButtonText: "Yes", + }); + return result.value; +}; + +export const removeLinkPopup = async () => { + const result = await Swal.fire({ + title: "Remove link?", + text: "This will remove public access to the file.", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#3085d6", + cancelButtonColor: "#d33", + confirmButtonText: "Yes", + }); + return result.value; +}; diff --git a/src/popups/folder.ts b/src/popups/folder.ts new file mode 100644 index 0000000..3f89c84 --- /dev/null +++ b/src/popups/folder.ts @@ -0,0 +1,44 @@ +import Swal from "sweetalert2"; + +export const renameFolderPopup = async (folderName: string) => { + const result = await Swal.fire({ + title: "Enter A folder Name", + input: "text", + inputValue: folderName, + showCancelButton: true, + inputValidator: (value) => { + if (!value) { + return "Please Enter a Name"; + } + }, + }); + return result.value; +}; + +export const deleteFolderPopup = async () => { + const result = await Swal.fire({ + title: "Delete folder?", + text: "You will not be able to recover this folder.", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#3085d6", + cancelButtonColor: "#d33", + confirmButtonText: "Yes", + }); + return result.value; +}; + +export const showCreateFolderPopup = async (defaultName = "") => { + const { value: folderName } = await Swal.fire({ + title: "Enter Folder Name", + input: "text", + inputValue: defaultName, + showCancelButton: true, + inputValidator: (value) => { + if (!value) { + return "Please Enter a Name"; + } + }, + }); + return folderName; +}; diff --git a/src/popups/user.ts b/src/popups/user.ts new file mode 100644 index 0000000..25088f6 --- /dev/null +++ b/src/popups/user.ts @@ -0,0 +1,11 @@ +import Swal from "sweetalert2"; + +export const emailVerificationSentPopup = async () => { + const result = await Swal.fire({ + title: "Email verification sent", + icon: "success", + confirmButtonColor: "#3085d6", + confirmButtonText: "Okay", + }); + return result.value; +}; diff --git a/src/providers/AuthProvider.js b/src/providers/AuthProvider.js new file mode 100644 index 0000000..76eaecf --- /dev/null +++ b/src/providers/AuthProvider.js @@ -0,0 +1,19 @@ +import React, { createContext, useState, useContext, useEffect } from "react"; +import { useSelector } from "react-redux"; +import { useLocation, useNavigate } from "react-router-dom"; + +const AuthContext = createContext(); + +export const AuthProvider = ({ children }) => { + const authenticated = useSelector((state) => !!state.auth.id); + const location = useLocation(); + const navigate = useNavigate(); + console.log("isAuthenticated", isAuthenticated); + if (!authenticated && location.pathname !== "/") { + navigate("/"); + } + + return {children}; +}; + +export const useAuth = () => useContext(AuthContext); diff --git a/src/reducers/filter.ts b/src/reducers/filter.ts new file mode 100644 index 0000000..7da8e1a --- /dev/null +++ b/src/reducers/filter.ts @@ -0,0 +1,25 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +const initialState = { + sortBy: "date_desc", + limit: 50, + search: "", + mediaFilter: "all", +}; + +const filterSlice = createSlice({ + name: "selected", + initialState, + reducers: { + setSortBy: (state, action: PayloadAction) => { + state.sortBy = action.payload; + }, + setMediaFilter: (state, action: PayloadAction) => { + state.mediaFilter = action.payload; + }, + }, +}); + +export const { setSortBy, setMediaFilter } = filterSlice.actions; + +export default filterSlice.reducer; diff --git a/src/reducers/general.ts b/src/reducers/general.ts new file mode 100644 index 0000000..83c13a3 --- /dev/null +++ b/src/reducers/general.ts @@ -0,0 +1,35 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +const initialState = { + listView: false, + loadThumbnailsDisabled: false, + singleClickFolders: false, +}; + +const generalSlice = createSlice({ + name: "general", + initialState, + reducers: { + toggleListView: (state) => { + state.listView = !state.listView; + }, + setListView: (state, action: PayloadAction) => { + state.listView = action.payload; + }, + setLoadThumbnailsDisabled: (state, action: PayloadAction) => { + state.loadThumbnailsDisabled = action.payload; + }, + setSingleClickFolders: (state, action: PayloadAction) => { + state.singleClickFolders = action.payload; + }, + }, +}); + +export const { + toggleListView, + setListView, + setLoadThumbnailsDisabled, + setSingleClickFolders, +} = generalSlice.actions; + +export default generalSlice.reducer; diff --git a/src/reducers/leftSection.ts b/src/reducers/leftSection.ts new file mode 100644 index 0000000..7bb7a2b --- /dev/null +++ b/src/reducers/leftSection.ts @@ -0,0 +1,26 @@ +import { createSlice } from "@reduxjs/toolkit"; + +const initialState = { + drawOpen: false, +}; + +const leftSectionSlice = createSlice({ + name: "selected", + initialState, + reducers: { + toggleDrawer: (state) => { + state.drawOpen = !state.drawOpen; + }, + closeDrawer: (state) => { + state.drawOpen = false; + }, + openDrawer: (state) => { + state.drawOpen = true; + }, + }, +}); + +export const { toggleDrawer, closeDrawer, openDrawer } = + leftSectionSlice.actions; + +export default leftSectionSlice.reducer; diff --git a/src/reducers/selected.ts b/src/reducers/selected.ts new file mode 100644 index 0000000..add08c3 --- /dev/null +++ b/src/reducers/selected.ts @@ -0,0 +1,195 @@ +import { FileInterface } from "../types/file"; +import { createSlice, configureStore, PayloadAction } from "@reduxjs/toolkit"; +import { FolderInterface } from "../types/folders"; + +interface MainSecionType { + type: "" | "quick-item" | "file" | "folder"; + id: string; + file: FileInterface | null; + folder: FolderInterface | null; +} + +type MoveStateType = { + type: "" | "file" | "folder" | "multi-select"; + file: FileInterface | null; + folder: FolderInterface | null; +}; + +export interface SelectedStateType { + mainSection: MainSecionType; + popupModal: { + type: "" | "quick-item" | "file"; + file: FileInterface | null; + }; + multiSelectMode: boolean; + multiSelectMap: { + [key: string]: MainSecionType; + }; + multiSelectCount: number; + shareModal: { + file: FileInterface | null; + }; + moveModal: MoveStateType; + navigationMap: { + [key: string]: { + url: string; + scrollTop: number; + }; + }; +} + +const initialState: SelectedStateType = { + mainSection: { + type: "", + id: "", + file: null, + folder: null, + }, + popupModal: { + type: "", + file: null, + }, + multiSelectMode: false, + multiSelectMap: {}, + multiSelectCount: 0, + shareModal: { + file: null, + }, + moveModal: { + type: "", + file: null, + folder: null, + }, + navigationMap: {}, +}; + +const selectedSlice = createSlice({ + name: "selected", + initialState, + reducers: { + setMainSelect: (state, action: PayloadAction) => { + state.mainSection = action.payload; + }, + resetSelected: () => initialState, + setMultiSelectMode: (state, action: PayloadAction) => { + const currentSelection = { ...state.mainSection }; + state.mainSection = { type: "", id: "", file: null, folder: null }; + + const selects = action.payload; + + const selectsIds = selects.map((select) => select.id); + + for (const select of selects) { + if ( + state.multiSelectMap[select.id] && + state.multiSelectMap[select.id].type !== select.type + ) { + state.multiSelectMode = true; + state.multiSelectMap[select.id] = select; + } else if (state.multiSelectMap[select.id]) { + delete state.multiSelectMap[select.id]; + const newCount = state.multiSelectCount - 1; + if (newCount === 0) { + state.multiSelectMode = false; + } + state.multiSelectCount = newCount; + } else { + state.multiSelectMode = true; + state.multiSelectMap[select.id] = select; + state.multiSelectCount++; + } + } + + if ( + currentSelection.id !== "" && + !selectsIds.includes(currentSelection.id) + ) { + state.multiSelectMap[currentSelection.id] = currentSelection; + state.multiSelectCount++; + } + }, + resetMultiSelect: (state) => { + state.multiSelectMode = false; + state.multiSelectMap = {}; + state.multiSelectCount = 0; + }, + setPopupSelect: ( + state, + action: PayloadAction<{ + type: "quick-item" | "file"; + file: FileInterface; + }> + ) => { + state.popupModal = { + type: action.payload.type, + file: action.payload.file, + }; + }, + resetPopupSelect: (state) => { + state.popupModal = { + type: "", + file: null, + }; + }, + setShareModal: (state, action: PayloadAction) => { + state.popupModal = { + type: "", + file: null, + }; + state.shareModal = { + file: action.payload, + }; + }, + resetShareModal: (state) => { + state.shareModal = { + file: null, + }; + }, + setMoveModal: (state, action: PayloadAction) => { + state.moveModal = action.payload; + }, + resetMoveModal: (state) => { + state.moveModal = { + type: "", + file: null, + folder: null, + }; + }, + addNavigationMap: ( + state, + action: PayloadAction<{ + url: string; + scrollTop: number; + }> + ) => { + const navigationMap = state.navigationMap; + navigationMap[action.payload.url] = { + url: action.payload.url, + scrollTop: action.payload.scrollTop, + }; + state.navigationMap = navigationMap; + }, + removeNavigationMap: (state, action: PayloadAction) => { + const navigationMap = state.navigationMap; + delete navigationMap[action.payload]; + state.navigationMap = navigationMap; + }, + }, +}); + +export const { + setMainSelect, + resetSelected, + setMultiSelectMode, + resetMultiSelect, + setPopupSelect, + resetPopupSelect, + setShareModal, + resetShareModal, + setMoveModal, + resetMoveModal, + addNavigationMap, + removeNavigationMap, +} = selectedSlice.actions; + +export default selectedSlice.reducer; diff --git a/src/reducers/uploader.ts b/src/reducers/uploader.ts new file mode 100644 index 0000000..16aefa3 --- /dev/null +++ b/src/reducers/uploader.ts @@ -0,0 +1,60 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +export interface UploadItemType { + id: string; + progress: number; + name: string; + completed: boolean; + canceled: boolean; + size: number; + type: "file" | "folder"; +} + +interface UploaderStateType { + uploads: UploadItemType[]; +} + +const initialState: UploaderStateType = { + uploads: [], +}; + +const uploaderSlice = createSlice({ + name: "uploader", + initialState, + reducers: { + addUpload(state, action: PayloadAction) { + state.uploads.unshift(action.payload); + }, + editUpload( + state, + action: PayloadAction<{ + id: string; + updateData: { + progress?: number; + completed?: boolean; + canceled?: boolean; + }; + }> + ) { + const uploads = state.uploads.map((upload) => { + if (upload.id === action.payload.id) { + return { + ...upload, + ...action.payload.updateData, + }; + } else { + return upload; + } + }); + + state.uploads = uploads; + }, + resetUploads(state) { + state.uploads = []; + }, + }, +}); + +export const { addUpload, editUpload, resetUploads } = uploaderSlice.actions; + +export default uploaderSlice.reducer; diff --git a/src/reducers/user.ts b/src/reducers/user.ts new file mode 100644 index 0000000..f149a6d --- /dev/null +++ b/src/reducers/user.ts @@ -0,0 +1,32 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { UserType } from "../types/user"; + +interface UserStateType { + user?: null | UserType; + loggedIn: boolean; + lastRefreshed: number; +} + +const initialState: UserStateType = { + user: null, + loggedIn: false, + lastRefreshed: 0, +}; + +const userSlice = createSlice({ + name: "selected", + initialState, + reducers: { + setUser: (state, action: PayloadAction) => { + state.user = action.payload; + state.loggedIn = true; + }, + setLastRefreshed: (state) => { + state.lastRefreshed = Date.now(); + }, + }, +}); + +export const { setUser, setLastRefreshed } = userSlice.actions; + +export default userSlice.reducer; diff --git a/src/routers/AppRouter.jsx b/src/routers/AppRouter.jsx new file mode 100644 index 0000000..c5aaf03 --- /dev/null +++ b/src/routers/AppRouter.jsx @@ -0,0 +1,45 @@ +import React from "react"; +import { + BrowserRouter, + Route, + Routes, + Navigate +} from "react-router-dom"; +import LoginPage from "../components/LoginPage/LoginPage"; +import DownloadPage from "../components/DownloadPage/DownloadPage"; +import VerifyEmailPage from "../components/VerifyEmailPage/VerifyEmailPage"; +import ResetPasswordPage from "../components/ResetPasswordPage/ResetPasswordPage"; +import Homepage from "../components/Homepage/Homepage"; +import SettingsPage from "../components/SettingsPage/SettingsPage"; +import LandingPage from '../components/LandingPage/LandingPage'; +import PrivateRoute from "./PrivateRoute"; + +const AppRouter = () => { + return ( + + + {/* PUBLIC ROUTES */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* PROTECTED ROUTES */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +}; + +export default AppRouter; \ No newline at end of file diff --git a/src/routers/PrivateRoute.jsx b/src/routers/PrivateRoute.jsx new file mode 100644 index 0000000..6e6ae28 --- /dev/null +++ b/src/routers/PrivateRoute.jsx @@ -0,0 +1,17 @@ +import React from "react"; +import { connect, useSelector } from "react-redux"; +import { Route, Navigate, useLocation } from "react-router-dom"; +import { useAppSelector } from "../hooks/store"; + +const PrivateRoute = ({ children }) => { + const isAuthenticated = useAppSelector((state) => state.user.loggedIn); + console.log("isAuthenticated", isAuthenticated); + const location = useLocation(); + if (!isAuthenticated) { + return ; + } + + return children; +}; + +export default PrivateRoute; diff --git a/src/routers/PublicRoute.jsx b/src/routers/PublicRoute.jsx new file mode 100644 index 0000000..502a78e --- /dev/null +++ b/src/routers/PublicRoute.jsx @@ -0,0 +1,21 @@ +import { connect } from "react-redux"; +import React from "react"; +import { Route, Navigate } from "react-router-dom"; + +export const PublicRoute = ( + { isAuthenticated, component: Component }, + ...rest +) => ( + + isAuthenticated ? : + } + /> +); + +const connectStateToProps = (state) => ({ + isAuthenticated: !!state.auth.id, +}); + +export default connect(connectStateToProps)(PublicRoute); diff --git a/src/store/configureStore.ts b/src/store/configureStore.ts new file mode 100644 index 0000000..ca3350f --- /dev/null +++ b/src/store/configureStore.ts @@ -0,0 +1,23 @@ +import { configureStore } from "@reduxjs/toolkit"; +import filterReducer from "../reducers/filter"; +import selectedReducer from "../reducers/selected"; +import leftSectionReducer from "../reducers/leftSection"; +import userReducer from "../reducers/user"; +import uploaderReducer from "../reducers/uploader"; +import generalReducer from "../reducers/general"; + +const store = configureStore({ + reducer: { + general: generalReducer, + filter: filterReducer, + selected: selectedReducer, + leftSection: leftSectionReducer, + user: userReducer, + uploader: uploaderReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; + +export default store; diff --git a/src/styles/base/_base.scss b/src/styles/base/_base.scss new file mode 100644 index 0000000..f3eedde --- /dev/null +++ b/src/styles/base/_base.scss @@ -0,0 +1,50 @@ +* { + box-sizing: border-box; +} + +html { +} + +input[type="text"], +input[type="email"], +input[type="password"], +textarea { + -webkit-appearance: none; +} + +html, body, #root, .landing-page { + overflow-x: hidden; + overflow-y: auto !important; + height: auto !important; + position: static !important; +} + + + + +button { + cursor: pointer; +} + +button:disabled { + cursor: default; +} +body, +html { + margin: 0px; + padding: 0px; + & input[type="text"], + & input[type="submit"] { + outline: none; + } +} +body { + & * { + box-sizing: border-box; + font-family: sans-serif; + } +} + +.dynamic-height { + height: 100dvh; +} diff --git a/src/styles/components/_Spinner.scss b/src/styles/components/_Spinner.scss new file mode 100644 index 0000000..bcbf5c9 --- /dev/null +++ b/src/styles/components/_Spinner.scss @@ -0,0 +1,125 @@ +.spinner { + border: 3px solid #f3f3f3; + border-top: 3px solid #3498db; + border-radius: 50%; + width: 50px; + height: 50px; + text-align: center; + animation: spin 2s linear infinite; +} + +.spinner-small { + border: 3px solid #f3f3f3; + border-top: 3px solid #3498db; + border-radius: 50%; + width: 20px; + height: 20px; + text-align: center; + animation: spin 2s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.spinner--small-margin { + margin-left: 4px; +} + +.spinner__no-margin { + margin: 0; + margin-bottom: 108px; +} + +.homepage__spinner__wrapper { + position: fixed; + height: 100dvh; + width: 100vw; + display: flex; + justify-content: center; + align-items: center; +} + +/* Style the background of the progress bar */ +.custom-progress::-webkit-progress-bar { + background-color: #e0dcf3; /* Light gray for the background */ + border-radius: 3px; + height: 5px; +} + +/* Style the filled portion of the progress bar */ +.custom-progress::-webkit-progress-value { + background-color: #3c85ee; /* Blue for progress */ + border-radius: 3px; +} + +/* For Firefox */ +.custom-progress::-moz-progress-bar { + background-color: #3c85ee; /* Blue for progress */ + border-radius: 3px; +} + +/* Adjust the overall progress bar appearance */ +.custom-progress { + width: 100%; + height: 5px; + appearance: none; /* Remove default styling */ + border: none; +} + +.custom-progress-failed { + width: 100%; + height: 5px; + appearance: none; + border-radius: 3px; + border: none; + background-color: red; + margin-top: 15px; +} + +.custom-progress-success { + width: 100%; + height: 5px; + appearance: none; + border-radius: 3px; + border: none; + margin-top: 15px; + @apply bg-green-600; +} + +.custom-progress.indeterminate { + position: relative; + overflow: hidden; +} + +/* Animation for indeterminate progress */ +.custom-progress.indeterminate::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 50%; + height: 100%; + background: linear-gradient(90deg, transparent, #3c85ee, transparent); + animation: loading 1.5s infinite; +} + +@keyframes loading { + 0% { + left: -50%; + width: 50%; + } + 50% { + left: 25%; + width: 50%; + } + 100% { + left: 100%; + width: 50%; + } +} diff --git a/src/styles/components/_Swal.scss b/src/styles/components/_Swal.scss new file mode 100644 index 0000000..4a25f20 --- /dev/null +++ b/src/styles/components/_Swal.scss @@ -0,0 +1,15 @@ +.swal2-popup { + font-size: 10px !important; + + @media (max-width: 480px) { + font-size: 13px !important; + } +} + +.swal2-confirm { + background-color: #3c85ee !important; +} + +.swal2-cancel { + background-color: red !important; +} diff --git a/src/styles/styles.scss b/src/styles/styles.scss new file mode 100644 index 0000000..5276e03 --- /dev/null +++ b/src/styles/styles.scss @@ -0,0 +1,49 @@ +@use "./base/base"; +@use "./components/Spinner"; +@use "./components/Swal"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + + + +.animate { + transition: 0.2s ease all; +} + +.animate-easy { + transition: 0.3s ease all; +} + +.animate-movement { + transition: 0.4s ease all; +} + +.animate-medium { + transition: 0.6s ease all; +} + +.animate-very-long { + transition: 6s ease all; +} + +.Toastify__progress-bar--success { + background-color: #3c85ee !important; +} + +.--toastify-color-transparent .Toastify__toast-icon { + background-color: #3c85ee !important; +} + +.Toastify__toast--success .Toastify__icon svg path { + fill: #3c85ee !important; +} + +:root { + --toastify-icon-color-success: #3c85ee !important; +} + +.disable-force-touch { + -webkit-touch-callout: none; +} diff --git a/src/types/file.ts b/src/types/file.ts new file mode 100644 index 0000000..9fb66b6 --- /dev/null +++ b/src/types/file.ts @@ -0,0 +1,43 @@ +export interface FileInterface { + _id: string; + length: number; + chunkSize: number; + uploadDate: string; + filename: string; + metadata: { + owner: string; + parent: string; + parentList: string; + hasThumbnail: boolean; + isVideo: boolean; + thumbnailID?: string; + size: number; + IV: Buffer; + linkType?: "one" | "public"; + link?: string; + filePath?: string; + s3ID?: string; + personalFile?: boolean; + trashed?: boolean; + }; +} +interface example { + selectedRightSectionItem: { + folder?: { + id: string; + name: string; + }; + file?: { + id: string; + filename: string; + }; + }; + popupFile: { + id: string; + filename: string; + }; + selectedItem: { + type: string; + id: string; + }; +} diff --git a/src/types/folders.ts b/src/types/folders.ts new file mode 100644 index 0000000..9cbbbed --- /dev/null +++ b/src/types/folders.ts @@ -0,0 +1,11 @@ +export interface FolderInterface { + _id: string; + name: string; + parent: string; + owner: string; + createdAt: Date; + updatedAt: Date; + parentList: string[]; + personalFolder?: boolean; + trashed?: boolean; +} diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 0000000..8737c8d --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,5 @@ +export interface UserType { + name: string; + email: string; + emailVerified?: boolean; +} diff --git a/src/utils/InternalServerError.js b/src/utils/InternalServerError.js new file mode 100644 index 0000000..5556ea5 --- /dev/null +++ b/src/utils/InternalServerError.js @@ -0,0 +1,10 @@ +class InternalServerError extends Error { + + constructor(args) { + super(args); + + this.code = 500; + } +} + +module.exports = InternalServerError; \ No newline at end of file diff --git a/src/utils/NotAuthorizedError.js b/src/utils/NotAuthorizedError.js new file mode 100644 index 0000000..d28fd6e --- /dev/null +++ b/src/utils/NotAuthorizedError.js @@ -0,0 +1,10 @@ +class NotAuthorizedError extends Error { + + constructor(args) { + super(args); + + this.code = 401; + } +} + +module.exports = NotAuthorizedError; \ No newline at end of file diff --git a/src/utils/NotFoundError.js b/src/utils/NotFoundError.js new file mode 100644 index 0000000..128205f --- /dev/null +++ b/src/utils/NotFoundError.js @@ -0,0 +1,10 @@ +class NotFoundError extends Error { + + constructor(args) { + super(args); + + this.code = 404; + } +} + +module.exports = NotFoundError; \ No newline at end of file diff --git a/src/utils/PWAUtils.ts b/src/utils/PWAUtils.ts new file mode 100644 index 0000000..8a736b9 --- /dev/null +++ b/src/utils/PWAUtils.ts @@ -0,0 +1,6 @@ +export const isPwa = () => { + return ["fullscreen", "standalone", "minimal-ui"].some( + (displayMode) => + window.matchMedia("(display-mode: " + displayMode + ")").matches + ); +}; diff --git a/src/utils/cancelTokenManager.ts b/src/utils/cancelTokenManager.ts new file mode 100644 index 0000000..55aaa99 --- /dev/null +++ b/src/utils/cancelTokenManager.ts @@ -0,0 +1,28 @@ +interface MapType { + [key: string]: { + token: any; + cancel: () => {}; + }; +} + +const cancelTokens: MapType = {}; + +// TODO: Fix any +export const addFileUploadCancelToken = (id: string, cancelToken: any) => { + cancelTokens[id] = cancelToken; +}; + +export const removeFileUploadCancelToken = (id: string) => { + delete cancelTokens[id]; +}; + +export const getCancelToken = (id: string) => { + return cancelTokens[id]; +}; + +export const cancelAllFileUploads = () => { + for (const key in cancelTokens) { + cancelTokens[key].cancel(); + delete cancelTokens[key]; + } +}; diff --git a/src/utils/capitalize.ts b/src/utils/capitalize.ts new file mode 100644 index 0000000..9cfe04a --- /dev/null +++ b/src/utils/capitalize.ts @@ -0,0 +1,9 @@ +const capitalize = (name: string) => { + if (name.length <= 1) { + return name.toUpperCase(); + } else { + return name.substring(0, 1).toUpperCase() + name.substring(1); + } +}; + +export default capitalize; diff --git a/src/utils/convertDriveListToMongoList.js b/src/utils/convertDriveListToMongoList.js new file mode 100644 index 0000000..6b079c6 --- /dev/null +++ b/src/utils/convertDriveListToMongoList.js @@ -0,0 +1,14 @@ +import convertDriveToMongo from "./convertDriveToMongo"; + +const convertDriveListToMongoList = (driveObjs, ownerID) => { + + let convertedObjs = []; + + for (let currentObj of driveObjs) { + convertedObjs.push(convertDriveToMongo(currentObj, ownerID)); + } + + return convertedObjs; +} + +export default convertDriveListToMongoList; \ No newline at end of file diff --git a/src/utils/convertDriveToMongo.js b/src/utils/convertDriveToMongo.js new file mode 100644 index 0000000..59f1ed9 --- /dev/null +++ b/src/utils/convertDriveToMongo.js @@ -0,0 +1,23 @@ +const convertDriveToMongo = (driveObj, ownerID) => { + + let convertedObj = {}; + convertedObj._id = driveObj.id; + convertedObj.filename = driveObj.name; + convertedObj.length = driveObj.size; + convertedObj.uploadDate = driveObj.modifiedTime; + convertedObj.metadata = { + IV: "", + hasThumbnail: driveObj.hasThumbnail, + isVideo: false, + owner: ownerID, + parent: driveObj.parents[driveObj.parents.length - 1] === "root" ? "/" : driveObj.parents[driveObj.parents.length - 1], + parentList: driveObj.parents, + size: driveObj.length, + drive: true, + thumbnailID: driveObj.thumbnailLink + } + + return convertedObj; +} + +export default convertDriveToMongo; \ No newline at end of file diff --git a/src/utils/createError.js b/src/utils/createError.js new file mode 100644 index 0000000..6e9efc1 --- /dev/null +++ b/src/utils/createError.js @@ -0,0 +1,12 @@ +const createError = (message, code, exception) => { + + let error = new Error(message); + error.message = message + error.code = code + error.exception = exception + + throw error; + +} + +module.exports = createError; \ No newline at end of file diff --git a/src/utils/createQuery.js b/src/utils/createQuery.js new file mode 100644 index 0000000..c4f6751 --- /dev/null +++ b/src/utils/createQuery.js @@ -0,0 +1,41 @@ +const createQuery = (owner, parent,sortBy, startAt, startAtDate,searchQuery, startAtName) => { + + let query = {"metadata.owner": owner} + + if (searchQuery !== "") { + + searchQuery = new RegExp(searchQuery, 'i') + + query = {...query, filename: searchQuery} + + } else { + + query = {...query, "metadata.parent": parent} + } + + if (startAt) { + + if (sortBy === "date_desc" || sortBy === "DEFAULT") { + + query = {...query, "uploadDate": {$lt: new Date(startAtDate)}} + + } else if (sortBy === "date_asc") { + + query = {...query, "uploadDate": {$gt: new Date(startAtDate)}} + + } else if (sortBy === "alp_desc") { + + query = {...query, "filename": {$lt: startAtName}} + + } else { + + query = {...query, "filename": {$gt: startAtName}} + } + } + + + return query; + +} + +module.exports = createQuery \ No newline at end of file diff --git a/src/utils/files.ts b/src/utils/files.ts new file mode 100644 index 0000000..7d69fcc --- /dev/null +++ b/src/utils/files.ts @@ -0,0 +1,59 @@ +export const getFileExtension = (filename: string, length = 4) => { + const filenameSplit = filename.split("."); + + if (filenameSplit.length > 1) { + let extension = filenameSplit[filenameSplit.length - 1]; + + if (extension.length > length) + extension = + extension.substring(0, length - 1) + + extension.substring(extension.length - 1, extension.length); + + return extension.toUpperCase(); + } else { + return "UNK"; + } +}; + +type ColorMap = { + [key: string]: string; +}; + +export const getFileColor = (filename: string) => { + const letter = getFileExtension(filename).substring(0, 1).toUpperCase(); + + const colorMap: ColorMap = { + A: "#e53935", + B: "#d81b60", + C: "#8e24aa", + D: "#5e35b1", + E: "#3949ab", + F: "#1e88e5", + G: "#039be5", + H: "#00acc1", + I: "#00897b", + J: "#43a047", + K: "#fdd835", + L: "#ffb300", + M: "#fb8c00", + N: "#f4511e", + O: "#d32f2f", + P: "#c2185b", + Q: "#7b1fa2", + R: "#512da8", + S: "#303f9f", + T: "#1976d2", + U: "#0288d1", + V: "#0097a7", + W: "#0097a7", + X: "#00796b", + Y: "#388e3c", + Z: "#fbc02d", + }; + + if (colorMap[letter]) { + return colorMap[letter]; + } else { + return "#03a9f4"; + } +}; diff --git a/src/utils/getBackendURL.ts b/src/utils/getBackendURL.ts new file mode 100644 index 0000000..9cf3970 --- /dev/null +++ b/src/utils/getBackendURL.ts @@ -0,0 +1,18 @@ +const getBackendURL = () => { + // @ts-ignore + const envURL = import.meta.env.VITE_BACKEND_URL; + + if (envURL) { + return envURL; + } + + const mode = process.env.NODE_ENV; + + if (mode === "development") { + return "http://localhost:5173/api"; + } + + return window.location.origin; +}; + +export default getBackendURL; diff --git a/src/utils/imageChecker.js b/src/utils/imageChecker.js new file mode 100644 index 0000000..2df835e --- /dev/null +++ b/src/utils/imageChecker.js @@ -0,0 +1,31 @@ +const videoExtList = [ + "jpeg", + "jpg", + "png", + "gif", + "svg", + "tiff", + "bmp" +] + +const videoChecker = (filename) => { + + if (filename.length < 1 || !filename.includes(".")) { + + return false; + } + + const extSplit = filename.split("."); + + if (extSplit < 1) { + + return false; + } + + const ext = extSplit[extSplit.length - 1]; + + return videoExtList.includes(ext.toLowerCase()); + +} + +module.exports = videoChecker; \ No newline at end of file diff --git a/src/utils/mobileCheck.ts b/src/utils/mobileCheck.ts new file mode 100644 index 0000000..8e9b37a --- /dev/null +++ b/src/utils/mobileCheck.ts @@ -0,0 +1,17 @@ +const mobilecheck = () => { + var check = false; + (function (a) { + if ( + /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test( + a + ) || + /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test( + a.substr(0, 4) + ) + ) + check = true; + // @ts-ignore + })(navigator.userAgent || navigator.vendor || window?.opera); + return check; +}; +export default mobilecheck; diff --git a/src/utils/reduceQuickItemList.js b/src/utils/reduceQuickItemList.js new file mode 100644 index 0000000..15b6fe4 --- /dev/null +++ b/src/utils/reduceQuickItemList.js @@ -0,0 +1,15 @@ +import mobilecheck from "./mobileCheck"; + +const reduceQuickItemList = (quickItemList) => { + const isMobile = mobilecheck(); + + if (quickItemList.length > 10 && !isMobile) { + quickItemList = quickItemList.slice(0, 10); + } else if (quickItemList.length > 2 && isMobile) { + quickItemList = quickItemList.slice(0, 2); + } + + return quickItemList; +}; + +export default reduceQuickItemList; diff --git a/src/utils/sortBySwitch.js b/src/utils/sortBySwitch.js new file mode 100644 index 0000000..d0c43c3 --- /dev/null +++ b/src/utils/sortBySwitch.js @@ -0,0 +1,15 @@ +const sortBySwitch = (sortBy) => { + switch(sortBy) { + + case "alp_asc": + return {filename: 1} + case "alp_desc": + return {filename: -1} + case "date_asc": + return {uploadDate: 1} + default: + return {uploadDate: -1} + } +} + +module.exports = sortBySwitch; \ No newline at end of file diff --git a/src/utils/sortBySwitchFolder.js b/src/utils/sortBySwitchFolder.js new file mode 100644 index 0000000..314397e --- /dev/null +++ b/src/utils/sortBySwitchFolder.js @@ -0,0 +1,15 @@ +const sortBySwitch = (sortBy) => { + switch(sortBy) { + + case "alp_asc": + return {name: 1} + case "alp_desc": + return {name: -1} + case "date_asc": + return {createdAt: 1} + default: + return {createdAt: -1} + } +} + +module.exports = sortBySwitch; \ No newline at end of file diff --git a/src/utils/updateSettings.js b/src/utils/updateSettings.js new file mode 100644 index 0000000..6918123 --- /dev/null +++ b/src/utils/updateSettings.js @@ -0,0 +1,9 @@ +let updateSettingsID = ""; + +export const getUpdateSettingsID = () => { + return updateSettingsID; +} + +export const setUpdateSettingsID = (id) => { + updateSettingsID = id; +} \ No newline at end of file diff --git a/src/utils/videoChecker.js b/src/utils/videoChecker.js new file mode 100644 index 0000000..e3c31f6 --- /dev/null +++ b/src/utils/videoChecker.js @@ -0,0 +1,57 @@ +const videoExtList = [ + "3g2", + "3gp", + "aaf", + "asf", + "avchd", + "avi", + "drc", + "flv", + "m2v", + "m4p", + "m4v", + "mkv", + "mng", + "mov", + "mp2", + "mp4", + "mpe", + "mpeg", + "mpg", + "mpv", + "mxf", + "nsv", + "ogg", + "ogv", + "qt", + "rm", + "rmvb", + "roq", + "svi", + "vob", + "webm", + "wmv", + "yuv" +] + +const videoChecker = (filename) => { + + if (filename.length < 1 || !filename.includes(".")) { + + return false; + } + + const extSplit = filename.split("."); + + if (extSplit < 1) { + + return false; + } + + const ext = extSplit[extSplit.length - 1]; + + return videoExtList.includes(ext.toLowerCase()); + +} + +module.exports = videoChecker; \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..1262326 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,42 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + "./public/index.html", + ], + theme: { + extend: { + colors: { + primary: "#3c85ee", + "primary-hover": "#326bcc", + "white-hover": "#f6f5fd", + "gray-primary": "#637381", + "gray-secondary": "#e8eef2", + "gray-third": "#ebe9f9", + "light-primary": "rgba(60, 133, 238, 0.4)", + }, + }, + screens: { + quickAccessOne: "1000px", + quickAccessTwo: "1210px", + quickAccessThree: "1420px", + quickAccessFour: "1600px", + xxs: "360px", + xs: "480px", + sm: "640px", + md: "768px", + lg: "1024px", + xl: "1280px", + xxl: "1536px", + fileTextXL: "1600px", + fileTextLG: "1400px", + fileTextMD: "1200px", + fileTextSM: "1000px", + fileTextXSM: "900px", + fileListShowDetails: "680px", + desktopMode: "1100px", + }, + }, + plugins: [], +}; diff --git a/tests/controller/file-controller.test.js b/tests/controller/file-controller.test.js new file mode 100644 index 0000000..01547ab --- /dev/null +++ b/tests/controller/file-controller.test.js @@ -0,0 +1,1600 @@ +const request = require("supertest"); +const app = require("../utils/express-app"); +const mongoose = require("mongoose"); +const { MongoMemoryServer } = require("mongodb-memory-server"); +const getEnvVariables = require("../../dist-backend/enviroment/get-env-variables"); +const getKey = require("../../dist-backend/key/get-key").default; +getEnvVariables(); +const env = require("../../dist-backend/enviroment/env"); +const { envFileFix } = require("../utils/db-setup"); +const { ObjectId } = require("mongodb"); + +let mongoServer; +let authToken; +let authToken2; +let file; +let file2; +let user; +let user2; + +describe("File Controller", () => { + beforeAll(async () => { + envFileFix(env); + await getKey(); + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri(), { useNewUrlParser: true }); + user = await request(app) + .post("/user-service/create") + .send({ + email: "test@test.com", + password: "test1234", + }) + .set("uuid", 12314123123); + + user2 = await request(app) + .post("/user-service/create") + .send({ + email: "test@test2.com", + password: "test1234", + }) + .set("uuid", 12314123124); + + authToken = user.headers["set-cookie"] + .map((cookie) => cookie.split(";")[0]) + .join("; "); + + authToken2 = user2.headers["set-cookie"] + .map((cookie) => cookie.split(";")[0]) + .join("; "); + }); + + beforeEach(async () => { + await mongoose.model("fs.files").deleteMany({}); + await mongoose.model("Folder").deleteMany({}); + + file = await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c2"), + filename: "test.txt", + uploadDate: new Date(), + length: 10000, + metadata: { + owner: user.body.user._id, + parent: "/", + parentList: "/", + hasThumbnail: false, + size: "10000", + IV: "test", + isVideo: false, + }, + }); + + file2 = await mongoose.model("fs.files").create({ + _id: new ObjectId("4eb88f29ecb8c9319ddca3c2"), + filename: "test.txt2", + uploadDate: new Date(), + length: 10000, + metadata: { + owner: user2.body.user._id, + parent: "/", + parentList: "/", + hasThumbnail: false, + size: "10000", + IV: "test", + isVideo: false, + }, + }); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + describe("File info: GET /file-service/info/:id", () => { + test("Should return file info", async () => { + const fileResponse = await request(app) + .get(`/file-service/info/${file._id}`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.filename).toBe(file.filename); + expect(fileResponse.body.length).toBe(file.length); + }); + test("Should return user 2's file info", async () => { + const fileResponse = await request(app) + .get(`/file-service/info/${file2._id}`) + .set("Cookie", authToken2); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.filename).toBe(file2.filename); + }); + test("Should return 404 if file not found", async () => { + const fileResponse = await request(app) + .get(`/file-service/info/5f7e5d8d1f962d5a0f5e8a9e`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(404); + }); + test("Should return 401 if not authorized", async () => { + const fileResponse = await request(app) + .get(`/file-service/info/${file._id}`) + .set("Cookie", "access-token=test"); + + expect(fileResponse.status).toBe(401); + }); + test("Should return 401/404 if not owner of file", async () => { + const fileResponse = await request(app) + .get(`/file-service/info/${file2._id}`) + .set("Cookie", authToken); + + expect([401, 404]).toContain(fileResponse.status); + }); + }); + + describe("File rename: PATCH /file-service/rename", () => { + test("Should rename file", async () => { + const fileResponse = await request(app) + .patch(`/file-service/rename`) + .set("Cookie", authToken) + .send({ + id: file._id, + title: "newname.txt", + }); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.filename).toBe("newname.txt"); + + const fileDbCheck = await mongoose.model("fs.files").findOne({ + _id: file._id, + }); + + expect(fileDbCheck.filename).toBe("newname.txt"); + }); + test("Should rename user 2's file", async () => { + const fileResponse = await request(app) + .patch(`/file-service/rename`) + .set("Cookie", authToken2) + .send({ + id: file2._id, + title: "newname.txt", + }); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.filename).toBe("newname.txt"); + + const fileDbCheck = await mongoose.model("fs.files").findOne({ + _id: file2._id, + }); + + expect(fileDbCheck.filename).toBe("newname.txt"); + }); + test("Should return 404 if file not found", async () => { + const fileResponse = await request(app) + .patch(`/file-service/rename`) + .set("Cookie", authToken) + .send({ + id: "5f7e5d8d1f962d5a0f5e8a9e", + title: "newname.txt", + }); + + expect(fileResponse.status).toBe(404); + }); + test("Should return 401 if not authorized", async () => { + const fileResponse = await request(app) + .patch(`/file-service/rename`) + .set("Cookie", "access-token=test") + .send({ + id: file._id, + title: "newname.txt", + }); + + expect(fileResponse.status).toBe(401); + }); + test("Should return 401/404 if not owner of file", async () => { + const fileResponse = await request(app) + .patch(`/file-service/rename`) + .set("Cookie", authToken) + .send({ + id: file2._id, + title: "newname.txt", + }); + + expect([401, 404]).toContain(fileResponse.status); + }); + test("Should return 400 if no title", async () => { + const fileResponse = await request(app) + .patch(`/file-service/rename`) + .set("Cookie", authToken) + .send({ + id: file._id, + }); + + expect(fileResponse.status).toBe(400); + }); + test("Should return 400 if title length is less than 1", async () => { + const fileResponse = await request(app) + .patch(`/file-service/rename`) + .set("Cookie", authToken) + .send({ + id: file._id, + title: "", + }); + + expect(fileResponse.status).toBe(400); + }); + test("Should return 400 if title length is greater than 256", async () => { + const fileResponse = await request(app) + .patch(`/file-service/rename`) + .set("Cookie", authToken) + .send({ + id: file._id, + title: "a".repeat(257), + }); + + expect(fileResponse.status).toBe(400); + }); + test("Should return 400 if no id", async () => { + const fileResponse = await request(app) + .patch(`/file-service/rename`) + .set("Cookie", authToken) + .send({ + title: "newname.txt", + }); + + expect(fileResponse.status).toBe(400); + }); + }); + + describe("Trash file: PATCH /file-service/trash", () => { + test("Should trash file", async () => { + const fileResponse = await request(app) + .patch(`/file-service/trash`) + .set("Cookie", authToken) + .send({ + id: file._id, + }); + + expect(fileResponse.status).toBe(200); + + const fileDbCheck = await mongoose.model("fs.files").findOne({ + _id: file._id, + }); + + expect(fileDbCheck.metadata.trashed).toBe(true); + }); + test("Should trash user 2's file", async () => { + const fileResponse = await request(app) + .patch(`/file-service/trash`) + .set("Cookie", authToken2) + .send({ + id: file2._id, + }); + + expect(fileResponse.status).toBe(200); + + const fileDbCheck = await mongoose.model("fs.files").findOne({ + _id: file2._id, + }); + + expect(fileDbCheck.metadata.trashed).toBe(true); + }); + test("Should return 404 if file not found", async () => { + const fileResponse = await request(app) + .patch(`/file-service/trash`) + .set("Cookie", authToken) + .send({ + id: "5f7e5d8d1f962d5a0f5e8a9e", + }); + + expect(fileResponse.status).toBe(404); + }); + test("Should return 401 if not authorized", async () => { + const fileResponse = await request(app) + .patch(`/file-service/trash`) + .set("Cookie", "access-token=test") + .send({ + id: file._id, + }); + + expect(fileResponse.status).toBe(401); + }); + test("Should return 401/404 if not owner of file", async () => { + const fileResponse = await request(app) + .patch(`/file-service/trash`) + .set("Cookie", authToken) + .send({ + id: file2._id, + }); + + expect([401, 404]).toContain(fileResponse.status); + }); + test("Should return 400 if no id", async () => { + const fileResponse = await request(app) + .patch(`/file-service/trash`) + .set("Cookie", authToken) + .send(); + + expect(fileResponse.status).toBe(400); + }); + }); + + describe("Restore file: PATCH /file-service/restore", () => { + test("Should restore file", async () => { + await mongoose + .model("fs.files") + .updateOne({ _id: file._id }, { $set: { "metadata.trashed": true } }); + + const fileResponse = await request(app) + .patch(`/file-service/restore`) + .set("Cookie", authToken) + .send({ + id: file._id, + }); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.metadata.trashed).toBe(null); + + const fileDbCheck = await mongoose.model("fs.files").findOne({ + _id: file._id, + }); + + expect(fileDbCheck.metadata.trashed).toBe(null); + }); + test("Should restore user 2's file", async () => { + await mongoose + .model("fs.files") + .updateOne({ _id: file2._id }, { $set: { "metadata.trashed": true } }); + + const fileResponse = await request(app) + .patch(`/file-service/restore`) + .set("Cookie", authToken2) + .send({ + id: file2._id, + }); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.metadata.trashed).toBe(null); + + const fileDbCheck = await mongoose.model("fs.files").findOne({ + _id: file2._id, + }); + + expect(fileDbCheck.metadata.trashed).toBe(null); + }); + test("Should return 404 if file not found", async () => { + const fileResponse = await request(app) + .patch(`/file-service/restore`) + .set("Cookie", authToken) + .send({ + id: "5f7e5d8d1f962d5a0f5e8a9e", + }); + + expect(fileResponse.status).toBe(404); + }); + test("Should return 401 if not authorized", async () => { + const fileResponse = await request(app) + .patch(`/file-service/restore`) + .set("Cookie", "access-token=test") + .send({ + id: file._id, + }); + + expect(fileResponse.status).toBe(401); + }); + test("Should return 401/404 if not owner of file", async () => { + const fileResponse = await request(app) + .patch(`/file-service/restore`) + .set("Cookie", authToken) + .send({ + id: file2._id, + }); + + expect([401, 404]).toContain(fileResponse.status); + }); + test("Should return 400 if no id", async () => { + const fileResponse = await request(app) + .patch(`/file-service/restore`) + .set("Cookie", authToken) + .send(); + + expect(fileResponse.status).toBe(400); + }); + }); + + describe("Make public file: PATCH /file-service/make-public", () => { + test("Should make file public", async () => { + const fileResponse = await request(app) + .patch(`/file-service/make-public/${file._id}`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + + const fileDbCheck = await mongoose.model("fs.files").findOne({ + _id: file._id, + }); + + expect(fileDbCheck.metadata.link).toBeTruthy(); + expect(fileDbCheck.metadata.linkType).toBe("public"); + }); + test("Should make user 2's file public", async () => { + const fileResponse = await request(app) + .patch(`/file-service/make-public/${file2._id}`) + .set("Cookie", authToken2); + + expect(fileResponse.status).toBe(200); + + const fileDbCheck = await mongoose.model("fs.files").findOne({ + _id: file2._id, + }); + + expect(fileDbCheck.metadata.link).toBeTruthy(); + expect(fileDbCheck.metadata.linkType).toBe("public"); + }); + test("Should return 404 if file not found", async () => { + const fileResponse = await request(app) + .patch(`/file-service/make-public/5f7e5d8d1f962d5a0f5e8a9e`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(404); + }); + test("Should return 401 if not authorized", async () => { + const fileResponse = await request(app) + .patch(`/file-service/make-public/${file._id}`) + .set("Cookie", "access-token=test"); + + expect(fileResponse.status).toBe(401); + }); + test("Should return 401/404 if not owner of file", async () => { + const fileResponse = await request(app) + .patch(`/file-service/make-public/${file2._id}`) + .set("Cookie", authToken); + + expect([401, 404]).toContain(fileResponse.status); + }); + }); + + describe("Make one time public file: PATCH /file-service/make-one/", () => { + test("Should make file one time public", async () => { + const fileResponse = await request(app) + .patch(`/file-service/make-one/${file._id}`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + + const fileDbCheck = await mongoose.model("fs.files").findOne({ + _id: file._id, + }); + + expect(fileDbCheck.metadata.link).toBeTruthy(); + expect(fileDbCheck.metadata.linkType).toBe("one"); + }); + test("Should make user 2's file one time public", async () => { + const fileResponse = await request(app) + .patch(`/file-service/make-one/${file2._id}`) + .set("Cookie", authToken2); + + expect(fileResponse.status).toBe(200); + + const fileDbCheck = await mongoose.model("fs.files").findOne({ + _id: file2._id, + }); + + expect(fileDbCheck.metadata.link).toBeTruthy(); + expect(fileDbCheck.metadata.linkType).toBe("one"); + }); + test("Should return 404 if file not found", async () => { + const fileResponse = await request(app) + .patch(`/file-service/make-one/5f7e5d8d1f962d5a0f5e8a9e`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(404); + }); + test("Should return 401 if not authorized", async () => { + const fileResponse = await request(app) + .patch(`/file-service/make-one/${file._id}`) + .set("Cookie", "access-token=test"); + + expect(fileResponse.status).toBe(401); + }); + test("Should return 401/404 if not owner of file", async () => { + const fileResponse = await request(app) + .patch(`/file-service/make-one/${file2._id}`) + .set("Cookie", authToken); + + expect([401, 404]).toContain(fileResponse.status); + }); + }); + + describe("Make private file: PATCH /file-service/remove-link/:id", () => { + test("Should make file private", async () => { + await mongoose + .model("fs.files") + .updateOne( + { _id: file._id }, + { $set: { "metadata.link": "test", "metadata.linkType": "public" } } + ); + + const fileResponse = await request(app) + .patch(`/file-service/remove-link/${file._id}`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + + const fileDbCheck = await mongoose.model("fs.files").findOne({ + _id: file._id, + }); + + expect(fileDbCheck.metadata.link).toBeFalsy(); + expect(fileDbCheck.metadata.linkType).toBeFalsy(); + }); + test("Should make user 2's file private", async () => { + await mongoose + .model("fs.files") + .updateOne( + { _id: file2._id }, + { $set: { "metadata.link": "test", "metadata.linkType": "public" } } + ); + + const fileResponse = await request(app) + .patch(`/file-service/remove-link/${file2._id}`) + .set("Cookie", authToken2); + + expect(fileResponse.status).toBe(200); + + const fileDbCheck = await mongoose.model("fs.files").findOne({ + _id: file2._id, + }); + + expect(fileDbCheck.metadata.link).toBeFalsy(); + expect(fileDbCheck.metadata.linkType).toBeFalsy(); + }); + test("Should return 404 if file not found", async () => { + const fileResponse = await request(app) + .patch(`/file-service/remove-link/5f7e5d8d1f962d5a0f5e8a9e`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(404); + }); + test("Should return 401 if not authorized", async () => { + const fileResponse = await request(app) + .patch(`/file-service/remove-link/${file._id}`) + .set("Cookie", "access-token=test"); + + expect(fileResponse.status).toBe(401); + }); + test("Should return 401/404 if not owner of file", async () => { + const fileResponse = await request(app) + .patch(`/file-service/remove-link/${file2._id}`) + .set("Cookie", authToken); + + expect([401, 404]).toContain(fileResponse.status); + }); + }); + + describe("Get public file info: GET /file-service/public/info/:id/:tempToken", () => { + test("Should return public file info", async () => { + const makePublicResponse = await request(app) + .patch(`/file-service/make-public/${file._id}`) + .set("Cookie", authToken); + + const tempToken = makePublicResponse.body.token; + + const fileResponse = await request(app) + .get(`/file-service/public/info/${file._id}/${tempToken}`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(file.filename === fileResponse.body.filename).toBeTruthy(); + }); + test("Should return 404 if file not found", async () => { + const fileResponse = await request(app) + .get(`/file-service/public/info/5f7e5d8d1f962d5a0f5e8a9e/test`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(404); + }); + test("Should return 401/404 if not authorized", async () => { + const fileResponse = await request(app) + .get(`/file-service/public/info/${file._id}/abcd`) + .set("Cookie", authToken); + + expect([401, 404]).toContain(fileResponse.status); + }); + test("Should return 401/404 if not public", async () => { + const makePublicResponse = await request(app) + .patch(`/file-service/make-public/${file._id}`) + .set("Cookie", authToken); + + const tempToken = makePublicResponse.body.token; + + await mongoose + .model("fs.files") + .updateOne( + { _id: file._id }, + { $set: { "metadata.link": null, "metadata.linkType": null } } + ); + + const fileResponse = await request(app) + .get(`/file-service/public/info/${file._id}/${tempToken}`) + .set("Cookie", authToken); + + expect([401, 404]).toContain(fileResponse.status); + }); + }); + + describe("Move file: PATCH /file-service/move", () => { + test("Should move file", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + + const folderId = folderResponse.body._id; + + const fileResponse = await request(app) + .patch(`/file-service/move`) + .set("Cookie", authToken) + .send({ + id: file._id, + parentID: folderId, + }); + + expect(fileResponse.status).toBe(200); + + const fileDbCheck = await mongoose.model("fs.files").findOne({ + _id: file._id, + }); + + expect(fileDbCheck.metadata.parent).toBe(folderId); + expect(fileDbCheck.metadata.parentList).toBe(`/,${folderId}`); + }); + test("Should move user 2's file", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken2) + .send({ + name: "test", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + + const folderId = folderResponse.body._id; + + const fileResponse = await request(app) + .patch(`/file-service/move`) + .set("Cookie", authToken2) + .send({ + id: file2._id, + parentID: folderId, + }); + + expect(fileResponse.status).toBe(200); + + const fileDbCheck = await mongoose.model("fs.files").findOne({ + _id: file2._id, + }); + + expect(fileDbCheck.metadata.parent).toBe(folderId); + expect(fileDbCheck.metadata.parentList).toBe(`/,${folderId}`); + }); + test("Should return 404 if file not found", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + + const folderId = folderResponse.body._id; + + const fileResponse = await request(app) + .patch(`/file-service/move`) + .set("Cookie", authToken) + .send({ + id: "5f7e5d8d1f962d5a0f5e8a9e", + parentID: folderId, + }); + + expect(fileResponse.status).toBe(404); + }); + test("Should return 404 if folder not found", async () => { + const fileResponse = await request(app) + .patch(`/file-service/move`) + .set("Cookie", authToken) + .send({ + id: file._id, + parentID: "5f7e5d8d1f962d5a0f5e8a9e", + }); + + expect(fileResponse.status).toBe(404); + }); + test("Should return 401 if not authorized", async () => { + const fileResponse = await request(app) + .patch(`/file-service/move`) + .set("Cookie", "access-token=test") + .send({ + id: file._id, + parentID: "5f7e5d8d1f962d5a0f5e8a9e", + }); + + expect(fileResponse.status).toBe(401); + }); + test("Should return 401/404 if not owner of file", async () => { + const fileResponse = await request(app) + .patch(`/file-service/move`) + .set("Cookie", authToken) + .send({ + id: file2._id, + parentID: "/", + }); + + expect([401, 404]).toContain(fileResponse.status); + }); + test("Should not allow moving into folder not owned by user", async () => { + const userResponse2 = await request(app) + .post("/user-service/create") + .send({ + email: "test2@test.com", + password: "test1234", + }) + .set("uuid", 12314123123); + + const authToken2 = userResponse2.headers["set-cookie"] + .map((cookie) => cookie.split(";")[0]) + .join("; "); + + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken2) + .send({ + name: "test", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + + const folderId = folderResponse.body._id; + + const fileResponse = await request(app) + .patch(`/file-service/move`) + .set("Cookie", authToken) + .send({ + id: file._id, + parentID: folderId, + }); + + expect([401, 404]).toContain(fileResponse.status); + }); + test("Should correctly move into nested folder", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + + const folderResponse2 = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test2", + parent: folderResponse.body._id, + }); + + expect(folderResponse2.status).toBe(201); + + const folderId = folderResponse2.body._id; + + const fileResponse = await request(app) + .patch(`/file-service/move`) + .set("Cookie", authToken) + .send({ + id: file._id, + parentID: folderId, + }); + + expect(fileResponse.status).toBe(200); + + expect(fileResponse.body.metadata.parent).toBe(folderResponse2.body._id); + expect(fileResponse.body.metadata.parentList).toBe( + `/,${folderResponse.body._id},${folderResponse2.body._id}` + ); + }); + test("Should move file home", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + + const folderId = folderResponse.body._id; + + const fileResponse = await request(app) + .patch(`/file-service/move`) + .set("Cookie", authToken) + .send({ + id: file._id, + parentID: folderId, + }); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.metadata.parent).toBe(folderId); + expect(fileResponse.body.metadata.parentList).toBe(`/,${folderId}`); + + const fileResponse2 = await request(app) + .patch(`/file-service/move`) + .set("Cookie", authToken) + .send({ + id: file._id, + parentID: "/", + }); + + expect(fileResponse2.status).toBe(200); + + expect(fileResponse2.body.metadata.parent).toBe("/"); + expect(fileResponse2.body.metadata.parentList).toBe("/"); + }); + }); + describe("Get quick file list: GET /file-service/quick-list", () => { + test("Should return quick file list", async () => { + const quickListResponse = await request(app) + .get(`/file-service/quick-list`) + .set("Cookie", authToken); + + expect(quickListResponse.status).toBe(200); + expect(quickListResponse.body.length).toBe(1); + }); + test("Should return 401 if not authorized", async () => { + const quickListResponse = await request(app) + .get(`/file-service/quick-list`) + .set("Cookie", "access-token=test"); + + expect(quickListResponse.status).toBe(401); + }); + test("Should return quick file list including files in folders", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + + await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c3"), + filename: "test2.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user.body.user._id, + parent: folderResponse.body._id, + parentList: `/,${folderResponse.body._id}`, + hasThumbnail: false, + size: "10001", + IV: "test1", + isVideo: false, + }, + }); + + const quickListResponse = await request(app) + .get(`/file-service/quick-list`) + .set("Cookie", authToken); + + expect(quickListResponse.status).toBe(200); + expect(quickListResponse.body.length).toBe(2); + }); + test("Should not return a file that is trashed", async () => { + const fileResponse = await request(app) + .patch(`/file-service/trash`) + .set("Cookie", authToken) + .send({ + id: file._id, + }); + + expect(fileResponse.status).toBe(200); + + const quickListResponse = await request(app) + .get(`/file-service/quick-list`) + .set("Cookie", authToken); + + expect(quickListResponse.status).toBe(200); + expect(quickListResponse.body.length).toBe(0); + }); + test("Should return newer files first", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + + await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c3"), + filename: "test2.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user.body.user._id, + parent: folderResponse.body._id, + parentList: `/,${folderResponse.body._id}`, + hasThumbnail: false, + size: "10001", + IV: "test1", + isVideo: false, + }, + }); + + const quickListResponse = await request(app) + .get(`/file-service/quick-list`) + .set("Cookie", authToken); + + expect(quickListResponse.status).toBe(200); + expect(quickListResponse.body[0].filename).toBe("test2.txt"); + }); + }); + describe("Get file list: GET /file-service/list", () => { + test("Should return file list", async () => { + const fileResponse = await request(app) + .get(`/file-service/list`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.length).toBe(1); + }); + test("Should return 401 if not authorized", async () => { + const fileResponse = await request(app) + .get(`/file-service/list`) + .set("Cookie", "access-token=test"); + + expect(fileResponse.status).toBe(401); + }); + test("Should return files in descending date order/default order", async () => { + const file2 = await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c3"), + filename: "test2.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user.body.user._id, + parent: "/", + parentList: "/", + hasThumbnail: false, + size: "10001", + IV: "test1", + isVideo: false, + }, + }); + + const fileResponse = await request(app) + .get(`/file-service/list`) + .set("Cookie", authToken); + + const fileResponse2 = await request(app) + .get(`/file-service/list?sortBy=date_desc`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body[0].filename).toBe(file2.filename); + expect(fileResponse.body[1].filename).toBe(file.filename); + + expect(fileResponse2.status).toBe(200); + expect(fileResponse2.body[0].filename).toBe(file2.filename); + expect(fileResponse2.body[1].filename).toBe(file.filename); + }); + test("Should return files in ascending date order", async () => { + const file2 = await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c3"), + filename: "test2.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user.body.user._id, + parent: "/", + parentList: "/", + hasThumbnail: false, + size: "10001", + IV: "test1", + isVideo: false, + }, + }); + + const fileResponse = await request(app) + .get(`/file-service/list?sortBy=date_asc`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body[0].filename).toBe(file.filename); + expect(fileResponse.body[1].filename).toBe(file2.filename); + }); + test("Should return files in descending alphabetical order", async () => { + const file2 = await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c3"), + filename: "a.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user.body.user._id, + parent: "/", + parentList: "/", + hasThumbnail: false, + size: "10001", + IV: "test1", + isVideo: false, + }, + }); + + const fileResponse = await request(app) + .get(`/file-service/list?sortBy=alp_desc`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body[0].filename).toBe(file.filename); + expect(fileResponse.body[1].filename).toBe(file2.filename); + }); + test("Should return files in ascending alphabetical order", async () => { + const file2 = await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c3"), + filename: "a.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user.body.user._id, + parent: "/", + parentList: "/", + hasThumbnail: false, + size: "10001", + IV: "test1", + isVideo: false, + }, + }); + + const fileResponse = await request(app) + .get(`/file-service/list?sortBy=alp_asc`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body[0].filename).toBe(file2.filename); + expect(fileResponse.body[1].filename).toBe(file.filename); + }); + test("Should correctly show only home files", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c3"), + filename: "a.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user.body.user._id, + parent: folderResponse.body._id, + parentList: `/,${folderResponse.body._id}`, + hasThumbnail: false, + size: "10001", + IV: "test1", + isVideo: false, + }, + }); + + const fileResponse = await request(app) + .get(`/file-service/list?parent=/`) + .set("Cookie", authToken); + + const fileResponse2 = await request(app) + .get(`/file-service/list`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.length).toBe(1); + expect(fileResponse.body[0].filename).toBe(file.filename); + + expect(fileResponse2.status).toBe(200); + expect(fileResponse2.body.length).toBe(1); + expect(fileResponse2.body[0].filename).toBe(file.filename); + }); + test("Should correctly show only folder files", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + const file2 = await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c3"), + filename: "a.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user.body.user._id, + parent: folderResponse.body._id, + parentList: `/,${folderResponse.body._id}`, + hasThumbnail: false, + size: "10001", + IV: "test1", + isVideo: false, + }, + }); + + const folderResponse2 = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test2", + parent: "/", + }); + + await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c4"), + filename: "b.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user.body.user._id, + parent: folderResponse2.body._id, + parentList: `/,${folderResponse2.body._id}`, + hasThumbnail: false, + size: "10001", + IV: "test1", + isVideo: false, + }, + }); + + const fileResponse = await request(app) + .get(`/file-service/list?parent=${folderResponse.body._id}`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.length).toBe(1); + expect(fileResponse.body[0].filename).toBe(file2.filename); + }); + test("Should correctly show only file search results", async () => { + const file2 = await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c3"), + filename: "a.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user.body.user._id, + parent: "/", + parentList: "/", + hasThumbnail: false, + size: "10001", + IV: "test1", + isVideo: false, + }, + }); + + const fileResponse = await request(app) + .get(`/file-service/list?search=a`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.length).toBe(1); + expect(fileResponse.body[0].filename).toBe(file2.filename); + }); + test("Should correctly show nested files search results", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c3"), + filename: "test1.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user.body.user._id, + parent: folderResponse.body._id, + parentList: `/,${folderResponse.body._id}`, + hasThumbnail: false, + size: "10001", + IV: "test1", + isVideo: false, + }, + }); + + const fileResponse = await request(app) + .get(`/file-service/list?search=test`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.length).toBe(2); + }); + test("Should return 0 files if no search results", async () => { + const fileResponse = await request(app) + .get(`/file-service/list?search=asdfasdfkl`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.length).toBe(0); + }); + test("Should correctly show only trashed files", async () => { + const file2 = await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c3"), + filename: "a.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user.body.user._id, + parent: "/", + parentList: "/", + hasThumbnail: false, + size: "10001", + IV: "test1", + isVideo: false, + trashed: true, + }, + }); + + const fileResponse = await request(app) + .get(`/file-service/list?trashMode=true`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.length).toBe(1); + expect(fileResponse.body[0].filename).toBe(file2.filename); + }); + test("Should correctly show only trashes files in a folder", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + await request(app) + .patch("/folder-service/trash") + .set("Cookie", authToken) + .send({ + id: folderResponse.body._id, + }); + + expect(folderResponse.status).toBe(201); + + await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c4"), + filename: "ab.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user.body.user._id, + parent: "/", + parentList: "/", + hasThumbnail: false, + size: "10001", + IV: "test1", + isVideo: false, + trashed: true, + }, + }); + + const file2 = await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c3"), + filename: "a.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user.body.user._id, + parent: folderResponse.body._id, + parentList: `/,${folderResponse.body._id}`, + hasThumbnail: false, + size: "10001", + IV: "test1", + isVideo: false, + trashed: true, + }, + }); + + const fileResponse = await request(app) + .get( + `/file-service/list?parent=${folderResponse.body._id}&trashMode=true` + ) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.length).toBe(1); + expect(fileResponse.body[0].filename).toBe(file2.filename); + }); + test("Should only return files that belong to the user", async () => { + await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c3"), + filename: "a.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user2.body.user._id, + parent: "/", + parentList: "/", + hasThumbnail: false, + size: "10001", + IV: "test1", + isVideo: false, + }, + }); + + const fileResponse = await request(app) + .get(`/file-service/list`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.length).toBe(1); + expect(fileResponse.body[0].filename).toBe(file.filename); + }); + test("Should not return file that is processing", async () => { + await mongoose + .model("fs.files") + .updateOne( + { _id: file._id }, + { $set: { "metadata.processingFile": true } } + ); + + const fileResponse2 = await request(app) + .get(`/file-service/list`) + .set("Cookie", authToken); + + expect(fileResponse2.status).toBe(200); + expect(fileResponse2.body.length).toBe(0); + }); + }); + describe("Get suggested list: GET /file-service/suggested-list", () => { + test("Should return suggested list", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + + const fileResponse = await request(app) + .get(`/file-service/suggested-list?search=test`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.fileList.length).toBe(1); + expect(fileResponse.body.folderList.length).toBe(1); + }); + test("Should return 401 if not authorized", async () => { + const fileResponse = await request(app) + .get(`/file-service/suggested-list?search=test`) + .set("Cookie", "access-token=test"); + + expect(fileResponse.status).toBe(401); + }); + test("If search is provided should atleast be length of 1", async () => { + const fileResponse = await request(app) + .get(`/file-service/suggested-list?search=`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(400); + }); + test("Should return nested files and folders", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test2", + parent: folderResponse.body._id, + }); + + await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c4"), + filename: "test3.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user.body.user._id, + parent: folderResponse.body._id, + parentList: `/,${folderResponse.body._id}`, + hasThumbnail: false, + size: "10001", + IV: "test1", + isVideo: false, + }, + }); + + const fileResponse = await request(app) + .get(`/file-service/suggested-list?search=test`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.fileList.length).toBe(2); + expect(fileResponse.body.folderList.length).toBe(2); + }); + test("Should not reutrn items not in the search query", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + + const fileResponse = await request(app) + .get(`/file-service/suggested-list?search=qweqweqwe`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.fileList.length).toBe(0); + expect(fileResponse.body.folderList.length).toBe(0); + }); + test("Should not return files that are processing", async () => { + await mongoose + .model("fs.files") + .updateOne( + { _id: file._id }, + { $set: { "metadata.processingFile": true } } + ); + const fileResponse = await request(app) + .get(`/file-service/suggested-list?search=test`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.fileList.length).toBe(0); + }); + test("Should not return items that are trashed", async () => { + await mongoose + .model("fs.files") + .updateOne({ _id: file._id }, { $set: { "metadata.trashed": true } }); + + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + + await mongoose + .model("Folder") + .updateOne( + { _id: folderResponse.body._id }, + { $set: { trashed: true } } + ); + + const fileResponse = await request(app) + .get(`/file-service/suggested-list?search=test`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.fileList.length).toBe(0); + expect(fileResponse.body.folderList.length).toBe(0); + }); + test("Should only return trashed items", async () => { + const file2 = await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c4"), + filename: "test2.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user.body.user._id, + parent: "/", + parentList: "/", + hasThumbnail: false, + size: "10001", + IV: "test1", + isVideo: false, + trashed: true, + }, + }); + + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + + const folderResponse2 = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test2", + parent: "/", + }); + + await mongoose + .model("Folder") + .updateOne( + { _id: folderResponse2.body._id }, + { $set: { trashed: true } } + ); + + const fileResponse = await request(app) + .get(`/file-service/suggested-list?search=test&trashMode=true`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.fileList.length).toBe(1); + expect(fileResponse.body.folderList.length).toBe(1); + expect(fileResponse.body.fileList[0].filename).toBe(file2.filename); + expect(fileResponse.body.folderList[0].name).toBe( + folderResponse2.body.name + ); + }); + test("Should only reutrn moedia items", async () => { + const file2 = await mongoose.model("fs.files").create({ + _id: new ObjectId("5eb88f29ecb8c9319ddca3c4"), + filename: "test2.txt", + uploadDate: new Date(), + length: 10001, + metadata: { + owner: user.body.user._id, + parent: "/", + parentList: "/", + hasThumbnail: true, + size: "10001", + IV: "test1", + isVideo: true, + }, + }); + + await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + const fileResponse = await request(app) + .get(`/file-service/suggested-list?search=test&mediaMode=true`) + .set("Cookie", authToken); + + expect(fileResponse.status).toBe(200); + expect(fileResponse.body.fileList.length).toBe(1); + expect(fileResponse.body.fileList[0].filename).toBe(file2.filename); + expect(fileResponse.body.folderList.length).toBe(0); + }); + }); +}); diff --git a/tests/controller/folder.controller.test.js b/tests/controller/folder.controller.test.js new file mode 100644 index 0000000..3443743 --- /dev/null +++ b/tests/controller/folder.controller.test.js @@ -0,0 +1,1165 @@ +const request = require("supertest"); +const app = require("../utils/express-app"); +const mongoose = require("mongoose"); +const { MongoMemoryServer } = require("mongodb-memory-server"); +const getEnvVariables = require("../../dist-backend/enviroment/get-env-variables"); +const getKey = require("../../dist-backend/key/get-key").default; +getEnvVariables(); +const env = require("../../dist-backend/enviroment/env"); +const { envFileFix } = require("../utils/db-setup"); +const { ObjectId } = require("mongodb"); + +let mongoServer; +let authToken; +let authToken2; +let folder; +let folder2; +let user; +let user2; + +describe("File Controller", () => { + beforeAll(async () => { + envFileFix(env); + await getKey(); + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri(), { useNewUrlParser: true }); + user = await request(app) + .post("/user-service/create") + .send({ + email: "test@test.com", + password: "test1234", + }) + .set("uuid", 12314123123); + + user2 = await request(app) + .post("/user-service/create") + .send({ + email: "test@test2.com", + password: "test1234", + }) + .set("uuid", 12314123124); + + authToken = user.headers["set-cookie"] + .map((cookie) => cookie.split(";")[0]) + .join("; "); + + authToken2 = user2.headers["set-cookie"] + .map((cookie) => cookie.split(";")[0]) + .join("; "); + }); + + beforeEach(async () => { + await mongoose.model("fs.files").deleteMany({}); + await mongoose.model("Folder").deleteMany({}); + + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + const folderResponse2 = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken2) + .send({ + name: "test", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + folder = folderResponse.body; + + expect(folderResponse2.status).toBe(201); + folder2 = folderResponse2.body; + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + describe("Folder info: GET /folder-service/info/:id", () => { + test("Should return folder info", async () => { + const folderResponse = await request(app) + .get(`/folder-service/info/${folder._id}`) + .set("Cookie", authToken); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body.name).toBe(folder.name); + }); + test("Should return user 2's folder info", async () => { + const folderResponse = await request(app) + .get(`/folder-service/info/${folder2._id}`) + .set("Cookie", authToken2); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body.name).toBe(folder2.name); + }); + test("Should return 404 if folder not found", async () => { + const folderResponse = await request(app) + .get(`/folder-service/info/5f7e5d8d1f962d5a0f5e8a9e`) + .set("Cookie", authToken); + + expect(folderResponse.status).toBe(404); + }); + test("Should return 401 if not authorized", async () => { + const folderResponse = await request(app) + .get(`/folder-service/info/${folder._id}`) + .set("Cookie", "access-token=test"); + + expect(folderResponse.status).toBe(401); + }); + test("Should return 401/404 if not owner of folder", async () => { + const folderResponse = await request(app) + .get(`/folder-service/info/${folder2._id}`) + .set("Cookie", authToken); + + expect([401, 404]).toContain(folderResponse.status); + }); + }); + + describe("Create folder: POST /folder-service/create", () => { + test("Should create folder", async () => { + const folderResponse = await request(app) + .post(`/folder-service/create`) + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + + const folderDbCheck = await mongoose.model("Folder").findOne({ + _id: folderResponse.body._id, + }); + + expect(folderDbCheck.name).toBe(folderResponse.body.name); + }); + test("Should return 401 if not authorized", async () => { + const folderResponse = await request(app) + .post(`/folder-service/create`) + .set("Cookie", "access-token=test") + .send({ + name: "test", + parent: "/", + }); + + expect(folderResponse.status).toBe(401); + }); + test("Should return 400 if no name", async () => { + const folderResponse = await request(app) + .post(`/folder-service/create`) + .set("Cookie", authToken) + .send({ + parent: "/", + }); + + expect(folderResponse.status).toBe(400); + }); + test("Should return 400 is name length is less than 1", async () => { + const folderResponse = await request(app) + .post(`/folder-service/create`) + .set("Cookie", authToken) + .send({ + name: "", + parent: "/", + }); + + expect(folderResponse.status).toBe(400); + }); + test("Should return 400 if title length is greater than 256", async () => { + const folderResponse = await request(app) + .post(`/folder-service/create`) + .set("Cookie", authToken) + .send({ + name: "a".repeat(257), + parent: "/", + }); + + expect(folderResponse.status).toBe(400); + }); + test("Should default parent to / if not provided", async () => { + const folderResponse = await request(app) + .post(`/folder-service/create`) + .set("Cookie", authToken) + .send({ + name: "test", + }); + + expect(folderResponse.status).toBe(201); + + const folderDbCheck = await mongoose.model("Folder").findOne({ + _id: folderResponse.body._id, + }); + + expect(folderDbCheck.name).toBe(folderResponse.body.name); + expect(folderDbCheck.parent).toBe("/"); + }); + test("Should correctly create nested folder", async () => { + const folderResponse = await request(app) + .post(`/folder-service/create`) + .set("Cookie", authToken) + .send({ + name: "test", + parent: folder._id, + }); + + expect(folderResponse.status).toBe(201); + + const folderDbCheck = await mongoose.model("Folder").findOne({ + _id: folderResponse.body._id, + }); + + expect(folderDbCheck.name).toBe(folderResponse.body.name); + expect(folderDbCheck.parent).toBe(folder._id); + expect(folderDbCheck.parentList.length).toBe(2); + expect(folderDbCheck.parentList[0]).toBe("/"); + expect(folderDbCheck.parentList[1]).toBe(folder._id); + }); + }); + + describe("Rename folder: PATCH /folder-service/rename", () => { + test("Should rename folder", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/rename`) + .set("Cookie", authToken) + .send({ + id: folder._id, + title: "newname.txt", + }); + + expect(folderResponse.status).toBe(200); + + const folderDbCheck = await mongoose.model("Folder").findOne({ + _id: folder._id, + }); + + expect(folderDbCheck.name).toBe("newname.txt"); + }); + test("Should rename user 2's folder", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/rename`) + .set("Cookie", authToken2) + .send({ + id: folder2._id, + title: "newname.txt", + }); + + expect(folderResponse.status).toBe(200); + + const folderDbCheck = await mongoose.model("Folder").findOne({ + _id: folder2._id, + }); + + expect(folderDbCheck.name).toBe("newname.txt"); + }); + test("Should return 404 if folder not found", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/rename`) + .set("Cookie", authToken) + .send({ + id: "5f7e5d8d1f962d5a0f5e8a9e", + title: "newname.txt", + }); + + expect(folderResponse.status).toBe(404); + }); + test("Should return 401 if not authorized", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/rename`) + .set("Cookie", "access-token=test") + .send({ + id: folder._id, + title: "newname.txt", + }); + + expect(folderResponse.status).toBe(401); + }); + test("Should return 401/404 if not owner of folder", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/rename`) + .set("Cookie", authToken) + .send({ + id: folder2._id, + title: "newname.txt", + }); + + expect([401, 404]).toContain(folderResponse.status); + }); + test("Should return 400 if no title", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/rename`) + .set("Cookie", authToken) + .send({ + id: folder._id, + }); + + expect(folderResponse.status).toBe(400); + }); + test("Should return 400 if title length is less than 1", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/rename`) + .set("Cookie", authToken) + .send({ + id: folder._id, + title: "", + }); + + expect(folderResponse.status).toBe(400); + }); + test("Should return 400 if title length is greater than 256", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/rename`) + .set("Cookie", authToken) + .send({ + id: folder._id, + title: "a".repeat(257), + }); + + expect(folderResponse.status).toBe(400); + }); + test("Should return 400 if no id", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/rename`) + .set("Cookie", authToken) + .send({ + title: "newname.txt", + }); + + expect(folderResponse.status).toBe(400); + }); + }); + + describe("Trash folder: PATCH /folder-service/trash", () => { + test("Should trash folder", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/trash`) + .set("Cookie", authToken) + .send({ + id: folder._id, + }); + + expect(folderResponse.status).toBe(200); + + const folderDbCheck = await mongoose.model("Folder").findOne({ + _id: folder._id, + }); + + expect(folderDbCheck.trashed).toBe(true); + }); + test("Should trash user 2's folder", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/trash`) + .set("Cookie", authToken2) + .send({ + id: folder2._id, + }); + + expect(folderResponse.status).toBe(200); + + const folderDbCheck = await mongoose.model("Folder").findOne({ + _id: folder2._id, + }); + + expect(folderDbCheck.trashed).toBe(true); + }); + test("Should return 404 if folder not found", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/trash`) + .set("Cookie", authToken) + .send({ + id: "5f7e5d8d1f962d5a0f5e8a9e", + }); + + expect(folderResponse.status).toBe(404); + }); + test("Should return 401 if not authorized", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/trash`) + .set("Cookie", "access-token=test") + .send({ + id: folder._id, + }); + + expect(folderResponse.status).toBe(401); + }); + test("Should return 401/404 if not owner of folder", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/trash`) + .set("Cookie", authToken) + .send({ + id: folder2._id, + }); + + expect([401, 404]).toContain(folderResponse.status); + }); + test("Should return 400 if no id", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/trash`) + .set("Cookie", authToken) + .send(); + + expect(folderResponse.status).toBe(400); + }); + }); + + describe("Restore folder: PATCH /folder-service/restore", () => { + test("Should restore folder", async () => { + await mongoose + .model("Folder") + .updateOne({ _id: folder._id }, { $set: { trashed: true } }); + + const folderResponse = await request(app) + .patch(`/folder-service/restore`) + .set("Cookie", authToken) + .send({ + id: folder._id, + }); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body.trashed).toBeFalsy(); + + const folderDbCheck = await mongoose.model("Folder").findOne({ + _id: folder._id, + }); + + expect(folderDbCheck.trashed).toBeFalsy(); + }); + test("Should restore user 2's folder", async () => { + await mongoose + .model("Folder") + .updateOne({ _id: folder2._id }, { $set: { trashed: true } }); + + const folderResponse = await request(app) + .patch(`/folder-service/restore`) + .set("Cookie", authToken2) + .send({ + id: folder2._id, + }); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body.trashed).toBeFalsy(); + + const folderDbCheck = await mongoose.model("Folder").findOne({ + _id: folder2._id, + }); + + expect(folderDbCheck.trashed).toBeFalsy(); + }); + test("Should return 404 if folder not found", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/restore`) + .set("Cookie", authToken) + .send({ + id: "5f7e5d8d1f962d5a0f5e8a9e", + }); + + expect(folderResponse.status).toBe(404); + }); + test("Should return 401 if not authorized", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/restore`) + .set("Cookie", "access-token=test") + .send({ + id: folder._id, + }); + + expect(folderResponse.status).toBe(401); + }); + test("Should return 401/404 if not owner of folder", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/restore`) + .set("Cookie", authToken) + .send({ + id: folder2._id, + }); + + expect([401, 404]).toContain(folderResponse.status); + }); + test("Should return 400 if no id", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/restore`) + .set("Cookie", authToken) + .send(); + + expect(folderResponse.status).toBe(400); + }); + }); + + describe("Get folder list: GET /folder-service/list", () => { + test("Should return folder list", async () => { + const folderResponse = await request(app) + .get(`/folder-service/list`) + .set("Cookie", authToken); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body.length).toBe(1); + }); + test("Should return user 2's folder list", async () => { + const folderResponse = await request(app) + .get(`/folder-service/list`) + .set("Cookie", authToken2); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body.length).toBe(1); + }); + test("Should return 401 if not authorized", async () => { + const folderResponse = await request(app) + .get(`/folder-service/list`) + .set("Cookie", "access-token=test"); + + expect(folderResponse.status).toBe(401); + }); + test("Should return folders in descending date order/default order", async () => { + const folder2 = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + const folderResponse = await request(app) + .get(`/folder-service/list`) + .set("Cookie", authToken); + + const folderResponse2 = await request(app) + .get(`/folder-service/list?sortBy=date_desc`) + .set("Cookie", authToken); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body[0].name).toBe(folder2.body.name); + expect(folderResponse.body[1].name).toBe(folder.name); + + expect(folderResponse2.status).toBe(200); + expect(folderResponse2.body[0].name).toBe(folder2.body.name); + expect(folderResponse2.body[1].name).toBe(folder.name); + }); + test("Should return folders in ascending date order", async () => { + const folder2 = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + const folderResponse = await request(app) + .get(`/folder-service/list?sortBy=date_asc`) + .set("Cookie", authToken); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body[0].name).toBe(folder.name); + expect(folderResponse.body[1].name).toBe(folder2.body.name); + }); + test("Should return folders in descending alphabetical order", async () => { + const folder2 = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "a", + parent: "/", + }); + + const folderResponse = await request(app) + .get(`/folder-service/list?sortBy=alp_desc`) + .set("Cookie", authToken); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body[0].name).toBe(folder.name); + expect(folderResponse.body[1].name).toBe(folder2.body.name); + }); + test("Should return folders in ascending alphabetical order", async () => { + const folder2 = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "a", + parent: "/", + }); + + const folderResponse = await request(app) + .get(`/folder-service/list?sortBy=alp_asc`) + .set("Cookie", authToken); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body[0].name).toBe(folder2.body.name); + expect(folderResponse.body[1].name).toBe(folder.name); + }); + test("Should correctly show only home folders", async () => { + await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: folder._id, + }); + + const folderResponse = await request(app) + .get(`/folder-service/list?parent=/`) + .set("Cookie", authToken); + + const folderResponse2 = await request(app) + .get(`/folder-service/list`) + .set("Cookie", authToken); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body.length).toBe(1); + expect(folderResponse.body[0].name).toBe(folder.name); + + expect(folderResponse2.status).toBe(200); + expect(folderResponse2.body.length).toBe(1); + expect(folderResponse2.body[0].name).toBe(folder.name); + }); + test("Should correctly show only sub folders", async () => { + await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: folder._id, + }); + + const folderResponse = await request(app) + .get(`/folder-service/list?parent=${folder._id}`) + .set("Cookie", authToken); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body.length).toBe(1); + expect(folderResponse.body[0].name).toBe(folder.name); + }); + test("Should correctly search for folders", async () => { + await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "newname.txt", + parent: "/", + }); + + const folderResponse = await request(app) + .get(`/folder-service/list?search=test`) + .set("Cookie", authToken); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body.length).toBe(1); + }); + test("Should correctly show nested folders search results", async () => { + await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: folder._id, + }); + + const folderResponse = await request(app) + .get(`/folder-service/list?search=test`) + .set("Cookie", authToken); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body.length).toBe(2); + }); + test("Should not return items not in the search query", async () => { + const folderResponse = await request(app) + .get(`/folder-service/list?search=qweqweqwe`) + .set("Cookie", authToken); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body.length).toBe(0); + }); + test("Should only return trashed folders", async () => { + const folder2 = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + await mongoose + .model("Folder") + .updateOne({ _id: folder._id }, { $set: { trashed: true } }); + + const folderResponse = await request(app) + .get(`/folder-service/list?trashMode=true`) + .set("Cookie", authToken); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body.length).toBe(1); + expect(folderResponse.body[0].name).toBe(folder2.body.name); + }); + test("Should only return folders that belong to the user", async () => { + const folderResponse = await request(app) + .get(`/folder-service/list`) + .set("Cookie", authToken); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body.length).toBe(1); + expect(folderResponse.body[0].name).toBe(folder.name); + }); + }); + + describe("Move folder: PATCH /folder-service/move", () => { + test("Should move a folder", async () => { + const folder2Response = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + const folderResponse = await request(app) + .patch(`/folder-service/move`) + .set("Cookie", authToken) + .send({ + id: folder._id, + parentID: folder2Response.body._id, + }); + + expect(folderResponse.status).toBe(200); + + const folderDbCheck = await mongoose.model("Folder").findOne({ + _id: folder._id, + }); + + expect(folderDbCheck.parent).toBe(folder2Response.body._id); + expect(folderDbCheck.parentList.length).toBe(2); + expect(folderDbCheck.parentList[0]).toBe("/"); + expect(folderDbCheck.parentList[1]).toBe(folder2Response.body._id); + }); + test("Should return 404 if folder not found", async () => { + const folder2Response = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + const folderResponse = await request(app) + .patch(`/folder-service/move`) + .set("Cookie", authToken) + .send({ + id: "5f7e5d8d1f962d5a0f5e8a9e", + parentID: folder2Response.body._id, + }); + + expect(folderResponse.status).toBe(404); + }); + test("Should return 401 if not authorized", async () => { + const folder2Response = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + const folderResponse = await request(app) + .patch(`/folder-service/move`) + .set("Cookie", "access-token=test") + .send({ + id: folder._id, + parentID: folder2Response.body._id, + }); + + expect(folderResponse.status).toBe(401); + }); + test("Should return 401/404 if not owner of folder", async () => { + const folderResponse = await request(app) + .patch(`/folder-service/move`) + .set("Cookie", authToken) + .send({ + id: folder._id, + parentID: folder2._id, + }); + + expect([401, 404]).toContain(folderResponse.status); + }); + test("Should also move files in folder", async () => { + const folder2Response = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + const folder2 = folder2Response.body; + + const file = await mongoose.model("fs.files").create({ + _id: new ObjectId("4eb88f29ecb8c9319ddca3c2"), + filename: "test.txt2", + uploadDate: new Date(), + length: 10000, + metadata: { + owner: user.body.user._id, + parent: folder._id, + parentList: `/,${folder._id}`, + hasThumbnail: false, + size: "10000", + IV: "test", + isVideo: false, + }, + }); + + const folderResponse = await request(app) + .patch(`/folder-service/move`) + .set("Cookie", authToken) + .send({ + id: folder._id, + parentID: folder2._id, + }); + + expect(folderResponse.status).toBe(200); + + const fileDbCheck = await mongoose.model("fs.files").findOne({ + _id: file._id, + }); + + expect(fileDbCheck.metadata.parent).toBe(folder._id); + expect(fileDbCheck.metadata.parentList).toBe( + `/,${folder2._id},${folder._id}` + ); + }); + test("Should also move subfiles in a folder", async () => { + const folder2Response = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + const folder2 = folder2Response.body; + + const folder3Response = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: folder2._id, + }); + + const folder3 = folder3Response.body; + + const file = await mongoose.model("fs.files").create({ + _id: new ObjectId("4eb88f29ecb8c9319ddca3c2"), + filename: "test.txt2", + uploadDate: new Date(), + length: 10000, + metadata: { + owner: user.body.user._id, + parent: folder3._id, + parentList: `/,${folder2._id},${folder3._id}`, + hasThumbnail: false, + size: "10000", + IV: "test", + isVideo: false, + }, + }); + + const folderResponse = await request(app) + .patch(`/folder-service/move`) + .set("Cookie", authToken) + .send({ + id: folder2._id, + parentID: folder._id, + }); + + expect(folderResponse.status).toBe(200); + + const fileDbCheck = await mongoose.model("fs.files").findOne({ + _id: file._id, + }); + + expect(fileDbCheck.metadata.parent).toBe(folder3._id); + expect(fileDbCheck.metadata.parentList).toBe( + `/,${folder._id},${folder2._id},${folder3._id}` + ); + }); + test("Should also move folders in a folder", async () => { + const folder2Response = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: folder._id, + }); + + const folder2 = folder2Response.body; + + const folder3Response = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + const folder3 = folder3Response.body; + + const folderMoveResponse = await request(app) + .patch(`/folder-service/move`) + .set("Cookie", authToken) + .send({ + id: folder._id, + parentID: folder3._id, + }); + + expect(folderMoveResponse.status).toBe(200); + + const folderDbCheck = await mongoose.model("Folder").findOne({ + _id: folder2._id, + }); + + expect(folderDbCheck.parent).toBe(folder._id); + expect(folderDbCheck.parentList[0]).toBe("/"); + expect(folderDbCheck.parentList[1]).toBe(folder3._id); + expect(folderDbCheck.parentList[2]).toBe(folder._id); + }); + test("Should also move subfolders in a folder", async () => { + const folder2Response = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: folder._id, + }); + + const folder2 = folder2Response.body; + + const folder3Response = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: folder2._id, + }); + + const folder3 = folder3Response.body; + + const folder4Response = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + const folder4 = folder4Response.body; + + const folderMoveResponse = await request(app) + .patch(`/folder-service/move`) + .set("Cookie", authToken) + .send({ + id: folder._id, + parentID: folder4._id, + }); + + expect(folderMoveResponse.status).toBe(200); + + const folderDbCheck = await mongoose.model("Folder").findOne({ + _id: folder3._id, + }); + + expect(folderDbCheck.parent).toBe(folder2._id); + expect(folderDbCheck.parentList[0]).toBe("/"); + expect(folderDbCheck.parentList[1]).toBe(folder4._id); + expect(folderDbCheck.parentList[2]).toBe(folder._id); + expect(folderDbCheck.parentList[3]).toBe(folder2._id); + }); + }); + + describe("Move folder list: GET /folder-service/move-folder-list", () => { + test("Should return folder list", async () => { + const folderResponse = await request(app) + .get(`/folder-service/move-folder-list`) + .set("Cookie", authToken); + + expect(folderResponse.status).toBe(200); + expect(folderResponse.body.length).toBe(1); + }); + test("Should return 401 if not authorized", async () => { + const folderResponse = await request(app) + .get(`/folder-service/move-folder-list`) + .set("Cookie", "access-token=test"); + + expect(folderResponse.status).toBe(401); + }); + test("Should only return folders on the homepage", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: folder._id, + }); + + expect(folderResponse.status).toBe(201); + + const folderMoveListResponse = await request(app) + .get(`/folder-service/move-folder-list`) + .set("Cookie", authToken); + + const folderMoveListResponse2 = await request(app) + .get(`/folder-service/move-folder-list?parent=/`) + .set("Cookie", authToken); + + expect(folderMoveListResponse.status).toBe(200); + expect(folderMoveListResponse2.status).toBe(200); + + expect(folderMoveListResponse.body.length).toBe(1); + expect(folderMoveListResponse2.body.length).toBe(1); + }); + test("Should only return subfolders of a parent", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + + const folderResponse2 = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "test", + parent: folder._id, + }); + + expect(folderResponse2.status).toBe(201); + + const folderMoveListResponse = await request(app) + .get(`/folder-service/move-folder-list?parent=${folder._id}`) + .set("Cookie", authToken); + + expect(folderMoveListResponse.status).toBe(200); + expect(folderMoveListResponse.body.length).toBe(1); + }); + test("Should search for folders", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "name", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + + const folderMoveListResponse = await request(app) + .get(`/folder-service/move-folder-list?search=name`) + .set("Cookie", authToken); + + expect(folderMoveListResponse.status).toBe(200); + expect(folderMoveListResponse.body.length).toBe(1); + }); + test("Should show nested folders in search", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "name", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + + const folderResponse2 = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "name2", + parent: folder._id, + }); + + expect(folderResponse2.status).toBe(201); + + const folderMoveListResponse = await request(app) + .get(`/folder-service/move-folder-list?search=name`) + .set("Cookie", authToken); + + expect(folderMoveListResponse.status).toBe(200); + expect(folderMoveListResponse.body.length).toBe(2); + }); + test("Should not return folders in the folder IDs array", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "name", + parent: "/", + }); + + expect(folderResponse.status).toBe(201); + + const folderResponse2 = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "name2", + parent: "/", + }); + + expect(folderResponse2.status).toBe(201); + + const folderMoveListResponse = await request(app) + .get( + `/folder-service/move-folder-list?folderIDs[]=${folder._id}&folderIDs[]=${folderResponse2.body._id}` + ) + .set("Cookie", authToken); + + expect(folderMoveListResponse.status).toBe(200); + expect(folderMoveListResponse.body.length).toBe(1); + }); + test("Should not return sub folders in the folder IDs array", async () => { + const folderResponse = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "name", + parent: folder._id, + }); + + expect(folderResponse.status).toBe(201); + + const folderResponse2 = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "name2", + parent: folderResponse.body._id, + }); + + expect(folderResponse2.status).toBe(201); + + const folderResponse3 = await request(app) + .post("/folder-service/create") + .set("Cookie", authToken) + .send({ + name: "name3", + parent: "/", + }); + + expect(folderResponse3.status).toBe(201); + + const folderMoveListResponse = await request(app) + .get( + `/folder-service/move-folder-list?search=name&folderIDs[]=${folderResponse.body._id}` + ) + .set("Cookie", authToken); + + expect(folderMoveListResponse.status).toBe(200); + expect(folderMoveListResponse.body.length).toBe(1); + }); + }); +}); diff --git a/tests/controller/user-controller.test.js b/tests/controller/user-controller.test.js new file mode 100644 index 0000000..44704a7 --- /dev/null +++ b/tests/controller/user-controller.test.js @@ -0,0 +1,422 @@ +const request = require("supertest"); +const app = require("../utils/express-app"); +const mongoose = require("mongoose"); +const { MongoMemoryServer } = require("mongodb-memory-server"); +const getEnvVariables = require("../../dist-backend/enviroment/get-env-variables"); +const getKey = require("../../dist-backend/key/get-key").default; +getEnvVariables(); +const env = require("../../dist-backend/enviroment/env"); +const { envFileFix } = require("../utils/db-setup"); +const { ObjectId } = require("mongodb"); + +let mongoServer; +let authToken; +let authToken2; +let user; +let user2; + +describe("File Controller", () => { + beforeAll(async () => { + envFileFix(env); + await getKey(); + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri(), { useNewUrlParser: true }); + }); + + beforeEach(async () => { + await mongoose.model("fs.files").deleteMany({}); + await mongoose.model("Folder").deleteMany({}); + await mongoose.model("User").deleteMany({}); + envFileFix(env); + + user = await request(app) + .post("/user-service/create") + .send({ + email: "test@test.com", + password: "test1234", + }) + .set("uuid", 12314123123); + + user2 = await request(app) + .post("/user-service/create") + .send({ + email: "test@test2.com", + password: "test1234", + }) + .set("uuid", 12314123124); + + authToken = user.headers["set-cookie"] + .map((cookie) => cookie.split(";")[0]) + .join("; "); + + authToken2 = user2.headers["set-cookie"] + .map((cookie) => cookie.split(";")[0]) + .join("; "); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + describe("User info: GET /user-service/user", () => { + test("Should return user info", async () => { + const userResponse = await request(app) + .get(`/user-service/user`) + .set("Cookie", authToken); + + expect(userResponse.status).toBe(200); + expect(userResponse.body.email).toBe(user.body.user.email); + }); + test("Should return 401 if not authorized", async () => { + const userResponse = await request(app) + .get(`/user-service/user`) + .set("Cookie", "access-token=test"); + + expect(userResponse.status).toBe(401); + }); + }); + + describe("User login: POST /user-service/login", () => { + test("Should login user", async () => { + const userResponse = await request(app).post("/user-service/login").send({ + email: user.body.user.email, + password: "test1234", + }); + + expect(userResponse.status).toBe(200); + expect(userResponse.body.user.email).toBe(user.body.user.email); + }); + test("Should return 401 if incorrect password", async () => { + const userResponse = await request(app).post("/user-service/login").send({ + email: user.body.user.email, + password: "test12345", + }); + + expect(userResponse.status).toBe(401); + }); + test("Should return 401 if incorrect email", async () => { + const userResponse = await request(app).post("/user-service/login").send({ + email: "notexist@test.com", + password: "test1234", + }); + expect(userResponse.status).toBe(401); + }); + test("Should return 400 if email length is less than 3", async () => { + const userResponse = await request(app).post("/user-service/login").send({ + email: "ab", + password: "test1234", + }); + + expect(userResponse.status).toBe(400); + }); + test("Should return 400 if email length is greater than 320", async () => { + const userResponse = await request(app) + .post("/user-service/login") + .send({ + email: "a".repeat(321) + "@test.com", + password: "test1234", + }); + + expect(userResponse.status).toBe(400); + }); + test("Should return 400 if email address is invalid", async () => { + const userResponse = await request(app).post("/user-service/login").send({ + email: "a@b", + password: "test1234", + }); + + expect(userResponse.status).toBe(400); + }); + test("Should return 400 if password length is less than 6", async () => { + const userResponse = await request(app).post("/user-service/login").send({ + email: "test@test.com", + password: "a", + }); + + expect(userResponse.status).toBe(400); + }); + test("Should return 400 if password length is greater than 256", async () => { + const userResponse = await request(app) + .post("/user-service/login") + .send({ + email: "test@test.com", + password: "a".repeat(257), + }); + + expect(userResponse.status).toBe(400); + }); + }); + + describe("Create User: POST /user-service/create", () => { + test("Should create user", async () => { + const userResponse = await request(app) + .post("/user-service/create") + .send({ + email: "newuser@test.com", + password: "test1234", + }); + + expect(userResponse.status).toBe(201); + + const userDbCheck = await mongoose.model("User").findOne({ + _id: userResponse.body.user._id, + }); + + expect(userDbCheck.email).toBe(userResponse.body.user.email); + }); + test("Should return 400 if no email", async () => { + const userResponse = await request(app) + .post("/user-service/create") + .send({ + password: "test1234", + }); + + expect(userResponse.status).toBe(400); + }); + test("Should return 400 if no password", async () => { + const userResponse = await request(app) + .post("/user-service/create") + .send({ + email: "newuser@test.com", + }); + + expect(userResponse.status).toBe(400); + }); + test("Should return 400 if email length is less than 3", async () => { + const userResponse = await request(app) + .post("/user-service/create") + .send({ + email: "a@b", + password: "test1234", + }); + + expect(userResponse.status).toBe(400); + }); + test("Should return 400 if email length is greater than 320", async () => { + const userResponse = await request(app) + .post("/user-service/create") + .send({ + email: "a".repeat(321) + "@test.com", + password: "test1234", + }); + + expect(userResponse.status).toBe(400); + }); + test("Should return 400 if password length is less than 6", async () => { + const userResponse = await request(app) + .post("/user-service/create") + .send({ + email: "newuser@test.com", + password: "", + }); + + expect(userResponse.status).toBe(400); + }); + test("Should return 400 if password length is greater than 256", async () => { + const userResponse = await request(app) + .post("/user-service/create") + .send({ + email: "newuser@test.com", + password: "a".repeat(267), + }); + + expect(userResponse.status).toBe(400); + }); + test("Should return 409 if email already exists", async () => { + const userResponse = await request(app) + .post("/user-service/create") + .send({ + email: "test@test.com", + password: "test1234", + }); + + expect(userResponse.status).toBe(409); + }); + test("Should return 403 if create account is blocked", async () => { + env.createAcctBlocked = true; + + const userResponse = await request(app) + .post("/user-service/create") + .send({ + email: "newemail@test.com", + password: "test1234", + }); + + expect(userResponse.status).toBe(403); + }); + }); + + describe("Change Password: PATCH /user-service/change-password", () => { + test("Should change password", async () => { + const changePasswordResponse = await request(app) + .patch("/user-service/change-password") + .set("Cookie", authToken) + .send({ + oldPassword: "test1234", + newPassword: "test12345", + }); + expect(changePasswordResponse.status).toBe(200); + const loginResponse = await request(app) + .post("/user-service/login") + .send({ + email: user.body.user.email, + password: "test12345", + }); + expect(loginResponse.status).toBe(200); + }); + test("Should return 401 if not authorized", async () => { + const changePasswordResponse = await request(app) + .patch("/user-service/change-password") + .set("Cookie", "access-token=test") + .send({ + oldPassword: "test1234", + newPassword: "test12345", + }); + expect(changePasswordResponse.status).toBe(401); + }); + test("Should return 401 if incorrect password", async () => { + const changePasswordResponse = await request(app) + .patch("/user-service/change-password") + .set("Cookie", authToken) + .send({ + oldPassword: "test12345", + newPassword: "test1234", + }); + expect(changePasswordResponse.status).toBe(401); + }); + test("Should return 400 if no old password", async () => { + const changePasswordResponse = await request(app) + .patch("/user-service/change-password") + .set("Cookie", authToken) + .send({ + newPassword: "test12345", + }); + expect(changePasswordResponse.status).toBe(400); + }); + test("Should return 400 if no new password", async () => { + const changePasswordResponse = await request(app) + .patch("/user-service/change-password") + .set("Cookie", authToken) + .send({ + oldPassword: "test1234", + }); + expect(changePasswordResponse.status).toBe(400); + }); + test("Should return 400 if old password length is less than 6", async () => { + const changePasswordResponse = await request(app) + .patch("/user-service/change-password") + .set("Cookie", authToken) + .send({ + oldPassword: "", + newPassword: "test12345", + }); + expect(changePasswordResponse.status).toBe(400); + }); + test("Should return 400 if old password length is greater than 256", async () => { + const changePasswordResponse = await request(app) + .patch("/user-service/change-password") + .set("Cookie", authToken) + .send({ + oldPassword: "a".repeat(257), + newPassword: "test12345", + }); + expect(changePasswordResponse.status).toBe(400); + }); + test("Should return 400 if new password length is less than 6", async () => { + const changePasswordResponse = await request(app) + .patch("/user-service/change-password") + .set("Cookie", authToken) + .send({ + oldPassword: "test1234", + newPassword: "", + }); + expect(changePasswordResponse.status).toBe(400); + }); + test("Should return 400 if new password length is greater than 256", async () => { + const changePasswordResponse = await request(app) + .patch("/user-service/change-password") + .set("Cookie", authToken) + .send({ + oldPassword: "test1234", + newPassword: "a".repeat(257), + }); + expect(changePasswordResponse.status).toBe(400); + }); + }); + + describe("Get/Create user token: POST /user-service/get-token", () => { + test("Should return user token", async () => { + const getTokenResponse = await request(app) + .post("/user-service/get-token") + .set("Cookie", authToken) + .send(); + + expect(getTokenResponse.status).toBe(201); + }); + test("Should return 401 if not authorized", async () => { + const getTokenResponse = await request(app) + .post("/user-service/get-token") + .set("Cookie", "access-token=test") + .send(); + + expect(getTokenResponse.status).toBe(401); + }); + }); + + describe("Logout: POST /user-service/logout", () => { + test("Should logout user", async () => { + const userDbCheck = await mongoose.model("User").findOne({ + email: user.body.user.email, + }); + + expect(userDbCheck.tokens.length).toBe(1); + + const logoutResponse = await request(app) + .post("/user-service/logout") + .set("Cookie", authToken) + .send(); + + const userDbCheck2 = await mongoose.model("User").findOne({ + email: user.body.user.email, + }); + + expect(userDbCheck2.tokens.length).toBe(0); + expect(logoutResponse.status).toBe(200); + }); + test("Should return 401 if not authorized", async () => { + const logoutResponse = await request(app) + .post("/user-service/logout") + .set("Cookie", "access-token=test") + .send(); + + expect(logoutResponse.status).toBe(401); + }); + }); + describe("Logout all: POST /user-service/logout-all", () => { + test("Should logout all users", async () => { + await request(app).post("/user-service/login").send({ + email: user.body.user.email, + password: "test1234", + }); + + const userDbCheck = await mongoose.model("User").findOne({ + email: user.body.user.email, + }); + + expect(userDbCheck.tokens.length).toBe(2); + + const logoutAllResponse = await request(app) + .post("/user-service/logout-all") + .set("Cookie", authToken) + .send(); + + const userDbCheck2 = await mongoose.model("User").findOne({ + email: user.body.user.email, + }); + + expect(userDbCheck2.tokens.length).toBe(0); + expect(logoutAllResponse.status).toBe(200); + }); + }); +}); diff --git a/tests/utils/db-setup.js b/tests/utils/db-setup.js new file mode 100644 index 0000000..69c8b81 --- /dev/null +++ b/tests/utils/db-setup.js @@ -0,0 +1,49 @@ +const createTestData = (mongoose) => { + const file = new mongoose.model("fs.files"); + + file.create({ + name: "test.txt", + type: "text/plain", + size: 10, + userId: "5f7e5d8d1f962d5a0f5e8a9e", + }); +}; + +const envFileFix = (env) => { + env.key = process.env.KEY; + env.newKey = process.env.NEW_KEY; + env.passwordAccess = process.env.PASSWORD_ACCESS; + env.passwordRefresh = process.env.PASSWORD_REFRESH; + env.passwordCookie = process.env.PASSWORD_COOKIE; + env.createAcctBlocked = process.env.BLOCK_CREATE_ACCOUNT; + env.root = process.env.ROOT; + env.url = process.env.URL; + env.mongoURL = process.env.MONGODB_URL; + env.dbType = process.env.DB_TYPE; + env.fsDirectory = process.env.FS_DIRECTORY; + env.s3ID = process.env.S3_ID; + env.s3Key = process.env.S3_KEY; + env.s3Bucket = process.env.S3_BUCKET; + env.useDocumentDB = process.env.USE_DOCUMENT_DB; + env.documentDBBundle = process.env.DOCUMENT_DB_BUNDLE; + env.sendgridKey = process.env.SENDGRID_KEY; + env.sendgridEmail = process.env.SENDGRID_EMAIL; + env.remoteURL = process.env.REMOTE_URL; + env.secureCookies = process.env.SECURE_COOKIES; + env.tempDirectory = process.env.TEMP_DIRECTORY; + env.emailVerification = process.env.EMAIL_VERIFICATION; + env.emailDomain = process.env.EMAIL_DOMAIN; + env.emailAPIKey = process.env.EMAIL_API_KEY; + env.emailHost = process.env.EMAIL_HOST; + env.emailPort = process.env.EMAIL_PORT; + env.emailAddress = process.env.EMAIL_ADDRESS; + env.videoThumbnailsEnabled = process.env.VIDEO_THUMBNAILS_ENABLED === "true"; + env.tempVideoThumbnailLimit = process.env.TEMP_VIDEO_THUMBNAIL_LIMIT + ? +process.env.TEMP_VIDEO_THUMBNAIL_LIMIT + : 0; + env.docker = process.env.DOCKER === "true"; +}; + +module.exports = { + envFileFix, +}; diff --git a/tests/utils/express-app.js b/tests/utils/express-app.js new file mode 100644 index 0000000..d920d8c --- /dev/null +++ b/tests/utils/express-app.js @@ -0,0 +1,49 @@ +const express = require("express"); +// const requestIp = require("request-ip"); +const bodyParser = require("body-parser"); +const cookieParser = require("cookie-parser"); +const helmet = require("helmet"); +const compression = require("compression"); +const busboy = require("connect-busboy"); +const userRouter = + require("../../dist-backend/express-routers/user-router").default; +const fileRouter = + require("../../dist-backend/express-routers/file-router").default; +const folderRouter = + require("../../dist-backend/express-routers/folder-router").default; +const env = require("../../dist-backend/enviroment/env"); +const middlewareErrorHandler = + require("../../dist-backend/middleware/utils/middleware-utils").middlewareErrorHandler; +const getEnviromentVariables = require("../../dist-backend/enviroment/get-env-variables"); + +process.env.NODE_ENV = "test"; + +getEnviromentVariables(); + +const app = express(); + +app.use(cookieParser(env.passwordCookie)); +app.use(helmet()); +app.use(compression()); +app.use(express.json()); +app.use(bodyParser.json({ limit: "50mb" })); +app.use( + bodyParser.urlencoded({ + limit: "50mb", + extended: true, + parameterLimit: 50000, + }) +); +// app.use(requestIp.mw()); + +app.use( + busboy({ + highWaterMark: 2 * 1024 * 1024, + }) +); + +app.use(userRouter, fileRouter, folderRouter); + +app.use(middlewareErrorHandler); + +module.exports = app; diff --git a/tests/utils/fileUtils.js b/tests/utils/fileUtils.js new file mode 100644 index 0000000..4453989 --- /dev/null +++ b/tests/utils/fileUtils.js @@ -0,0 +1,15 @@ +// const createFolderTree = async (request, app, token) => { +// const rootFolderResponse = await request(app) +// .post("/folder-service/create") +// .set("Cookie", authToken) +// .send({ +// name: "test", +// parent: "/", +// }); + +// expect(rootFolderResponse.status).toBe(201); + +// const rootFolderId = rootFolderResponse.body._id; + +// const folder2Response = +// }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a7fc6fb --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..7bcf571 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,68 @@ +import { defineConfig, loadEnv } from "vite"; +import react from "@vitejs/plugin-react"; +import { visualizer } from "rollup-plugin-visualizer"; +import { VitePWA } from "vite-plugin-pwa"; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, "./src/config/"); + + const proxyURL = env.VITE_PROXY_URL || "http://localhost:3000"; + + console.log(`\nBackend Development Proxy URL: ${proxyURL}/api\n`); + + return { + plugins: [ + react(), + visualizer(), + VitePWA({ + registerType: "autoUpdate", + workbox: { + globPatterns: ["**/*.{js,css,html,ico,png,svg}"], + navigateFallbackDenylist: [ + /^\/file-service\/download\//, // Matches any path starting with /file-service/download/ + /^\/file-service\/public\/download\/[^\/]+\/[^\/]+/, // Matches /file-service/public/download/:id/:tempToken + /^\/folder-service\/download-zip/, // Matches /folder-service/download-zip + /^\/file-service\/stream-video\/[^\/]+/, // Matches /file-service/stream-video/:id + /^\/file-service\/download\/[^\/]+/, // Matches /file-service/download/:id + ], + runtimeCaching: [ + { + // Matches any URL that follows the pattern /file-service/thumbnail/{id} + urlPattern: /\/file-service\/thumbnail\/[a-zA-Z0-9_-]+$/, + handler: "CacheFirst", + options: { + cacheName: "dynamic-thumbnails", + expiration: { + maxEntries: 1000, + maxAgeSeconds: 60 * 60 * 24 * 7, // Cache for 1 week + }, + cacheableResponse: { + statuses: [0, 200], + }, + }, + }, + ], + }, + }), + ], + build: { + outDir: "dist-frontend", + }, + resolve: { + extensions: [".js", ".jsx", ".ts", ".tsx"], // Include these extensions + }, + envDir: "./src/config/", + server: { + proxy: proxyURL + ? { + "/api": { + target: proxyURL, // The port where your backend is running + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ""), + }, + } + : undefined, + host: proxyURL ? true : undefined, + }, + }; +});