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
]