UNPKG

@xrengine/server-core

Version:

Shared components for XREngine server

745 lines (658 loc) 26.1 kB
import { BadRequest, Forbidden } from '@feathersjs/errors' import { Id, Params } from '@feathersjs/feathers' import appRootPath from 'app-root-path' import { SequelizeServiceOptions, Service } from 'feathers-sequelize' import fs from 'fs' import path from 'path' import Sequelize, { Op } from 'sequelize' import { GITHUB_URL_REGEX, PUBLIC_SIGNED_REGEX } from '@xrengine/common/src/constants/GitHubConstants' import { DefaultUpdateSchedule, ProjectInterface, ProjectUpdateType } from '@xrengine/common/src/interfaces/ProjectInterface' import { UserInterface } from '@xrengine/common/src/interfaces/User' import { processFileName } from '@xrengine/common/src/utils/processFileName' import templateProjectJson from '@xrengine/projects/template-project/package.json' import { Application } from '../../../declarations' import config from '../../appconfig' import { getCacheDomain } from '../../media/storageprovider/getCacheDomain' import { getCachedURL } from '../../media/storageprovider/getCachedURL' import { getStorageProvider } from '../../media/storageprovider/storageprovider' import { getFileKeysRecursive } from '../../media/storageprovider/storageProviderUtils' import logger from '../../ServerLogger' import { UserParams } from '../../user/user/user.class' import { cleanString } from '../../util/cleanString' import { getContentType } from '../../util/fileUtils' import { copyFolderRecursiveSync, deleteFolderRecursive, getFilesRecursive } from '../../util/fsHelperFunctions' import { getGitConfigData, getGitHeadData, getGitOrigHeadData } from '../../util/getGitData' import { useGit } from '../../util/gitHelperFunctions' import { checkAppOrgStatus, checkUserOrgWriteStatus, checkUserRepoWriteStatus, getAuthenticatedRepo, getRepo, getUserRepos } from './github-helper' import { createOrUpdateProjectUpdateJob, getEnginePackageJson, getProjectConfig, getProjectPackageJson, onProjectEvent, removeProjectUpdateJob } from './project-helper' const templateFolderDirectory = path.join(appRootPath.path, `packages/projects/template-project/`) const projectsRootFolder = path.join(appRootPath.path, 'packages/projects/projects/') export type ProjectQueryParams = { sourceURL?: string destinationURL?: string existingProject?: boolean inputProjectURL?: string branchName?: string selectedSHA?: string } export type ProjectParams = { user: UserInterface } & Params<ProjectQueryParams> export type ProjectParamsClient = Omit<ProjectParams, 'user'> export const copyDefaultProject = () => { deleteFolderRecursive(path.join(projectsRootFolder, `default-project`)) copyFolderRecursiveSync(path.join(appRootPath.path, 'packages/projects/default-project'), projectsRootFolder) } const getGitProjectData = (project) => { const response = { repositoryPath: '', sourceRepo: '', sourceBranch: '', commitSHA: '' } //TODO: We can use simpleGit instead of manually accessing files. const projectGitDir = path.resolve(__dirname, `../../../../projects/projects/${project}/.git`) const config = getGitConfigData(projectGitDir) if (config?.remote?.origin?.url) { response.repositoryPath = config?.remote?.origin?.url response.sourceRepo = config?.remote?.origin?.url } const branch = getGitHeadData(projectGitDir) if (branch) { response.sourceBranch = branch } const sha = getGitOrigHeadData(projectGitDir, branch) if (sha) { response.commitSHA = sha } return response } export const deleteProjectFilesInStorageProvider = async (projectName: string, storageProviderName?: string) => { const storageProvider = getStorageProvider(storageProviderName) try { const existingFiles = await getFileKeysRecursive(`projects/${projectName}`) if (existingFiles.length) { await Promise.all([ storageProvider.deleteResources(existingFiles), storageProvider.createInvalidation([`projects/${projectName}*`]) ]) } } catch (e) { logger.info('[ERROR deleteProjectFilesInStorageProvider]:', e) } } /** * Updates the local storage provider with the project's current files * @param projectName * @param storageProviderName * @param remove */ export const uploadLocalProjectToProvider = async (projectName, remove = true, storageProviderName?: string) => { const storageProvider = getStorageProvider(storageProviderName) const cacheDomain = getCacheDomain(storageProvider, true) // remove exiting storage provider files logger.info(`uploadLocalProjectToProvider for project "${projectName}" started at "${new Date()}".`) if (remove) { await deleteProjectFilesInStorageProvider(projectName) } // upload new files to storage provider const projectPath = path.resolve(projectsRootFolder, projectName) const files = getFilesRecursive(projectPath) const results = await Promise.all( files .filter((file) => !file.includes(`projects/${projectName}/.git/`)) .map((file: string) => { return new Promise(async (resolve) => { try { const fileResult = fs.readFileSync(file) const filePathRelative = processFileName(file.slice(projectPath.length)) await storageProvider.putObject( { Body: fileResult, ContentType: getContentType(file), Key: `projects/${projectName}${filePathRelative}` }, { isDirectory: false } ) resolve(getCachedURL(`projects/${projectName}${filePathRelative}`, cacheDomain)) } catch (e) { logger.error(e) resolve(null) } }) }) ) logger.info(`uploadLocalProjectToProvider for project "${projectName}" ended at "${new Date()}".`) return results.filter((success) => !!success) as string[] } export class Project extends Service { app: Application docs: any constructor(options: Partial<SequelizeServiceOptions>, app: Application) { super(options) this.app = app this.app.isSetup.then(() => this._callOnLoad()) } async _getCommitSHADate(projectName: string): Promise<{ commitSHA: string; commitDate: Date }> { const projectDirectory = path.resolve(appRootPath.path, `packages/projects/projects/${projectName}/`) const git = useGit(projectDirectory) let commitSHA = '' let commitDate try { commitSHA = await git.revparse(['HEAD']) const commit = await git.log(['-1']) commitDate = commit?.latest?.date ? new Date(commit.latest.date) : new Date() } catch (err) {} return { commitSHA, commitDate } } async _callOnLoad() { const projects = ( (await super.find({ query: { $select: ['name'] } })) as any ).data as Array<{ name }> await Promise.all( projects.map(async ({ name }) => { if (!fs.existsSync(path.join(projectsRootFolder, name, 'xrengine.config.ts'))) return const config = await getProjectConfig(name) if (config?.onEvent) return onProjectEvent(this.app, name, config.onEvent, 'onLoad') }) ) } async _seedProject(projectName: string): Promise<any> { logger.warn('[Projects]: Found new locally installed project: ' + projectName) const projectConfig = (await getProjectConfig(projectName)) ?? {} const gitData = getGitProjectData(projectName) const { commitSHA, commitDate } = await this._getCommitSHADate(projectName) await super.create({ thumbnail: projectConfig.thumbnail, name: projectName, repositoryPath: gitData.repositoryPath, sourceRepo: gitData.sourceRepo, sourceBranch: gitData.sourceBranch, commitSHA, commitDate, needsRebuild: true, updateType: 'none' as ProjectUpdateType, updateSchedule: DefaultUpdateSchedule }) // run project install script if (projectConfig.onEvent) { return onProjectEvent(this.app, projectName, projectConfig.onEvent, 'onInstall') } return Promise.resolve() } /** * On dev, sync the db with any projects installed locally */ async _fetchDevLocalProjects() { const data = (await this.Model.findAll({ paginate: false })) as ProjectInterface[] if (!fs.existsSync(projectsRootFolder)) { fs.mkdirSync(projectsRootFolder, { recursive: true }) } const locallyInstalledProjects = fs .readdirSync(projectsRootFolder, { withFileTypes: true }) .filter((dirent) => dirent.isDirectory()) .map((dirent) => dirent.name) const promises: Promise<any>[] = [] for (const projectName of locallyInstalledProjects) { if (!data.find((e) => e.name === projectName)) { try { promises.push(this._seedProject(projectName)) } catch (e) { logger.error(e) } } const { commitSHA, commitDate } = await this._getCommitSHADate(projectName) await this.Model.update( { commitSHA, commitDate }, { where: { name: projectName } } ) promises.push(uploadLocalProjectToProvider(projectName)) } await Promise.all(promises) await this._callOnLoad() for (const { name, id } of data) { if (!locallyInstalledProjects.includes(name)) { await deleteProjectFilesInStorageProvider(name) logger.warn(`[Projects]: Project ${name} not found, assuming removed`) await super.remove(id) } } } async create(data: { name: string }, params?: Params) { const projectName = cleanString(data.name) const projectLocalDirectory = path.resolve(projectsRootFolder, projectName) if (await this.Model.count({ where: { name: projectName } })) throw new Error(`[Projects]: Project with name ${projectName} already exists`) if ((!config.db.forceRefresh && projectName === 'default-project') || projectName === 'template-project') throw new Error(`[Projects]: Project name ${projectName} not allowed`) copyFolderRecursiveSync(templateFolderDirectory, projectsRootFolder) fs.renameSync(path.resolve(projectsRootFolder, 'template-project'), projectLocalDirectory) fs.mkdirSync(path.resolve(projectLocalDirectory, '.git'), { recursive: true }) const git = useGit(path.resolve(projectLocalDirectory, '.git')) try { await git.init(true) } catch (e) { logger.warn(e) } const packageData = Object.assign({}, templateProjectJson) as any packageData.name = projectName packageData.etherealEngine.version = getEnginePackageJson().version fs.writeFileSync(path.resolve(projectLocalDirectory, 'package.json'), JSON.stringify(packageData, null, 2)) await uploadLocalProjectToProvider(projectName, false) return super.create( { thumbnail: packageData.thumbnail, name: projectName, repositoryPath: null, needsRebuild: true }, params ) } /** * 1. Clones the repo to the local FS * 2. If in production mode, uploads it to the storage provider * 3. Creates a database entry * @param data * @param placeholder This is where data normally goes, but we've put data as the first parameter * @param params * @returns */ // @ts-ignore async update( data: { sourceURL: string destinationURL: string name?: string needsRebuild?: boolean reset?: boolean commitSHA?: string sourceBranch: string updateType: ProjectUpdateType updateSchedule: string }, placeholder?: null, params?: UserParams ) { if (data.sourceURL === 'default-project') { copyDefaultProject() await uploadLocalProjectToProvider('default-project') return } const urlParts = data.sourceURL.split('/') let projectName = data.name || urlParts.pop() if (!projectName) throw new Error('Git repo must be plain URL') projectName = projectName.toLowerCase() if (projectName.substring(projectName.length - 4) === '.git') projectName = projectName.slice(0, -4) if (projectName.substring(projectName.length - 1) === '/') projectName = projectName.slice(0, -1) const projectLocalDirectory = path.resolve(appRootPath.path, `packages/projects/projects/`) const projectDirectory = path.resolve(appRootPath.path, `packages/projects/projects/${projectName}/`) // if project exists already, remove it and re-clone it if (fs.existsSync(projectDirectory)) { // if (isDev) throw new Error('Cannot create project - already exists') deleteFolderRecursive(projectDirectory) } const project = await this.app.service('project').Model.findOne({ where: { name: projectName } }) const userId = params!.user?.id || project.updateUserId const githubIdentityProvider = await this.app.service('identity-provider').Model.findOne({ where: { userId: userId, type: 'github' } }) let repoPath = await getAuthenticatedRepo(githubIdentityProvider.oauthToken, data.sourceURL) if (!repoPath) repoPath = data.sourceURL //public repo const gitCloner = useGit(projectLocalDirectory) await gitCloner.clone(repoPath, projectDirectory) const git = useGit(projectDirectory) const branchName = `${config.server.releaseName}-deployment` try { const branchExists = await git.raw(['ls-remote', '--heads', repoPath, `${branchName}`]) if (data.commitSHA) await git.checkout(data.commitSHA) if (branchExists.length === 0 || data.reset) { try { await git.deleteLocalBranch(branchName) } catch (err) {} await git.checkoutLocalBranch(branchName) } else await git.checkout(branchName) } catch (err) { logger.error(err) throw err } await uploadLocalProjectToProvider(projectName) const projectConfig = (await getProjectConfig(projectName)) ?? {} // when we have successfully re-installed the project, remove the database entry if it already exists const existingProjectResult = await this.Model.findOne({ where: { [Op.or]: [ Sequelize.where(Sequelize.fn('lower', Sequelize.col('name')), { [Op.like]: '%' + projectName + '%' }) ] } }) let repositoryPath = data.destinationURL || data.sourceURL const publicSignedExec = PUBLIC_SIGNED_REGEX.exec(repositoryPath) //In testing, intermittently the signed URL was being entered into the database, which made matching impossible. //Stripping the signed portion out if it's about to be inserted. if (publicSignedExec) repositoryPath = `https://github.com/${publicSignedExec[1]}/${publicSignedExec[2]}` const { commitSHA, commitDate } = await this._getCommitSHADate(projectName) const returned = !existingProjectResult ? // Add to DB await super.create( { thumbnail: projectConfig.thumbnail, name: projectName, repositoryPath, needsRebuild: data.needsRebuild ? data.needsRebuild : true, sourceRepo: data.sourceURL, sourceBranch: data.sourceBranch, updateType: data.updateType, updateSchedule: data.updateSchedule, updateUserId: userId, commitSHA, commitDate }, params || {} ) : await super.patch(existingProjectResult.id, { commitSHA, commitDate, sourceRepo: data.sourceURL, sourceBranch: data.sourceBranch, updateType: data.updateType, updateSchedule: data.updateSchedule, updateUserId: userId }) returned.needsRebuild = typeof data.needsRebuild === 'boolean' ? data.needsRebuild : true if (!existingProjectResult) { await this.app.service('project-permission').create({ projectId: returned.id, userId }) } if (returned.name !== projectName) await super.patch(existingProjectResult.id, { name: projectName }) if (data.reset) { let repoPath = await getAuthenticatedRepo(githubIdentityProvider.oauthToken, data.destinationURL) if (!repoPath) repoPath = data.destinationURL //public repo await git.addRemote('destination', repoPath) await git.push('destination', branchName, ['-f', '--tags']) const { commitSHA, commitDate } = await this._getCommitSHADate(projectName) await super.patch(returned.id, { commitSHA, commitDate }) } // run project install script if (projectConfig.onEvent) { await onProjectEvent( this.app, projectName, projectConfig.onEvent, existingProjectResult ? 'onUpdate' : 'onInstall' ) } if (this.app.k8BatchClient && (data.updateType === 'tag' || data.updateType === 'commit')) { await createOrUpdateProjectUpdateJob(this.app, projectName) } else if (this.app.k8BatchClient && (data.updateType === 'none' || data.updateType == null)) await removeProjectUpdateJob(this.app, projectName) return returned } async patch(id: Id, data: any, params?: UserParams) { if (data.repositoryPath) { const repoPath = data.repositoryPath const user = params!.user! const githubIdentityProvider = await this.app.service('identity-provider').Model.findOne({ where: { userId: user.id, type: 'github' } }) const githubPathRegexExec = GITHUB_URL_REGEX.exec(repoPath) if (!githubPathRegexExec) throw new BadRequest('Invalid Github URL') if (!githubIdentityProvider) throw new Error('Must be logged in with GitHub to link a project to a GitHub repo') const split = githubPathRegexExec[2].split('/') const org = split[0] const repo = split[1].replace('.git', '') const appOrgAccess = await checkAppOrgStatus(org, githubIdentityProvider.oauthToken) if (!appOrgAccess) throw new Forbidden( `The organization ${org} needs to install the GitHub OAuth app ${config.authentication.oauth.github.key} in order to push code to its repositories` ) const repoWriteStatus = await checkUserRepoWriteStatus(org, repo, githubIdentityProvider.oauthToken) if (repoWriteStatus !== 200) { if (repoWriteStatus === 404) { const orgWriteStatus = await checkUserOrgWriteStatus(org, githubIdentityProvider.oauthToken) if (orgWriteStatus !== 200) throw new Forbidden('You do not have write access to that organization') } else { throw new Forbidden('You do not have write access to that repo') } } } return super.patch(id, data, params) } async remove(id: Id, params?: Params) { if (!id) return const { name } = await super.get(id, params) const projectConfig = await getProjectConfig(name) // run project uninstall script if (projectConfig?.onEvent) { await onProjectEvent(this.app, name, projectConfig.onEvent, 'onUninstall') } if (fs.existsSync(path.resolve(projectsRootFolder, name))) { fs.rmSync(path.resolve(projectsRootFolder, name), { recursive: true }) } logger.info(`[Projects]: removing project id "${id}", name: "${name}".`) await deleteProjectFilesInStorageProvider(name) const locationItems = await (this.app.service('location') as any).Model.findAll({ where: { sceneId: { [Op.like]: `${name}/%` } } }) locationItems.length && locationItems.forEach(async (location) => { await this.app.service('location').remove(location.dataValues.id) }) const whereClause = { [Op.and]: [ { project: name }, { project: { [Op.ne]: null } } ] } const routeItems = await (this.app.service('route') as any).Model.findAll({ where: whereClause }) routeItems.length && routeItems.forEach(async (route) => { await this.app.service('route').remove(route.dataValues.id) }) const avatarItems = await (this.app.service('avatar') as any).Model.findAll({ where: whereClause }) await Promise.all( avatarItems.map(async (avatar) => { await this.app.service('avatar').remove(avatar.dataValues.id) }) ) const staticResourceItems = await (this.app.service('static-resource') as any).Model.findAll({ where: whereClause }) staticResourceItems.length && staticResourceItems.forEach(async (staticResource) => { await this.app.service('static-resource').remove(staticResource.dataValues.id) }) await removeProjectUpdateJob(this.app, name) return super.remove(id, params) } async get(name: string, params?: Params): Promise<{ data: ProjectInterface }> { if (!params) params = {} if (!params.query) params.query = {} if (!params.query.$limit) params.query.$limit = 1000 const data: ProjectInterface[] = ((await super.find(params)) as any).data const project = data.find((e) => e.name === name) if (!project) return null! return { data: project } } async updateSettings(id: Id, data: { settings: string }) { return super.patch(id, data) } //@ts-ignore async find(params?: UserParams): Promise<{ data: ProjectInterface[]; errors: any[] }> { let projectPushIds: string[] = [] const errors = [] as any if (params?.query?.allowed != null) { // See if the user has a GitHub identity-provider, and if they do, also determine which GitHub repos they personally // can push to. const githubIdentityProvider = await this.app.service('identity-provider').Model.findOne({ where: { userId: params.user!.id, type: 'github' } }) // Get all of the projects that this user has permissions for, then calculate push status by whether the user // can push to it. This will make sure no one tries to push to a repo that they do not have write access to. const projectPermissions = (await this.app.service('project-permission').Model.findAll({ where: { userId: params.user!.id }, include: [{ model: this.app.service('project').Model }], paginate: false })) as any let allowedProjects = await projectPermissions.map((permission) => permission.project) const repoAccess = githubIdentityProvider ? await this.app.service('github-repo-access').Model.findAll({ paginate: false, where: { identityProviderId: githubIdentityProvider.id } }) : [] const pushRepoPaths = repoAccess.filter((repo) => repo.hasWriteAccess).map((item) => item.repo.toLowerCase()) let allowedProjectGithubRepos = allowedProjects.filter((project) => project.repositoryPath != null) allowedProjectGithubRepos = await Promise.all( allowedProjectGithubRepos.map(async (project) => { const regexExec = GITHUB_URL_REGEX.exec(project.repositoryPath) if (!regexExec) return { repositoryPath: '', name: '' } const split = regexExec[2].split('/') project.repositoryPath = `https://github.com/${split[0]}/${split[1]}` return project }) ) const pushableAllowedProjects = allowedProjectGithubRepos.filter( (project) => pushRepoPaths.indexOf(project.repositoryPath.toLowerCase().replace(/.git$/, '')) > -1 ) projectPushIds = projectPushIds.concat(pushableAllowedProjects.map((project) => project.id)) if (githubIdentityProvider) { repoAccess.forEach((item, index) => { if (item.hasWriteAccess) { const url = item.repo.toLowerCase() repoAccess[index] = url repoAccess.push(`${url}.git`) const regexExec = GITHUB_URL_REGEX.exec(url) if (regexExec) { const split = regexExec[2].split('/') repoAccess.push(`git@github.com:${split[0]}/${split[1]}`) repoAccess.push(`git@github.com:${split[0]}/${split[1]}.git`) } } else repoAccess.splice(index) }) const matchingAllowedRepos = await this.app.service('project').Model.findAll({ where: { repositoryPath: { [Op.in]: repoAccess } } }) projectPushIds = projectPushIds.concat(matchingAllowedRepos.map((repo) => repo.id)) } if (!params.user!.scopes?.find((scope) => scope.type === 'admin:admin')) params.query.id = { $in: [...new Set(allowedProjects.map((project) => project.id))] } delete params.query.allowed if (!params.sequelize) params.sequelize = { raw: false } if (!params.sequelize.include) params.sequelize.include = [] params.sequelize.include.push({ model: this.app.service('project-permission').Model, include: [this.app.service('user').Model] }) } params = { ...params, query: { ...params?.query, $limit: params?.query?.$limit || 1000, $select: params?.query?.$select || [ 'id', 'name', 'thumbnail', 'repositoryPath', 'needsRebuild', 'sourceRepo', 'sourceBranch', 'updateType', 'updateSchedule', 'commitSHA', 'commitDate' ] } } const data: ProjectInterface[] = ((await super.find(params)) as any).data data.forEach((item) => { const values = (item as any).dataValues ? ((item as any).dataValues as ProjectInterface) : (item as ProjectInterface) try { const packageJson = getProjectPackageJson(values.name) values.version = packageJson.version values.engineVersion = packageJson.etherealEngine?.version values.description = packageJson.description values.hasWriteAccess = projectPushIds.indexOf(item.id) > -1 } catch (err) {} }) return { data, errors } } }