Skip to content

Commit

Permalink
feat(platform,platform-server): allow anonymous users visit public pr…
Browse files Browse the repository at this point in the history
…oject
  • Loading branch information
liuyi.levi committed Feb 1, 2023
1 parent 68b43a8 commit bf32d38
Show file tree
Hide file tree
Showing 58 changed files with 414 additions and 272 deletions.
6 changes: 6 additions & 0 deletions packages/platform-server/src/app.entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export async function createApp() {
const config = app.get(Config)
const metrics = app.get(Metric)

if (config.publicPath !== config.origin) {
app.enableCors({
origin: config.publicPath,
})
}

app.set('trust proxy', 1)
app.use(
session({
Expand Down
2 changes: 2 additions & 0 deletions packages/platform-server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
ScriptFileModule,
UsagePackModule,
ProjectUsageModule,
JobArtifactModule,
} from './modules'
import { RedisModule } from './redis'
import { RestfulModule } from './restful.module'
Expand Down Expand Up @@ -83,6 +84,7 @@ const functionalityModules: ModuleMetadata['imports'] = [

const businessModules: ModuleMetadata['imports'] = [
FileModule,
JobArtifactModule,
ProjectModule,
ArtifactModule,
SnapshotModule,
Expand Down
27 changes: 27 additions & 0 deletions packages/platform-server/src/config/def.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,33 @@ export interface PerfseeConfig {
*/
path: string

/**
*
* where the frontend get deployed.
* if not set, it will be the same as `https?://[host]/[path]`
*
* @env PERFSEE_PUBLIC_PATH
*/
_publicPath?: string

/**
*
* where the frontend get deployed.
* if not set, it will be the same as `https?://[host]/[path]`
*
* @env PERFSEE_PUBLIC_PATH
*/
get publicPath(): string

/**
*
* where the frontend get deployed.
* if not set, it will be the same as `https?://[host]/[path]`
*
* @env PERFSEE_PUBLIC_PATH
*/
set publicPath(value: string)

/**
* Readonly property `baseUrl` is the full url of the server consists of `https://HOST:PORT/PATH`.
*
Expand Down
6 changes: 6 additions & 0 deletions packages/platform-server/src/config/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export const getDefaultPerfseeConfig: () => PerfseeConfig = () => ({
host: 'localhost',
port: 3000,
path: '',
get publicPath() {
return this._publicPath ?? this.origin
},
set publicPath(value: string) {
this._publicPath = value
},
get origin() {
return `${this.https ? 'https' : 'http'}://${this.host}${this.host === 'localhost' ? `:${this.port}` : ''}`
},
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-server/src/db/fixtures/artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ registerEntityFactory(Artifact, () =>
issuer: faker.internet.email(),
tag: faker.system.semver(),
name: 'main',
buildKey: faker.system.commonFileName('tar'),
buildKey: 'artifacts/' + faker.system.commonFileName('tar'),
status: BundleJobStatus.Pending,
}),
)
14 changes: 11 additions & 3 deletions packages/platform-server/src/db/mysql/artifact.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export class Artifact extends BaseEntity {
@Column({ type: 'varchar', length: '100' })
issuer!: string

@Field(() => String, { description: 'the build file key in storage' })
@Field(() => String, { description: 'the build file key in storage', deprecationReason: 'use `buildLink` instead' })
@Column({ type: 'varchar' })
buildKey!: string

Expand All @@ -85,11 +85,19 @@ export class Artifact extends BaseEntity {
@Column({ type: 'text', nullable: true })
failedReason!: string

@Field(() => String, { description: 'the report file key in storage', nullable: true })
@Field(() => String, {
description: 'the report file key in storage',
nullable: true,
deprecationReason: 'use `reportLink` instead',
})
@Column({ type: 'varchar', nullable: true })
reportKey!: string | null

@Field(() => String, { description: 'the content file key in storage', nullable: true })
@Field(() => String, {
description: 'the content file key in storage',
nullable: true,
deprecationReason: 'use `contentLink` instead',
})
@Column({ type: 'varchar', nullable: true })
contentKey!: string | null

Expand Down
6 changes: 6 additions & 0 deletions packages/platform-server/src/db/mysql/project.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,10 @@ export class Project extends BaseEntity {

@OneToMany('ProjectJobUsage', 'project')
jobUsage!: ProjectJobUsage

static findOneByIdSlug(idOrSlug: string | number) {
return this.findOne({
where: typeof idOrSlug === 'number' || idOrSlug.match(/^\d+$/) ? { id: Number(idOrSlug) } : { slug: idOrSlug },
})
}
}
12 changes: 4 additions & 8 deletions packages/platform-server/src/db/mysql/snapshot-report.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,35 +97,30 @@ export class SnapshotReport extends BaseEntity {
@Field(() => String, {
nullable: true,
description: 'lighthouse result key in storage, you may fetch the result with this key from storage service',
deprecationReason: 'use `reportLink` instead',
})
@Column({ type: 'varchar', nullable: true })
lighthouseStorageKey!: string | null

@Field(() => String, {
nullable: true,
description: 'screen cast key in storage, you may fetch screen cast with this key from storage service',
deprecationReason: 'use `screencastLink` instead',
})
@Column({ type: 'varchar', nullable: true })
screencastStorageKey!: string | null

@Field(() => String, {
nullable: true,
description: 'js coverage data key in storage, you can fetch result with this key at storage service',
})
@Column({ type: 'varchar', nullable: true })
jsCoverageStorageKey!: string | null

@Field(() => String, {
nullable: true,
description: 'chrome track events key in storage, you may fetch track events with this key from storage service',
})
@Column({ type: 'varchar', nullable: true })
traceEventsStorageKey!: string | null

@Field(() => String, {
nullable: true,
description:
'flame chart raw data key in storage, you may fetch flame chart detail with this key from storage service',
deprecationReason: 'use `flameChartLink` instead',
})
@Column({ type: 'varchar', nullable: true })
flameChartStorageKey!: string | null
Expand All @@ -134,6 +129,7 @@ export class SnapshotReport extends BaseEntity {
nullable: true,
description:
'source coverage data key in storage, you may fetch flame chart detail with this key from storage service',
deprecationReason: 'use `sourceCoverageLink` instead',
})
@Column({ type: 'varchar', nullable: true })
sourceCoverageStorageKey!: string | null
Expand Down
16 changes: 16 additions & 0 deletions packages/platform-server/src/modules/artifact/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import { Artifact, Project, ArtifactEntrypoint, AppVersion } from '@perfsee/platform-server/db'
import { UserError } from '@perfsee/platform-server/error'
import { PaginationInput, PaginatedType, paginate, Paginated } from '@perfsee/platform-server/graphql'
import { artifactLink } from '@perfsee/platform-server/utils'

import { PermissionGuard, Permission } from '../permission'
import { ProjectService } from '../project/service'
Expand Down Expand Up @@ -177,6 +178,21 @@ export class ArtifactResolver {
async version(@Parent() artifact: Artifact) {
return artifact.version ?? (await AppVersion.findOneBy({ projectId: artifact.projectId, hash: artifact.hash }))
}

@ResolveField(() => String, { description: 'the link to uploaded build tar file' })
buildLink(@Parent() artifact: Artifact) {
return artifactLink(artifact.buildKey)
}

@ResolveField(() => String, { nullable: true, description: 'the link to build analysis report file' })
reportLink(@Parent() artifact: Artifact) {
return artifactLink(artifact.reportKey)
}

@ResolveField(() => String, { nullable: true, description: 'the link to module reference detail of a build' })
contentLink(@Parent() artifact: Artifact) {
return artifactLink(artifact.contentKey)
}
}

@Resolver(() => ArtifactEntrypoint)
Expand Down
43 changes: 39 additions & 4 deletions packages/platform-server/src/modules/file/file.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { Controller, Get, Query, Res, HttpException, HttpStatus } from '@nestjs/common'
import { Controller, Get, Query, Res, HttpException, HttpStatus, Param } from '@nestjs/common'
import { Response } from 'express'
import FileType from 'file-type'

import { Logger } from '@perfsee/platform-server/logger'
import { ObjectStorage } from '@perfsee/platform-server/storage'
import { artifactKey } from '@perfsee/platform-server/utils'

import { Auth } from '../auth'
import { Permission, PermissionGuard } from '../permission'

@Auth()
@Controller('/v1/file')
@Controller('/v1')
export class FileController {
constructor(private readonly storage: ObjectStorage, private readonly logger: Logger) {}

@Get()
/**
* @deprecated use /v1/artifacts/:key instead
*/
@Auth()
@Get('/file')
async resource(@Res() res: Response, @Query('key') name: string) {
if (!name) {
throw new HttpException('Resource is required', HttpStatus.BAD_REQUEST)
Expand All @@ -54,3 +59,33 @@ export class FileController {
}
}
}

@Controller('/artifacts')
export class JobArtifactController {
constructor(private readonly storage: ObjectStorage, private readonly logger: Logger) {}

@PermissionGuard(Permission.Read, 'projectId')
@Get('/:projectId/*')
async getProjectArtifacts(@Res() res: Response, @Param() params: Record<string, string>) {
const key = artifactKey(params.projectId, params[0])

try {
const stream = await this.storage.getStream(key)
res.set('cache-control', 'public, max-age=6048000, immutable')
// fast pass and in the most case
if (key.endsWith('.json')) {
res.set('content-type', 'application/json')
stream.pipe(res)
} else {
const contentWithType = await FileType.stream(stream)
if (contentWithType.fileType) {
res.set('content-type', contentWithType.fileType.mime)
}
contentWithType.pipe(res)
}
} catch (error) {
this.logger.error('failed to get project artifact ', { key, error })
throw new HttpException(`${key} not found`, HttpStatus.NOT_FOUND)
}
}
}
8 changes: 7 additions & 1 deletion packages/platform-server/src/modules/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,16 @@ import { Module } from '@nestjs/common'

import { StorageModule } from '@perfsee/platform-server/storage'

import { FileController } from './file.controller'
import { FileController, JobArtifactController } from './file.controller'

@Module({
imports: [StorageModule],
controllers: [FileController],
})
export class FileModule {}

@Module({
imports: [StorageModule],
controllers: [JobArtifactController],
})
export class JobArtifactModule {}
3 changes: 2 additions & 1 deletion packages/platform-server/src/modules/job/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { EventEmitter } from '@perfsee/platform-server/event'
import { Logger } from '@perfsee/platform-server/logger'
import { Metric } from '@perfsee/platform-server/metrics'
import { ObjectStorage } from '@perfsee/platform-server/storage'
import { artifactKey } from '@perfsee/platform-server/utils/artifact-link'
import {
JobRequestParams,
JobRequestResponse,
Expand Down Expand Up @@ -185,7 +186,7 @@ export class JobController {
throw new ForbiddenException('JobId not match the runner')
}

const finalKey = 'artifacts/' + job.projectId + '/' + key
const finalKey = artifactKey(job.projectId, key)
await this.storage.upload(finalKey, buf)

this.event.emit(`${job.jobType}.upload`, job.entityId, buf.byteLength)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ test('should apply guard for whole resolver', async (t) => {
)
})

test('should fail if not signed in', async (t) => {
// we now allow anonymous access to the public project
test.skip('should fail if not signed in', async (t) => {
const { gqlClient, guard } = t.context
Sinon.stub(guard, 'getUserFromContext').returns(null)

Expand Down
15 changes: 11 additions & 4 deletions packages/platform-server/src/modules/permission/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { Reflector } from '@nestjs/core'
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql'
import { Request } from 'express'

import { User } from '@perfsee/platform-server/db'

import { getUserFromContext } from '../auth'

import { Permission } from './def'
Expand All @@ -37,10 +39,15 @@ export class PermissionGuardImpl implements CanActivate {
constructor(private readonly permission: PermissionProvider, private readonly reflector: Reflector) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const user = this.getUserFromContext(context)
if (!user) {
return false
}
const user =
this.getUserFromContext(context) ??
({
id: 0,
isAdmin: false,
isApp: false,
username: 'anonymous',
email: 'anonymous@perfsee.com',
} as User)

if (user.isAdmin) {
return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ export class SelfHostPermissionProvider extends PermissionProvider {
return [Permission.Admin]
}

const project = await Project.findOneByOrFail(typeof id === 'string' ? { slug: id } : { id })
const project = await Project.findOneByIdSlug(id)
if (!project) {
throw new Error('Project not found')
}

const permissions = await UserPermission.findBy({ userId: user.id, projectId: project.id })
return permissions.map((permission) => permission.permission)
Expand All @@ -58,7 +61,10 @@ export class SelfHostPermissionProvider extends PermissionProvider {
return true
}

const project = await Project.findOneByOrFail(typeof id === 'string' ? { slug: id } : { id })
const project = await Project.findOneByIdSlug(id)
if (!project) {
throw new Error('Project not found')
}

// pass read permission check if project is public
if (project.isPublic && permission === Permission.Read) {
Expand Down
7 changes: 6 additions & 1 deletion packages/platform-server/src/modules/project/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { Project, User } from '@perfsee/platform-server/db'
import { PaginationInput, PaginatedType, paginate, Paginated } from '@perfsee/platform-server/graphql'
import { GitHost } from '@perfsee/shared'

import { CurrentUser, Auth } from '../auth'
import { CurrentUser, Auth, SkipAuth } from '../auth'
import { PermissionGuard, Permission } from '../permission'

import { ProjectService } from './service'
Expand All @@ -44,6 +44,7 @@ export class ProjectResolver {
constructor(private readonly projectService: ProjectService) {}

@PermissionGuard(Permission.Read, 'id')
@SkipAuth('skip it for public project, permission guard will cover it')
@Query(() => Project, { name: 'project', description: 'get project by id' })
async getProjectById(@Args({ name: 'id', type: () => ID }) slug: string) {
return this.projectService.getProject(slug)
Expand Down Expand Up @@ -171,6 +172,10 @@ export class ProjectResolver {

@ResolveField(() => [Permission], { description: 'current user permission to this project' })
async userPermission(@CurrentUser() user: User, @Parent() project: Project) {
if (!user) {
return []
}

return this.projectService.getUserPermission(user, project)
}
}
Expand Down

0 comments on commit bf32d38

Please sign in to comment.