Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): Add feature to fork projects #239

Merged
merged 3 commits into from
May 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
"start": "node dist/main",
"dev": "cross-env NODE_ENV=dev nest start --watch",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"db:generate-types": "pnpm dlx prisma generate --schema=src/prisma/schema.prisma",
"db:generate-migrations": "pnpx dotenv-cli -e ../../.env -- pnpx prisma migrate dev --create-only --skip-seed --schema=src/prisma/schema.prisma",
"db:deploy-migrations": "pnpx dotenv-cli -e ../../.env -- pnpx prisma migrate deploy --schema=src/prisma/schema.prisma",
Expand All @@ -18,8 +17,9 @@
"db:reset": "pnpx dotenv-cli -e ../../.env -- pnpm dlx prisma migrate reset --force --schema=src/prisma/schema.prisma",
"sourcemaps": "sentry-cli sourcemaps inject ./dist && sentry-cli sourcemaps upload ./dist || echo 'Failed to upload source maps to Sentry'",
"e2e:prepare": "cd ../../ && docker compose down && docker compose -f docker-compose-test.yml up -d && cd apps/api && pnpm db:generate-types && cross-env NODE_ENV='e2e' DATABASE_URL='postgresql://prisma:prisma@localhost:5432/tests' pnpm run db:deploy-migrations",
"e2e": "pnpm run e2e:prepare && cross-env NODE_ENV='e2e' DATABASE_URL='postgresql://prisma:prisma@localhost:5432/tests' turbo run test --no-cache --filter=api -- --runInBand --config=jest.e2e-config.ts --coverage --coverageDirectory=../../coverage-e2e/api --coverageReporters=json && pnpm run e2e:teardown",
"e2e:teardown": "cd ../../ && docker compose -f docker-compose-test.yml down"
"e2e": "pnpm run e2e:prepare && cross-env NODE_ENV='e2e' DATABASE_URL='postgresql://prisma:prisma@localhost:5432/tests' jest --runInBand --config=jest.e2e-config.ts --coverage --coverageDirectory=../../coverage-e2e/api --coverageReporters=json && pnpm run e2e:teardown",

Check failure

Code scanning / SonarCloud

PostgreSQL database passwords should not be disclosed

<!--SONAR_ISSUE_KEY:AY-qz8DwYbBhSzyuK0B9-->Make sure this PostgreSQL database password gets changed and removed from the code. <p>See more on <a href="https://sonarcloud.io/project/issues?id=keyshade-xyz_keyshade&issues=AY-qz8DwYbBhSzyuK0B9&open=AY-qz8DwYbBhSzyuK0B9&pullRequest=239">SonarCloud</a></p>
"e2e:teardown": "cd ../../ && docker compose -f docker-compose-test.yml down",
"unit": "pnpm db:generate-types && jest --config=jest.config.ts"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
Expand Down Expand Up @@ -67,6 +67,7 @@
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"ajv": "^7",
"dotenv-cli": "^7.4.2",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
Expand All @@ -80,7 +81,6 @@
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
Expand Down
14 changes: 13 additions & 1 deletion apps/api/src/common/authority-checker.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,19 @@ export class AuthorityCheckerService {
const projectAccessLevel = project.accessLevel
switch (projectAccessLevel) {
case ProjectAccessLevel.GLOBAL:
//everyone can access this
// We will only allow reads for the project. If the authority is READ_PROJECT, we will allow access
// For any other authority, the user needs to have the required collective authority over the workspace
// or WORKSPACE_ADMIN authority
if (authority !== Authority.READ_PROJECT) {
if (
!permittedAuthoritiesForWorkspace.has(authority) &&
!permittedAuthoritiesForWorkspace.has(Authority.WORKSPACE_ADMIN)
) {
throw new UnauthorizedException(
`User with id ${userId} does not have the authority in the project with id ${entity?.id}`
)
}
}
break
case ProjectAccessLevel.INTERNAL:
// Any workspace member with the required collective authority over the workspace or
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export class CreateEnvironment {

@IsString()
@IsOptional()
description: string
description?: string

@IsBoolean()
@IsOptional()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "forkedFromId" TEXT,
ADD COLUMN "isForked" BOOLEAN NOT NULL DEFAULT false;

-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_forkedFromId_fkey" FOREIGN KEY ("forkedFromId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
5 changes: 5 additions & 0 deletions apps/api/src/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ model Project {
isDisabled Boolean @default(false) // This is set to true when the user stops his subscription and still has premium features in use
accessLevel ProjectAccessLevel @default(PRIVATE)
pendingCreation Boolean @default(false)
isForked Boolean @default(false)

lastUpdatedBy User? @relation(fields: [lastUpdatedById], references: [id], onUpdate: Cascade, onDelete: SetNull)
lastUpdatedById String?
Expand All @@ -311,6 +312,10 @@ model Project {
environments Environment[]
workspaceRoles ProjectWorkspaceRoleAssociation[]
integrations Integration[]
forks Project[] @relation("Fork")

forkedFromId String?
forkedFrom Project? @relation("Fork", fields: [forkedFromId], references: [id], onDelete: SetNull, onUpdate: Cascade)
}

model ProjectWorkspaceRoleAssociation {
Expand Down
43 changes: 42 additions & 1 deletion apps/api/src/project/controller/project.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { CreateProject } from '../dto/create.project/create.project'
import { UpdateProject } from '../dto/update.project/update.project'
import { RequiredApiKeyAuthorities } from '../../decorators/required-api-key-authorities.decorator'
import { AlphanumericReasonValidationPipe } from '../../common/alphanumeric-reason-pipe'
import { ForkProject } from '../dto/fork.project/fork.project'

@Controller('project')
export class ProjectController {
Expand Down Expand Up @@ -61,12 +62,52 @@ export class ProjectController {
return await this.service.getProjectById(user, projectId)
}

@Post(':projectId/fork')
@RequiredApiKeyAuthorities(Authority.READ_PROJECT, Authority.CREATE_PROJECT)
async forkProject(
@CurrentUser() user: User,
@Param('projectId') projectId: Project['id'],
@Body() forkMetadata: ForkProject
) {
return await this.service.forkProject(user, projectId, forkMetadata)
}

@Put(':projectId/sync-fork')
@RequiredApiKeyAuthorities(Authority.READ_PROJECT, Authority.UPDATE_PROJECT)
async syncFork(
@CurrentUser() user: User,
@Param('projectId') projectId: Project['id'],
@Param('hardSync') hardSync: boolean = false
) {
return await this.service.syncFork(user, projectId, hardSync)
}

@Put(':projectId/unlink-fork')
@RequiredApiKeyAuthorities(Authority.UPDATE_PROJECT)
async unlinkFork(
@CurrentUser() user: User,
@Param('projectId') projectId: Project['id']
) {
return await this.service.unlinkParentOfFork(user, projectId)
}

@Get(':projectId/forks')
@RequiredApiKeyAuthorities(Authority.READ_PROJECT)
async getForks(
@CurrentUser() user: User,
@Param('projectId') projectId: Project['id'],
@Query('page') page: number = 0,
@Query('limit') limit: number = 10
) {
return await this.service.getAllProjectForks(user, projectId, page, limit)
}

@Get('/all/:workspaceId')
@RequiredApiKeyAuthorities(Authority.READ_PROJECT)
async getAllProjects(
@CurrentUser() user: User,
@Param('workspaceId') workspaceId: Workspace['id'],
@Query('page') page: number = 1,
@Query('page') page: number = 0,
@Query('limit') limit: number = 10,
@Query('sort') sort: string = 'name',
@Query('order') order: string = 'asc',
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/project/dto/fork.project/fork.project.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ForkProject } from './fork.project'

describe('ForkProject', () => {
it('should be defined', () => {
expect(new ForkProject()).toBeDefined()
})
})
16 changes: 16 additions & 0 deletions apps/api/src/project/dto/fork.project/fork.project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Workspace } from '@prisma/client'
import { IsOptional, IsString } from 'class-validator'

export class ForkProject {
@IsString()
@IsOptional()
workspaceId?: Workspace['id']

@IsString()
@IsOptional()
name?: string

@IsString()
@IsOptional()
storePrivateKey?: boolean
}
Loading
Loading