mirror of
https://github.com/zen-browser/rices.git
synced 2025-07-07 00:45:40 +02:00
first commit
This commit is contained in:
commit
232d8b37d6
36 changed files with 11302 additions and 0 deletions
5
.env.example
Normal file
5
.env.example
Normal file
|
@ -0,0 +1,5 @@
|
|||
GITHUB_TOKEN=ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
GITHUB_REPO_OWNER=zen-browser
|
||||
GITHUB_REPO_NAME=rices-store
|
||||
|
||||
MODERATION_SECRET=superSecret123
|
25
.eslintrc.js
Normal file
25
.eslintrc.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
|
@ -0,0 +1,56 @@
|
|||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
/build
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# temp directory
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
4
.prettierrc
Normal file
4
.prettierrc
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
57
README.md
Normal file
57
README.md
Normal file
|
@ -0,0 +1,57 @@
|
|||
## Description
|
||||
|
||||
Zen Rices API
|
||||
Based on [Nest](https://github.com/nestjs/nest) framework
|
||||
|
||||
## Project setup (install NVM)
|
||||
|
||||
```bash
|
||||
$ nvm use 22
|
||||
$ npm install
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ npm install -g mau
|
||||
$ mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
## Resources
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
22
jest.config.js
Normal file
22
jest.config.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
// jest.config.js
|
||||
|
||||
module.exports = {
|
||||
preset: 'ts-jest/presets/default-esm',
|
||||
testEnvironment: 'node',
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
maxWorkers: 1,
|
||||
rootDir: '.',
|
||||
testRegex: '.e2e-spec.ts$',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': ['ts-jest', { useESM: true }],
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'/node_modules/(?!(\\@octokit)/)',
|
||||
],
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: 'tsconfig.json',
|
||||
useESM: true,
|
||||
},
|
||||
},
|
||||
};
|
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
9640
package-lock.json
generated
Normal file
9640
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
64
package.json
Normal file
64
package.json
Normal file
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"name": "zen-rices-backend",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/swagger": "^8.1.0",
|
||||
"@octokit/rest": "^18.12.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"simple-git": "^3.27.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@nestjs/throttler": "^6.3.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"eslint": "^8.0.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.1.3"
|
||||
}
|
||||
}
|
18
src/app.controller.spec.ts
Normal file
18
src/app.controller.spec.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {});
|
||||
});
|
7
src/app.controller.ts
Normal file
7
src/app.controller.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
}
|
31
src/app.module.ts
Normal file
31
src/app.module.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { GitHubModule } from './github/github.module';
|
||||
import { RicesModule } from './rices/rices.module';
|
||||
import { ThrottlerExceptionFilter } from './common/filters/throttler-exception.filter';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60000,
|
||||
limit: 10,
|
||||
},
|
||||
]),
|
||||
GitHubModule,
|
||||
RicesModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
4
src/app.service.ts
Normal file
4
src/app.service.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {}
|
21
src/common/filters/throttler-exception.filter.ts
Normal file
21
src/common/filters/throttler-exception.filter.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ThrottlerException } from '@nestjs/throttler';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Catch(ThrottlerException)
|
||||
export class ThrottlerExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: ThrottlerException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
response.status(HttpStatus.TOO_MANY_REQUESTS).json({
|
||||
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||
message: 'Too many requests. Please try again later.',
|
||||
});
|
||||
}
|
||||
}
|
12
src/github/github.module.ts
Normal file
12
src/github/github.module.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
// src/github/github.module.ts
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GitHubService } from './github.service';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [GitHubService],
|
||||
exports: [GitHubService],
|
||||
})
|
||||
export class GitHubModule {}
|
18
src/github/github.service.spec.ts
Normal file
18
src/github/github.service.spec.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { GitHubService } from './github.service';
|
||||
|
||||
describe('GithubService', () => {
|
||||
let service: GitHubService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [GitHubService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<GitHubService>(GitHubService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
453
src/github/github.service.ts
Normal file
453
src/github/github.service.ts
Normal file
|
@ -0,0 +1,453 @@
|
|||
import {
|
||||
Injectable,
|
||||
OnModuleInit,
|
||||
Logger,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Type guard to verify if the error has a 'status' property of type 'number'
|
||||
* and a 'message' property of type 'string'.
|
||||
*/
|
||||
function isOctokitResponseError(
|
||||
error: any,
|
||||
): error is { status: number; message: string } {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'status' in error &&
|
||||
typeof error.status === 'number' &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GitHubService implements OnModuleInit {
|
||||
private octokit!: Octokit;
|
||||
private readonly logger = new Logger(GitHubService.name);
|
||||
private repoOwner: string;
|
||||
private repoName: string;
|
||||
private defaultBranch: string = 'main'; // Default value
|
||||
private directoryLocks: Map<string, boolean> = new Map();
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
// Initialize properties in the constructor
|
||||
this.repoOwner = this.configService.get<string>('GITHUB_REPO_OWNER') || '';
|
||||
this.repoName = this.configService.get<string>('GITHUB_REPO_NAME') || '';
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
const token = this.configService.get<string>('GITHUB_TOKEN');
|
||||
if (!token) {
|
||||
this.logger.error(
|
||||
'GITHUB_TOKEN is not defined in the environment variables',
|
||||
);
|
||||
throw new Error('GITHUB_TOKEN is not defined');
|
||||
}
|
||||
|
||||
if (!this.repoOwner || !this.repoName) {
|
||||
this.logger.error(
|
||||
'GITHUB_REPO_OWNER or GITHUB_REPO_NAME is not defined in the environment variables',
|
||||
);
|
||||
throw new Error('GITHUB_REPO_OWNER or GITHUB_REPO_NAME is not defined');
|
||||
}
|
||||
|
||||
this.octokit = new Octokit({
|
||||
auth: token,
|
||||
});
|
||||
|
||||
// Fetch the default branch of the repository
|
||||
try {
|
||||
const { data: repo } = await this.octokit.repos.get({
|
||||
owner: this.repoOwner,
|
||||
repo: this.repoName,
|
||||
});
|
||||
this.defaultBranch = repo.default_branch;
|
||||
this.logger.log(
|
||||
`Default branch of the repository: ${this.defaultBranch}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isOctokitResponseError(error)) {
|
||||
if (error.status === 404) {
|
||||
this.logger.error(
|
||||
`Repository ${this.repoOwner}/${this.repoName} not found.`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Error fetching repository information: ${error.message} (Status: ${error.status})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Unexpected error fetching repository information: ${error}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a file in the repository.
|
||||
* Ensures that the specified directory exists by creating a .gitkeep file if necessary.
|
||||
* @param filePath Path of the file in the repository.
|
||||
* @param content Content of the file in plain text.
|
||||
* @param commitMessage Commit message.
|
||||
*/
|
||||
async createOrUpdateFile(
|
||||
filePath: string,
|
||||
content: string,
|
||||
commitMessage: string,
|
||||
retries = 3,
|
||||
): Promise<void> {
|
||||
const directoryPath = path.dirname(filePath);
|
||||
await this.lockDirectory(directoryPath);
|
||||
try {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
// Get the SHA of the file if it exists
|
||||
let sha: string | undefined;
|
||||
try {
|
||||
const { data: existingFile } = await this.octokit.repos.getContent({
|
||||
owner: this.repoOwner,
|
||||
repo: this.repoName,
|
||||
path: filePath,
|
||||
ref: this.defaultBranch,
|
||||
});
|
||||
if ('sha' in existingFile) {
|
||||
sha = existingFile.sha;
|
||||
}
|
||||
} catch (error) {
|
||||
// File does not exist, proceed to create it
|
||||
if (isOctokitResponseError(error)) {
|
||||
if (error.status !== 404) {
|
||||
this.logger.error(
|
||||
`Error checking file ${filePath}: ${error.message} (Status: ${error.status})`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
// If the error is 404, the file does not exist and we can proceed to create it
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to create or update the file
|
||||
await this.octokit.repos.createOrUpdateFileContents({
|
||||
owner: this.repoOwner,
|
||||
repo: this.repoName,
|
||||
path: filePath,
|
||||
message: commitMessage,
|
||||
content: Buffer.from(content, 'utf-8').toString('base64'),
|
||||
sha,
|
||||
branch: this.defaultBranch,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`File ${filePath} created/updated successfully.`,
|
||||
);
|
||||
return;
|
||||
} catch (error: any) {
|
||||
if (error.status === 409 && attempt < retries) {
|
||||
this.logger.warn(
|
||||
`Conflict creating/updating ${filePath}. Retrying (${attempt}/${retries})...`,
|
||||
);
|
||||
const backoffTime = 1000 * Math.pow(2, attempt - 1); // 1s, 2s, 4s
|
||||
await this.delay(backoffTime);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (error.status === 409) {
|
||||
this.logger.error(
|
||||
`Persistent conflict creating/updating ${filePath}: ${error.message}`,
|
||||
);
|
||||
throw new InternalServerErrorException(
|
||||
`Error creating/updating file ${filePath}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.error(
|
||||
`Error creating/updating file ${filePath}: ${error.message}`,
|
||||
);
|
||||
throw new InternalServerErrorException(
|
||||
`Error creating/updating file ${filePath}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.unlockDirectory(directoryPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a file from the repository.
|
||||
* @param filePath Path of the file in the repository.
|
||||
* @param commitMessage Commit message.
|
||||
*/
|
||||
async deleteFile(
|
||||
filePath: string,
|
||||
commitMessage: string,
|
||||
retries = 3,
|
||||
): Promise<void> {
|
||||
try {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
// Get the file's SHA
|
||||
const { data: existingFile } = await this.octokit.repos.getContent({
|
||||
owner: this.repoOwner,
|
||||
repo: this.repoName,
|
||||
path: filePath,
|
||||
ref: this.defaultBranch,
|
||||
});
|
||||
|
||||
if (!('sha' in existingFile)) {
|
||||
throw new Error(`The file ${filePath} does not have a valid SHA`);
|
||||
}
|
||||
|
||||
const sha = existingFile.sha;
|
||||
|
||||
// Attempt to delete the file
|
||||
await this.octokit.repos.deleteFile({
|
||||
owner: this.repoOwner,
|
||||
repo: this.repoName,
|
||||
path: filePath,
|
||||
message: commitMessage,
|
||||
sha: sha,
|
||||
branch: this.defaultBranch,
|
||||
});
|
||||
|
||||
this.logger.log(`File ${filePath} deleted successfully.`);
|
||||
return;
|
||||
} catch (error: any) {
|
||||
if (error.status === 409 && attempt < retries) {
|
||||
this.logger.warn(
|
||||
`Conflict deleting ${filePath}. Retrying (${attempt}/${retries})...`,
|
||||
);
|
||||
const backoffTime = 1000 * Math.pow(2, attempt - 1); // 1s, 2s, 4s
|
||||
await this.delay(backoffTime);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (error.status === 409) {
|
||||
this.logger.error(
|
||||
`Persistent conflict deleting ${filePath}: ${error.message}`,
|
||||
);
|
||||
throw new InternalServerErrorException(
|
||||
`Error deleting file ${filePath}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (isOctokitResponseError(error) && error.status === 404) {
|
||||
this.logger.warn(`The file ${filePath} does not exist in the repository.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOctokitResponseError(error)) {
|
||||
this.logger.error(
|
||||
`Error deleting file ${filePath}: ${error.message} (Status: ${error.status})`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`Error deleting file ${filePath}: ${error}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error deleting file ${filePath}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of a file.
|
||||
* @param filePath Path of the file in the repository.
|
||||
* @returns Plain text file content or null if it does not exist.
|
||||
*/
|
||||
async getFileContent(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
const { data } = await this.octokit.repos.getContent({
|
||||
owner: this.repoOwner,
|
||||
repo: this.repoName,
|
||||
path: filePath,
|
||||
ref: this.defaultBranch,
|
||||
});
|
||||
|
||||
if ('content' in data && data.content) {
|
||||
const buffer = Buffer.from(data.content, 'base64');
|
||||
return buffer.toString('utf-8');
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
if (isOctokitResponseError(error)) {
|
||||
if (error.status === 404) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (isOctokitResponseError(error)) {
|
||||
this.logger.error(
|
||||
`Error getting content of file ${filePath}: ${error.message} (Status: ${error.status})`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Error getting content of file ${filePath}: ${error}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the files in a specific directory on GitHub.
|
||||
* @param directoryPath Path of the directory in the repository.
|
||||
* @returns Array of file names.
|
||||
*/
|
||||
async listFilesInDirectory(directoryPath: string): Promise<string[]> {
|
||||
try {
|
||||
const { data } = await this.octokit.repos.getContent({
|
||||
owner: this.repoOwner,
|
||||
repo: this.repoName,
|
||||
path: directoryPath,
|
||||
ref: this.defaultBranch,
|
||||
});
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((file) => file.name);
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error: any) {
|
||||
if (isOctokitResponseError(error) && error.status === 404) {
|
||||
this.logger.warn(`The directory ${directoryPath} does not exist.`);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isOctokitResponseError(error)) {
|
||||
this.logger.error(
|
||||
`Error listing files in ${directoryPath}: ${error.message} (Status: ${error.status})`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Error listing files in ${directoryPath}: ${error}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all files in the GitHub repository.
|
||||
* Useful for cleaning the state before running tests.
|
||||
*/
|
||||
async clearRepository(): Promise<void> {
|
||||
this.logger.log('Starting GitHub repository cleanup...');
|
||||
|
||||
try {
|
||||
const files = await this.listAllFiles();
|
||||
|
||||
for (const file of files) {
|
||||
// Do not delete essential files like .gitignore or .gitkeep
|
||||
if (
|
||||
file.path === '.gitignore' ||
|
||||
path.basename(file.path) === '.gitkeep'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.deleteFile(
|
||||
file.path,
|
||||
`Clear repository: Remove ${file.path}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log('GitHub repository cleaned successfully.');
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error cleaning the repository: ${error.message}`);
|
||||
throw new InternalServerErrorException(
|
||||
`Error cleaning the repository: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively lists all files in the GitHub repository.
|
||||
* @returns List of file paths in the repository.
|
||||
*/
|
||||
private async listAllFiles(): Promise<Array<{ path: string }>> {
|
||||
const rootPath = '';
|
||||
const files: Array<{ path: string }> = [];
|
||||
|
||||
async function traverseDirectory(
|
||||
service: GitHubService,
|
||||
currentPath: string,
|
||||
accumulator: Array<{ path: string }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await service.octokit.repos.getContent({
|
||||
owner: service.repoOwner,
|
||||
repo: service.repoName,
|
||||
path: currentPath,
|
||||
ref: service.defaultBranch,
|
||||
});
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
for (const file of response.data) {
|
||||
if (file.type === 'file') {
|
||||
accumulator.push({ path: file.path });
|
||||
} else if (file.type === 'dir') {
|
||||
await traverseDirectory(service, file.path, accumulator);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (isOctokitResponseError(error) && error.status === 404) {
|
||||
service.logger.warn(`Directory ${currentPath} does not exist.`);
|
||||
} else {
|
||||
service.logger.error(
|
||||
`Error listing files in ${currentPath}: ${error.message} (Status: ${error.status})`,
|
||||
);
|
||||
throw new InternalServerErrorException(
|
||||
`Error listing files in ${currentPath}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await traverseDirectory(this, rootPath, files);
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Introduces a delay during tests.
|
||||
* @param ms Milliseconds to pause.
|
||||
*/
|
||||
private async delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple directory lock implementation to prevent concurrent operations.
|
||||
* @param directoryPath Path of the directory to lock.
|
||||
*/
|
||||
private async lockDirectory(directoryPath: string): Promise<void> {
|
||||
while (this.directoryLocks.get(directoryPath)) {
|
||||
this.logger.warn(
|
||||
`Directory ${directoryPath} is locked. Waiting...`,
|
||||
);
|
||||
await this.delay(100); // Wait 100ms before retrying
|
||||
}
|
||||
this.directoryLocks.set(directoryPath, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocks a directory after completing operations.
|
||||
* @param directoryPath Path of the directory to unlock.
|
||||
*/
|
||||
private unlockDirectory(directoryPath: string): void {
|
||||
this.directoryLocks.set(directoryPath, false);
|
||||
this.logger.log(`Directory ${directoryPath} unlocked.`);
|
||||
}
|
||||
}
|
28
src/main.ts
Normal file
28
src/main.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { ThrottlerExceptionFilter } from './common/filters/throttler-exception.filter';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
||||
app.useGlobalFilters(new ThrottlerExceptionFilter());
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Rices API')
|
||||
.setDescription('Zen Rices API management (Zen Browser)')
|
||||
.setVersion('1.0')
|
||||
// To manage the API with Swagger, we need to add the bearer token
|
||||
// .addBearerAuth()
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api', app, document);
|
||||
|
||||
await app.listen(3000);
|
||||
console.log('API running on http://localhost:3000');
|
||||
console.log('Swagger docs on http://localhost:3000/api');
|
||||
}
|
||||
bootstrap();
|
7
src/rices/dto/create-rice.dto.ts
Normal file
7
src/rices/dto/create-rice.dto.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateRiceDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
}
|
7
src/rices/dto/update-rice.dto.ts
Normal file
7
src/rices/dto/update-rice.dto.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateRiceDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
}
|
103
src/rices/rices.controller.ts
Normal file
103
src/rices/rices.controller.ts
Normal file
|
@ -0,0 +1,103 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { RicesService } from './rices.service';
|
||||
import { CreateRiceDto } from './dto/create-rice.dto';
|
||||
import { UpdateRiceDto } from './dto/update-rice.dto';
|
||||
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiConsumes,
|
||||
} from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
|
||||
@ApiTags('rices')
|
||||
@Controller('rices')
|
||||
export class RicesController {
|
||||
constructor(private readonly ricesService: RicesService) {}
|
||||
|
||||
@ApiOperation({ summary: 'Upload a new Rice' })
|
||||
@ApiResponse({ status: 201, description: 'Rice successfully created.' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@Post()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async createRice(
|
||||
@Body() createRiceDto: CreateRiceDto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.ricesService.create(createRiceDto, file);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Get information about a Rice' })
|
||||
@ApiResponse({ status: 200, description: 'Returns metadata of the Rice.' })
|
||||
@Get(':identifier')
|
||||
async getRice(@Param('identifier') identifier: string) {
|
||||
return this.ricesService.findOne(identifier);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Update an existing Rice' })
|
||||
@ApiResponse({ status: 200, description: 'Rice successfully updated.' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@Put(':identifier')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async updateRice(
|
||||
@Param('identifier') identifier: string,
|
||||
@Headers('x-rices-token') token: string,
|
||||
@Body() updateRiceDto: UpdateRiceDto,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
) {
|
||||
return this.ricesService.update(identifier, token, updateRiceDto, file);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Delete an existing Rice' })
|
||||
@ApiResponse({ status: 204, description: 'Rice successfully deleted.' })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Delete(':identifier')
|
||||
async removeRice(
|
||||
@Param('identifier') identifier: string,
|
||||
@Headers('x-rices-token') token: string,
|
||||
) {
|
||||
await this.ricesService.remove(identifier, token);
|
||||
return;
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// NEW ENDPOINT FOR MODERATION DELETION
|
||||
// =========================================
|
||||
@ApiOperation({
|
||||
summary: 'Forcefully delete a Rice (moderation)',
|
||||
description:
|
||||
'Requires knowledge of a moderation secret to delete the Rice.',
|
||||
})
|
||||
@ApiResponse({ status: 204, description: 'Rice deleted by moderation.' })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Delete('moderate/delete/:identifier')
|
||||
async removeRiceByModerator(
|
||||
@Param('identifier') identifier: string,
|
||||
@Headers('x-moderation-secret') moderationSecret: string,
|
||||
) {
|
||||
// Verify the secret
|
||||
if (moderationSecret !== process.env.MODERATION_SECRET) {
|
||||
throw new UnauthorizedException('Invalid moderation secret');
|
||||
}
|
||||
|
||||
// Call the service to delete without a token
|
||||
await this.ricesService.moderateRemove(identifier);
|
||||
return;
|
||||
}
|
||||
}
|
14
src/rices/rices.module.ts
Normal file
14
src/rices/rices.module.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
// src/rices/rices.module.ts
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RicesService } from './rices.service';
|
||||
import { GitHubModule } from '../github/github.module';
|
||||
import { RicesController } from './rices.controller';
|
||||
|
||||
@Module({
|
||||
imports: [GitHubModule],
|
||||
providers: [RicesService],
|
||||
controllers: [RicesController],
|
||||
exports: [RicesService],
|
||||
})
|
||||
export class RicesModule {}
|
296
src/rices/rices.service.ts
Normal file
296
src/rices/rices.service.ts
Normal file
|
@ -0,0 +1,296 @@
|
|||
// src/rices/rices.service.ts
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { CreateRiceDto } from './dto/create-rice.dto';
|
||||
import { UpdateRiceDto } from './dto/update-rice.dto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { generateSlug } from './utils/slug.util';
|
||||
import { GitHubService } from '../github/github.service';
|
||||
|
||||
/**
|
||||
* Checks if the provided error has a 'status' property of type 'number'
|
||||
* and a 'message' property of type 'string'.
|
||||
*/
|
||||
function isOctokitResponseError(
|
||||
error: unknown,
|
||||
): error is { status: number; message: string } {
|
||||
return (
|
||||
error !== null &&
|
||||
typeof error === 'object' &&
|
||||
'status' in error &&
|
||||
typeof (error as any).status === 'number' &&
|
||||
'message' in error &&
|
||||
typeof (error as any).message === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RicesService {
|
||||
constructor(private readonly gitHubService: GitHubService) {}
|
||||
|
||||
/**
|
||||
* Create a new rice
|
||||
*/
|
||||
async create(createRiceDto: CreateRiceDto, file?: Express.Multer.File) {
|
||||
try {
|
||||
// 1. Generate identifier (slug + UUID or just UUID)
|
||||
let identifier: string;
|
||||
if (createRiceDto.name) {
|
||||
// Generate slug from the name
|
||||
const slug = generateSlug(createRiceDto.name);
|
||||
identifier = `${slug}-${uuidv4()}`;
|
||||
} else {
|
||||
identifier = uuidv4();
|
||||
}
|
||||
|
||||
// 2. Generate token and save metadata
|
||||
const token = uuidv4();
|
||||
const metadata = {
|
||||
id: identifier,
|
||||
token,
|
||||
name: createRiceDto.name || null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const metadataContent = JSON.stringify(metadata, null, 2);
|
||||
const riceJsonPath = `rices/${identifier}/rice.json`;
|
||||
|
||||
// 3. Create or update rice.json in GitHub
|
||||
await this.gitHubService.createOrUpdateFile(
|
||||
riceJsonPath,
|
||||
metadataContent,
|
||||
`Add rice ${identifier}`,
|
||||
);
|
||||
|
||||
// 4. If there's a file, upload it to GitHub
|
||||
if (file && file.originalname && file.buffer) {
|
||||
const fileContent = file.buffer.toString('utf-8');
|
||||
const uploadedFilePath = `rices/${identifier}/data.zenrice`;
|
||||
await this.gitHubService.createOrUpdateFile(
|
||||
uploadedFilePath,
|
||||
fileContent,
|
||||
`Add file ${file.originalname} to rice ${identifier}/data.zenrice`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Return identifier and token
|
||||
return {
|
||||
identifier,
|
||||
token,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error creating the rice:', error);
|
||||
throw new Error('Failed to create rice');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rice information by its identifier
|
||||
*/
|
||||
async findOne(identifier: string) {
|
||||
try {
|
||||
const riceJsonPath = `rices/${identifier}/data.zenrice`;
|
||||
const fileContent = await this.gitHubService.getFileContent(riceJsonPath);
|
||||
|
||||
if (!fileContent) {
|
||||
throw new NotFoundException('Rice not found');
|
||||
}
|
||||
|
||||
return fileContent;
|
||||
} catch (error) {
|
||||
if (isOctokitResponseError(error) && error.status === 404) {
|
||||
throw new NotFoundException('Rice not found');
|
||||
}
|
||||
console.error('Error getting the rice:', error);
|
||||
throw new Error('Failed to get rice');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing rice
|
||||
*/
|
||||
async update(
|
||||
identifier: string,
|
||||
token: string,
|
||||
updateRiceDto: UpdateRiceDto,
|
||||
file?: Express.Multer.File,
|
||||
) {
|
||||
try {
|
||||
// 1. Retrieve and validate metadata
|
||||
const riceJsonPath = `rices/${identifier}/rice.json`;
|
||||
const metadataContent =
|
||||
await this.gitHubService.getFileContent(riceJsonPath);
|
||||
|
||||
if (!metadataContent) {
|
||||
throw new NotFoundException('Rice not found');
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(metadataContent);
|
||||
|
||||
if (metadata.token !== token) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
// 2. Update metadata
|
||||
if (updateRiceDto.name) {
|
||||
metadata.name = updateRiceDto.name;
|
||||
}
|
||||
metadata.updatedAt = new Date().toISOString();
|
||||
const updatedMetadataContent = JSON.stringify(metadata, null, 2);
|
||||
|
||||
// 3. Update rice.json in GitHub
|
||||
await this.gitHubService.createOrUpdateFile(
|
||||
riceJsonPath,
|
||||
updatedMetadataContent,
|
||||
`Update rice ${identifier}`,
|
||||
);
|
||||
|
||||
// 4. If there's a file, update it in GitHub
|
||||
if (file && file.originalname && file.buffer) {
|
||||
const fileContent = file.buffer.toString('utf-8');
|
||||
const uploadedFilePath = `rices/${identifier}/data.zenrice`;
|
||||
await this.gitHubService.createOrUpdateFile(
|
||||
uploadedFilePath,
|
||||
fileContent,
|
||||
`Update file ${file.originalname} in rice ${identifier}/data.zenrice`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Rice ${identifier} updated`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isOctokitResponseError(error)) {
|
||||
if (error.status === 404) {
|
||||
throw new NotFoundException('Rice not found');
|
||||
}
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
console.error('Error updating the rice:', error);
|
||||
throw new Error('Failed to update rice');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing rice
|
||||
*/
|
||||
async remove(identifier: string, token: string): Promise<void> {
|
||||
try {
|
||||
// 1. Retrieve and validate metadata
|
||||
const riceJsonPath = `rices/${identifier}/rice.json`;
|
||||
const metadataContent =
|
||||
await this.gitHubService.getFileContent(riceJsonPath);
|
||||
|
||||
if (!metadataContent) {
|
||||
throw new NotFoundException('Rice not found');
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(metadataContent);
|
||||
|
||||
if (metadata.token !== token) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
// 2. Delete rice.json from GitHub
|
||||
await this.gitHubService.deleteFile(
|
||||
riceJsonPath,
|
||||
`Remove rice ${identifier}`,
|
||||
);
|
||||
|
||||
// 3. List and delete uploaded files (if any)
|
||||
const uploadedFilesPath = `rices/${identifier}`;
|
||||
const files =
|
||||
await this.gitHubService.listFilesInDirectory(uploadedFilesPath);
|
||||
|
||||
for (const file of files) {
|
||||
if (file !== 'rice.json') {
|
||||
const filePath = `rices/${identifier}/${file}`;
|
||||
await this.gitHubService.deleteFile(
|
||||
filePath,
|
||||
`Remove file ${file} from rice ${identifier}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isOctokitResponseError(error)) {
|
||||
if (error.status === 404) {
|
||||
throw new NotFoundException('Rice not found');
|
||||
}
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
console.error('Error deleting the rice:', error);
|
||||
throw new Error('Failed to remove rice');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a rice without checking the user's token.
|
||||
* Exclusive use for moderators with the secret key.
|
||||
*/
|
||||
public async moderateRemove(identifier: string): Promise<void> {
|
||||
try {
|
||||
// 1. Check if rice.json exists
|
||||
const riceJsonPath = `rices/${identifier}/rice.json`;
|
||||
const metadataContent =
|
||||
await this.gitHubService.getFileContent(riceJsonPath);
|
||||
|
||||
if (!metadataContent) {
|
||||
throw new NotFoundException('Rice not found');
|
||||
}
|
||||
|
||||
// 2. Delete rice.json from GitHub
|
||||
await this.gitHubService.deleteFile(
|
||||
riceJsonPath,
|
||||
`[MODERATION] Remove rice ${identifier}`,
|
||||
);
|
||||
|
||||
// 3. List and delete uploaded files (if any)
|
||||
const uploadedFilesPath = `rices/${identifier}`;
|
||||
const files =
|
||||
await this.gitHubService.listFilesInDirectory(uploadedFilesPath);
|
||||
|
||||
for (const file of files) {
|
||||
if (file !== 'rice.json') {
|
||||
const filePath = `rices/${identifier}/${file}`;
|
||||
await this.gitHubService.deleteFile(
|
||||
filePath,
|
||||
`[MODERATION] Remove file ${file} from rice ${identifier}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isOctokitResponseError(error)) {
|
||||
if (error.status === 404) {
|
||||
throw new NotFoundException('Rice not found');
|
||||
}
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
console.error('Error removing rice by moderation:', error);
|
||||
throw new Error('Failed to remove rice by moderation');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a specific directory in GitHub
|
||||
*/
|
||||
private async listFilesInDirectory(pathInRepo: string): Promise<string[]> {
|
||||
try {
|
||||
return await this.gitHubService.listFilesInDirectory(pathInRepo);
|
||||
} catch (error) {
|
||||
if (isOctokitResponseError(error) && error.status === 404) {
|
||||
return [];
|
||||
}
|
||||
console.error(`Error listing files in ${pathInRepo}:`, error);
|
||||
throw new Error('Failed to list files in directory');
|
||||
}
|
||||
}
|
||||
}
|
6
src/rices/utils/slug.util.ts
Normal file
6
src/rices/utils/slug.util.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export function generateSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
211
test/app.e2e-spec.ts
Normal file
211
test/app.e2e-spec.ts
Normal file
|
@ -0,0 +1,211 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from './../src/app.module';
|
||||
import * as path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { GitHubService } from '../src/github/github.service';
|
||||
|
||||
describe('Rices API E2E', () => {
|
||||
let app: INestApplication;
|
||||
let gitHubService: GitHubService;
|
||||
const moderationSecret = 'testSecret999';
|
||||
|
||||
beforeAll(async () => {
|
||||
require('dotenv').config({ path: '.env.test.local' });
|
||||
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
gitHubService = moduleFixture.get<GitHubService>(GitHubService);
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// await gitHubService.clearRepository();
|
||||
});
|
||||
|
||||
it('POST /rices - Create new zenrice', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/rices')
|
||||
.field('name', 'My first zenrice')
|
||||
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toHaveProperty('identifier');
|
||||
expect(response.body).toHaveProperty('token');
|
||||
|
||||
const { identifier, token } = response.body;
|
||||
|
||||
const uploadedFileContent = await gitHubService.getFileContent(
|
||||
`rices/${identifier}/data.zenrice`,
|
||||
);
|
||||
expect(uploadedFileContent).not.toBeNull();
|
||||
expect(uploadedFileContent).toContain('This is an example zenrice file.');
|
||||
});
|
||||
|
||||
it('GET /rices/:identifier - Download zenrice', async () => {
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post('/rices')
|
||||
.field('name', 'My first zenrice')
|
||||
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||
.expect(201);
|
||||
|
||||
const { identifier, token } = createResponse.body;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/rices/${identifier}`)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('PUT /rices/:identifier - Update zenrice', async () => {
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post('/rices')
|
||||
.field('name', 'My first zenrice')
|
||||
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||
.expect(201);
|
||||
|
||||
const { identifier, token } = createResponse.body;
|
||||
|
||||
const updateResponse = await request(app.getHttpServer())
|
||||
.put(`/rices/${identifier}`)
|
||||
.set('x-rices-token', token)
|
||||
.field('name', 'Mi rice renombrado')
|
||||
.attach('file', path.join(__dirname, 'files', 'example_update.zenrice'))
|
||||
.expect(200);
|
||||
|
||||
expect(updateResponse.body).toHaveProperty(
|
||||
'message',
|
||||
`Rice ${identifier} updated`,
|
||||
);
|
||||
|
||||
const uploadedFileContent = await gitHubService.getFileContent(
|
||||
`rices/${identifier}/data.zenrice`,
|
||||
);
|
||||
expect(uploadedFileContent).not.toBeNull();
|
||||
expect(uploadedFileContent).toContain(
|
||||
'This is an example zenrice file (modified).',
|
||||
);
|
||||
});
|
||||
|
||||
it('DELETE /rices/:identifier - Delete zenrice with previous token', async () => {
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post('/rices')
|
||||
.field('name', 'My first zenrice')
|
||||
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||
.expect(201);
|
||||
|
||||
const { identifier, token } = createResponse.body;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/rices/${identifier}`)
|
||||
.set('x-rices-token', token)
|
||||
.expect(204);
|
||||
|
||||
const riceJsonContent = await gitHubService.getFileContent(
|
||||
`rices/${identifier}/rice.json`,
|
||||
);
|
||||
expect(riceJsonContent).toBeNull();
|
||||
|
||||
const uploadedFileContent = await gitHubService.getFileContent(
|
||||
`rices/${identifier}/data.zenrice`,
|
||||
);
|
||||
expect(uploadedFileContent).toBeNull();
|
||||
});
|
||||
|
||||
it('GET /rices/:identifier - Trying to download deleted zenrice', async () => {
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post('/rices')
|
||||
.field('name', 'My first zenrice')
|
||||
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||
.expect(201);
|
||||
|
||||
const { identifier, token } = createResponse.body;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/rices/${identifier}`)
|
||||
.set('x-rices-token', token)
|
||||
.expect(204);
|
||||
|
||||
await request(app.getHttpServer()).get(`/rices/${identifier}`).expect(404);
|
||||
});
|
||||
|
||||
it('POST /rices - New zenrice for moderation test', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/rices')
|
||||
.field('name', 'Rice for moderation')
|
||||
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toHaveProperty('identifier');
|
||||
expect(response.body).toHaveProperty('token');
|
||||
|
||||
const { identifier, token } = response.body;
|
||||
|
||||
const riceJsonContent = await gitHubService.getFileContent(
|
||||
`rices/${identifier}/rice.json`,
|
||||
);
|
||||
expect(riceJsonContent).not.toBeNull();
|
||||
|
||||
const riceData = JSON.parse(riceJsonContent!);
|
||||
expect(riceData).toMatchObject({
|
||||
id: identifier,
|
||||
token,
|
||||
name: 'Rice for moderation',
|
||||
});
|
||||
|
||||
const uploadedFileContent = await gitHubService.getFileContent(
|
||||
`rices/${identifier}/data.zenrice`,
|
||||
);
|
||||
expect(uploadedFileContent).not.toBeNull();
|
||||
expect(uploadedFileContent).toContain('This is an example zenrice file.');
|
||||
});
|
||||
|
||||
it('DELETE /rices/moderate/delete/:identifier - Delete zenrice for moderation using a correct secret', async () => {
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post('/rices')
|
||||
.field('name', 'Rice for moderation')
|
||||
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
|
||||
.expect(201);
|
||||
|
||||
const { identifier, token } = createResponse.body;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/rices/moderate/delete/${identifier}`)
|
||||
.set('x-moderation-secret', moderationSecret)
|
||||
.expect(204);
|
||||
|
||||
const riceJsonContent = await gitHubService.getFileContent(
|
||||
`rices/${identifier}/rice.json`,
|
||||
);
|
||||
expect(riceJsonContent).toBeNull();
|
||||
|
||||
const uploadedFileContent = await gitHubService.getFileContent(
|
||||
`rices/${identifier}/data.zenrice`,
|
||||
);
|
||||
expect(uploadedFileContent).toBeNull();
|
||||
});
|
||||
|
||||
it('DELETE /rices/moderate/delete/:identifier - Delete zenrice for moderation using an incorrect secret', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/rices/moderate/delete/${uuidv4()}`)
|
||||
.set('x-moderation-secret', 'claveIncorrecta')
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('DELETE /rices/moderate/delete/:identifier - Delete non existent zenrice for moderation', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/rices/moderate/delete/${uuidv4()}`)
|
||||
.set('x-moderation-secret', moderationSecret)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
1
test/files/example.zenrice
Normal file
1
test/files/example.zenrice
Normal file
|
@ -0,0 +1 @@
|
|||
This is an example zenrice file.
|
1
test/files/example_update.zenrice
Normal file
1
test/files/example_update.zenrice
Normal file
|
@ -0,0 +1 @@
|
|||
This is an example zenrice file (modified).
|
13
test/jest-e2e.json
Normal file
13
test/jest-e2e.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "../",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.ts$": "ts-jest"
|
||||
},
|
||||
"setupFilesAfterEnv": ["<rootDir>/test/setup.ts"],
|
||||
"coverageDirectory": "./coverage",
|
||||
"testEnvironment": "node",
|
||||
"maxWorkers": 1,
|
||||
"testTimeout": 30000
|
||||
}
|
18
test/restclient/01_create_rice.http
Normal file
18
test/restclient/01_create_rice.http
Normal file
|
@ -0,0 +1,18 @@
|
|||
@baseUrl = http://localhost:3000
|
||||
@moderationSecret = superSecret123
|
||||
@random_identifier = 123e4567-e89b-12d3-a456-426614174000
|
||||
|
||||
|
||||
POST {{baseUrl}}/rices
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="name"
|
||||
|
||||
Mi primer zenrice
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="file"; filename="example.zenrice"
|
||||
Content-Type: text/plain
|
||||
|
||||
This is an example zenrice file.
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
5
test/restclient/02_download_rice.http
Normal file
5
test/restclient/02_download_rice.http
Normal file
|
@ -0,0 +1,5 @@
|
|||
@baseUrl = http://localhost:3000
|
||||
@moderationSecret = superSecret123
|
||||
@previous_identifier = my-first-zenrice-feffbf61-b815-4357-ba0c-2cbadf94fcfe
|
||||
|
||||
GET {{baseUrl}}/rices/{{previous_identifier}}
|
19
test/restclient/03_update_rice.http
Normal file
19
test/restclient/03_update_rice.http
Normal file
|
@ -0,0 +1,19 @@
|
|||
@baseUrl = http://localhost:3000
|
||||
@moderationSecret = superSecret123
|
||||
@previous_identifier = my-first-zenrice-feffbf61-b815-4357-ba0c-2cbadf94fcfe
|
||||
@previous_token = 806cd360-6c14-44de-92db-f46f328dab5a
|
||||
|
||||
PUT {{baseUrl}}/rices/{{previous_identifier}}
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
x-rices-token: {{previous_token}}
|
||||
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="name"
|
||||
|
||||
Mi rice renombrado
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="file"; filename="newfile.txt"
|
||||
Content-Type: text/plain
|
||||
|
||||
This is an example zenrice file (modified).
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
7
test/restclient/04_delete_rice.http
Normal file
7
test/restclient/04_delete_rice.http
Normal file
|
@ -0,0 +1,7 @@
|
|||
@baseUrl = http://localhost:3000
|
||||
@moderationSecret = superSecret123
|
||||
@previous_identifier = my-first-zenrice-b7b94d24-ecb6-4495-93de-ba85be2e3052
|
||||
@previous_token = 6181664b-00e8-4eef-8e23-1f7fa0c64021
|
||||
|
||||
DELETE {{baseUrl}}/rices/{{previous_identifier}}
|
||||
x-rices-token: {{previous_token}}
|
94
test/restclient/requests_full.http
Normal file
94
test/restclient/requests_full.http
Normal file
|
@ -0,0 +1,94 @@
|
|||
### Variables Iniciales
|
||||
@baseUrl = http://localhost:3000
|
||||
@moderationSecret = superSecret123
|
||||
@random_identifier = 123e4567-e89b-12d3-a456-426614174000 # Give a valid UUID
|
||||
|
||||
@identifier = YOUR_IDENTIFIER_HERE
|
||||
@token = YOUR_TOKEN_HERE
|
||||
|
||||
###
|
||||
|
||||
### 1. Crear un Nuevo Pack
|
||||
POST {{baseUrl}}/rices
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="name"
|
||||
|
||||
Mi primer rice
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="file"; filename="example.txt"
|
||||
Content-Type: text/plain
|
||||
|
||||
Contenido de mi archivo.
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||
|
||||
### **Instrucciones Después de Ejecutar:**
|
||||
1. Ejecuta esta solicitud.
|
||||
2. En la respuesta, copia los valores de `identifier` y `token`.
|
||||
3. Reemplaza `YOUR_IDENTIFIER_HERE` y `YOUR_TOKEN_HERE` en las variables al inicio del archivo con los valores copiados.
|
||||
|
||||
###
|
||||
|
||||
GET {{baseUrl}}/rices/{{identifier}}
|
||||
|
||||
###
|
||||
|
||||
PUT {{baseUrl}}/rices/{{identifier}}
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
x-rices-token: {{token}}
|
||||
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="name"
|
||||
|
||||
Mi rice renombrado
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="file"; filename="newfile.txt"
|
||||
Content-Type: text/plain
|
||||
|
||||
Contenido de mi nuevo archivo.
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||
|
||||
###
|
||||
|
||||
### 4. Eliminar el Pack Usando el Token
|
||||
DELETE {{baseUrl}}/rices/{{identifier}}
|
||||
x-rices-token: {{token}}
|
||||
|
||||
###
|
||||
|
||||
### 5. Intentar Obtener el Pack Eliminado (Debe Retornar 404)
|
||||
GET {{baseUrl}}/rices/{{identifier}}
|
||||
|
||||
###
|
||||
|
||||
### 6. Crear un Nuevo Pack para Moderación
|
||||
POST {{baseUrl}}/rices
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="name"
|
||||
|
||||
Pack para moderación
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="file"; filename="example.txt"
|
||||
Content-Type: text/plain
|
||||
|
||||
Contenido de mi archivo.
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||
|
||||
###
|
||||
|
||||
### 7. Eliminar el Pack por Moderación con el Secreto Correcto
|
||||
DELETE {{baseUrl}}/rices/moderate/delete/{{identifier}}
|
||||
x-moderation-secret: {{moderationSecret}}
|
||||
|
||||
###
|
||||
|
||||
DELETE {{baseUrl}}/rices/moderate/delete/{{random_identifier}}
|
||||
x-moderation-secret: claveIncorrecta
|
||||
|
||||
###
|
||||
|
||||
DELETE {{baseUrl}}/rices/moderate/delete/{{random_identifier}}
|
||||
x-moderation-secret: {{moderationSecret}}
|
5
test/setup.ts
Normal file
5
test/setup.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
// test/setup.ts
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env.test.local') });
|
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue