提供 gieea webhooks (#2)

chore: debug log 仅输出app的log

fix(commons): fix sanitize not return value.

feat(webhooks): add gitea webhooks api.

Co-authored-by: Ivan Li <ivanli@live.cn>
Co-authored-by: Ivan <ivanli@live.cn>
Reviewed-on: #2
Co-Authored-By: Ivan Li <ivan@noreply.%(DOMAIN)s>
Co-Committed-By: Ivan Li <ivan@noreply.%(DOMAIN)s>
This commit is contained in:
Ivan Li
2021-03-28 10:24:12 +08:00
parent 429de1eaed
commit da6bc9a068
22 changed files with 7935 additions and 8698 deletions

View File

@ -0,0 +1,8 @@
import { IsString } from 'class-validator';
export class GiteaHookPayloadDto {
@IsString()
ref: string;
@IsString()
after: string;
}

View File

@ -0,0 +1,3 @@
export enum SourceService {
gitea = 'gitea',
}

View File

@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { GiteaWebhooksController } from './gitea-webhooks.controller';
import { WebhooksService } from './webhooks.service';
describe('GiteaWebhooksController', () => {
let controller: GiteaWebhooksController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [GiteaWebhooksController],
providers: [
{
provide: WebhooksService,
useValue: {},
},
],
}).compile();
controller = module.get<GiteaWebhooksController>(GiteaWebhooksController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -0,0 +1,37 @@
import { Body, Controller, Headers, Param, Post } from '@nestjs/common';
import { validateOrReject } from 'class-validator';
import { pick } from 'ramda';
import { GiteaHookPayloadDto } from './dtos/gitea-hook-payload.dto';
import { SourceService } from './enums/source-service.enum';
import { WebhookLog } from './webhook-log.entity';
import { WebhooksService } from './webhooks.service';
@Controller('gitea-webhooks')
export class GiteaWebhooksController {
constructor(private readonly service: WebhooksService) {}
@Post(':pipelineId')
async onCall(
@Headers('X-Gitea-Delivery') delivery: string,
@Headers('X-Gitea-Event') event: string,
@Headers('X-Gitea-Signature') signature: string,
@Body() body: Buffer,
@Param('pipelineId') pipelineId: string,
) {
const payload = Object.assign(
new GiteaHookPayloadDto(),
JSON.parse(body.toString('utf-8')),
);
await validateOrReject(payload);
await this.service.verifySignature(body, signature, 'boardcat');
return await this.service
.onCall(pipelineId, {
payload,
sourceDelivery: delivery,
sourceEvent: event,
sourceService: SourceService.gitea,
})
.then((data) =>
pick<keyof WebhookLog>(['id', 'createdAt', 'localEvent'])(data),
);
}
}

View File

@ -0,0 +1,9 @@
import { SourceService } from '../enums/source-service.enum';
import { WebhookLog } from '../webhook-log.entity';
export class CreateWebhookLogModel<T> implements Partial<WebhookLog> {
sourceDelivery: string;
sourceEvent: string;
sourceService: SourceService;
payload: T;
}

View File

@ -0,0 +1,19 @@
import { Column, Entity } from 'typeorm';
import { AppBaseEntity } from './../commons/entities/app-base-entity';
import { SourceService } from './enums/source-service.enum';
@Entity()
export class WebhookLog extends AppBaseEntity {
@Column()
sourceDelivery: string;
@Column({ type: 'enum', enum: SourceService })
sourceService: SourceService;
@Column()
sourceEvent: string;
@Column({ type: 'jsonb' })
payload: any;
@Column()
localEvent: string;
@Column({ type: 'jsonb' })
localPayload: any;
}

View File

@ -0,0 +1,20 @@
import { MiddlewareConsumer, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PipelineTasksModule } from '../pipeline-tasks/pipeline-tasks.module';
import { GiteaWebhooksController } from './gitea-webhooks.controller';
import { WebhookLog } from './webhook-log.entity';
import { WebhooksService } from './webhooks.service';
import { raw } from 'body-parser';
@Module({
imports: [TypeOrmModule.forFeature([WebhookLog]), PipelineTasksModule],
controllers: [GiteaWebhooksController],
providers: [WebhooksService],
})
export class WebhooksModule {
// configure(consumer: MiddlewareConsumer) {
// consumer
// .apply(raw({ type: 'application/json' }))
// .forRoutes(GiteaWebhooksController);
// }
}

View File

@ -0,0 +1,57 @@
import { UnauthorizedException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { readFile } from 'fs/promises';
import { join } from 'path';
import { Repository } from 'typeorm';
import { PipelineTasksService } from '../pipeline-tasks/pipeline-tasks.service';
import { WebhookLog } from './webhook-log.entity';
import { WebhooksService } from './webhooks.service';
describe('WebhooksService', () => {
let service: WebhooksService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
WebhooksService,
{
provide: PipelineTasksService,
useValue: {},
},
{
provide: getRepositoryToken(WebhookLog),
useValue: new Repository(),
},
],
}).compile();
service = module.get<WebhooksService>(WebhooksService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('verifySignature', () => {
const signature =
'b175e07189a6106f386b62253b18b5879c4b1f3af2f11fe13a294602671e361a';
const secret = 'boardcat';
let payload: Buffer;
beforeAll(async () => {
payload = await readFile(
join(__dirname, '../../test/data/gitea-hook-payload.json.bin'),
);
});
it('must be valid', async () => {
await expect(
service.verifySignature(payload, signature, secret),
).resolves.toEqual(undefined);
});
it('must be invalid', async () => {
await expect(
service.verifySignature(payload, 'test', secret),
).rejects.toThrowError(UnauthorizedException);
});
});
});

View File

@ -0,0 +1,65 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { createHmac } from 'crypto';
import { Repository } from 'typeorm';
import { PipelineUnits } from '../pipeline-tasks/enums/pipeline-units.enum';
import { PipelineTasksService } from '../pipeline-tasks/pipeline-tasks.service';
import { GiteaHookPayloadDto } from './dtos/gitea-hook-payload.dto';
import { CreateWebhookLogModel } from './models/create-webhook-log.model';
import { WebhookLog } from './webhook-log.entity';
@Injectable()
export class WebhooksService {
constructor(
@InjectRepository(WebhookLog)
private readonly repository: Repository<WebhookLog>,
private readonly taskService: PipelineTasksService,
) {}
async onCall(
pipelineId: string,
model: CreateWebhookLogModel<GiteaHookPayloadDto>,
) {
if (model.sourceEvent.toLowerCase() === 'push') {
const taskDto = {
pipelineId,
commit: model.payload.after,
units: Object.values(PipelineUnits),
};
await this.taskService.addTask(taskDto);
return await this.repository.save(
this.repository.create({
...model,
localEvent: 'create-pipeline-task',
localPayload: taskDto,
}),
);
} else {
throw new BadRequestException('无法处理的请求');
}
}
async verifySignature(payload: any, signature: string, secret: string) {
const local = await new Promise<string>((resolve, reject) => {
const hmac = createHmac('sha256', secret);
hmac.on('readable', () => {
const data = hmac.read();
if (data) {
resolve(data.toString('hex'));
}
});
hmac.on('error', (err) => {
reject(err);
});
hmac.write(payload);
hmac.end();
});
if (local !== signature) {
throw new UnauthorizedException();
}
}
}