diff --git a/packages/client-core/package.json b/packages/client-core/package.json index 62e36bda5d..0b8eace789 100755 --- a/packages/client-core/package.json +++ b/packages/client-core/package.json @@ -54,6 +54,7 @@ "i18next": "21.6.16", "image-palette-core": "0.2.2", "javascript-time-ago": "^2.5.7", + "jszip": "^3.10.1", "lodash": "4.17.21", "material-ui-confirm": "3.0.5", "mediasoup-client": "3.6.57", @@ -70,6 +71,7 @@ "react-reflex": "^4.0.9", "react-router-dom": "6.8.1", "recovery": "^0.2.6", + "save-as": "^0.1.8", "styled-components": "5.3.3", "tick-tock": "^1.0.0", "typescript": "4.9.5", diff --git a/packages/client-core/src/admin/common/variables/projects.ts b/packages/client-core/src/admin/common/variables/projects.ts index 15aab030c4..b4a4a39f48 100644 --- a/packages/client-core/src/admin/common/variables/projects.ts +++ b/packages/client-core/src/admin/common/variables/projects.ts @@ -4,6 +4,7 @@ export interface ProjectColumn { | 'projectVersion' | 'commitSHA' | 'commitDate' + | 'download' | 'update' | 'invalidate' | 'view' @@ -21,6 +22,7 @@ export const projectsColumns: ProjectColumn[] = [ { id: 'projectVersion', label: 'Version', minWidth: 65 }, { id: 'commitSHA', label: 'Commit SHA', minWidth: 100 }, { id: 'commitDate', label: 'Commit Date', minWidth: 100 }, + { id: 'download', label: 'Download', minWidth: 65 }, { id: 'update', label: 'Update', minWidth: 65, align: 'center' }, { id: 'push', label: 'Push to GitHub', minWidth: 65, align: 'center' }, { id: 'link', label: 'GitHub Repo Link', minWidth: 65, align: 'center' }, diff --git a/packages/client-core/src/admin/components/Project/ProjectTable.tsx b/packages/client-core/src/admin/components/Project/ProjectTable.tsx index 02448544ad..c8bc301dde 100644 --- a/packages/client-core/src/admin/components/Project/ProjectTable.tsx +++ b/packages/client-core/src/admin/components/Project/ProjectTable.tsx @@ -1,14 +1,30 @@ +import { Paginated } from '@feathersjs/client' +import JSZip from 'jszip' +import _ from 'lodash' import React, { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { saveAs } from 'save-as' import ConfirmDialog from '@etherealengine/client-core/src/common/components/ConfirmDialog' +import config from '@etherealengine/common/src/config' +import { FileContentType } from '@etherealengine/common/src/interfaces/FileContentType' import { ProjectInterface } from '@etherealengine/common/src/interfaces/ProjectInterface' import multiLogger from '@etherealengine/common/src/logger' +import { Engine } from '@etherealengine/engine/src/ecs/classes/Engine' +import { EngineActions } from '@etherealengine/engine/src/ecs/classes/EngineState' +import { createActionQueue, getState, startReactor } from '@etherealengine/hyperflux' import Box from '@etherealengine/ui/src/Box' import Icon from '@etherealengine/ui/src/Icon' import IconButton from '@etherealengine/ui/src/IconButton' import Tooltip from '@etherealengine/ui/src/Tooltip' +import { API } from '../../../API' +import { + FileBrowserAction, + FileBrowserService, + FileBrowserServiceReceptor, + FileBrowserState +} from '../../../common/services/FileBrowserService' import { NotificationService } from '../../../common/services/NotificationService' import { PROJECT_PAGE_LIMIT, ProjectService, useProjectState } from '../../../common/services/ProjectService' import { useAuthState } from '../../../user/services/AuthService' @@ -127,6 +143,15 @@ const ProjectTable = ({ className }: Props) => { }) } + const DownloadProject = async (row: ProjectInterface) => { + setProject(row) + const url = `/projects/${row.name}` + + const data = await API.instance.client.service('archiver').get(url) + const blob = await (await fetch(`${config.client.fileServer}/${data}`)).blob() + saveAs(blob, row.name + '.zip') + } + const openInvalidateConfirmation = (row) => { setProject(row) @@ -250,12 +275,12 @@ const ProjectTable = ({ className }: Props) => { name="update" disabled={el.repositoryPath === null} onClick={() => handleOpenProjectDrawer(el)} - icon={} + icon={} /> )} {isAdmin && name === 'default-project' && ( - } /> + } /> )} @@ -273,6 +298,19 @@ const ProjectTable = ({ className }: Props) => { )} ), + download: ( + <> + {isAdmin && ( + DownloadProject(el)} + icon={} + /> + )} + + ), link: ( <> > { + app: Application + + constructor(app: Application) { + this.app = app + } + + async setup(app: Application, path: string) {} + + async get(directory: string, params?: UserParams): Promise { + if (!params) params = {} + if (!params.query) params.query = {} + const { storageProviderName } = params.query + + delete params.query.storageProviderName + + const storageProvider = getStorageProvider(storageProviderName) + if (directory[0] === '/') directory = directory.slice(1) + + let result = await storageProvider.listFolderContent(directory) + + const zip = new JSZip() + + for (let i = 0; i < result.length; i++) { + if (result[i].type == 'folder') { + let content = await storageProvider.listFolderContent(result[i].key) + content.forEach((f) => { + result.push(f) + }) + } + + if (result[i].type == 'folder') continue + + const blobPromise = await fetch(result[i].url, { method: 'GET' }).then((r) => { + if (r.status === 200) return r.arrayBuffer() + return Promise.reject(new Error(r.statusText)) + }) + + const dir = result[i].key.substring(result[i].key.indexOf('/') + 1) + zip.file(dir, blobPromise) + } + + const generated = await zip.generateAsync({ type: 'blob', streamFiles: true }) + + const zipOutputDirectory = `'temp'${directory.substring(directory.lastIndexOf('/'))}.zip` + + await storageProvider.putObject({ + Key: zipOutputDirectory, + Body: Buffer.from(await generated.arrayBuffer()), + ContentType: 'archive/zip' + }) + + return zipOutputDirectory + } +} diff --git a/packages/server-core/src/media/recursive-archiver/archiver.hooks.ts b/packages/server-core/src/media/recursive-archiver/archiver.hooks.ts new file mode 100755 index 0000000000..a1fde86a1a --- /dev/null +++ b/packages/server-core/src/media/recursive-archiver/archiver.hooks.ts @@ -0,0 +1,37 @@ +import { disallow } from 'feathers-hooks-common' + +import logRequest from '@etherealengine/server-core/src/hooks/log-request' + +// Don't remove this comment. It's needed to format import lines nicely. + +export default { + before: { + all: [logRequest()], + find: [disallow()], + get: [], + create: [disallow()], + update: [disallow()], + patch: [disallow()], + remove: [disallow()] + }, + + after: { + all: [], + find: [], + get: [], + create: [], + update: [], + patch: [], + remove: [] + }, + + error: { + all: [logRequest()], + find: [], + get: [], + create: [], + update: [], + patch: [], + remove: [] + } +} as any diff --git a/packages/server-core/src/media/recursive-archiver/archiver.service.ts b/packages/server-core/src/media/recursive-archiver/archiver.service.ts new file mode 100644 index 0000000000..f36000992f --- /dev/null +++ b/packages/server-core/src/media/recursive-archiver/archiver.service.ts @@ -0,0 +1,30 @@ +import multer from 'multer' + +import { Application } from '../../../declarations' +import { Archiver } from './archiver.class' +import hooks from './archiver.hooks' + +declare module '@etherealengine/common/declarations' { + interface ServiceTypes { + archiver: Archiver + } +} + +const multipartMiddleware = multer({ limits: { fieldSize: Infinity, files: 1 } }) + +export default (app: Application): any => { + const archiver = new Archiver(app) + // fileBrowser.docs = projectDocs + + /** + * Initialize our service with any options it requires and docs + */ + app.use('archiver', archiver) + + /** + * Get our initialized service so that we can register hooks + */ + const service = app.service('archiver') + + service.hooks(hooks as any) +} diff --git a/packages/server-core/src/media/services.ts b/packages/server-core/src/media/services.ts index 6bf0bcb7d3..b52e5ce4c6 100755 --- a/packages/server-core/src/media/services.ts +++ b/packages/server-core/src/media/services.ts @@ -7,6 +7,7 @@ import Image from './image/image.service' import Material from './material/material.service' import Model from './model/model.service' import OEmbed from './oembed/oembed.service' +import Archiver from './recursive-archiver/archiver.service' import Rig from './rig/rig.service' import Script from './script/script.service' import SerializedEntity from './serialized-entity/serialized-entity.service' @@ -33,5 +34,6 @@ export default [ SerializedEntity, Upload, Video, - Volumetric + Volumetric, + Archiver ]