first commit

This commit is contained in:
Mr Cheff 2024-12-25 23:45:00 +01:00 committed by mr. M
commit 232d8b37d6
No known key found for this signature in database
GPG key ID: CBD57A2AEDBDA1FB
36 changed files with 11302 additions and 0 deletions

5
.env.example Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

57
README.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

64
package.json Normal file
View 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"
}
}

View 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
View 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
View 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
View file

@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {}

View 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.',
});
}
}

View 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 {}

View 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();
});
});

View 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
View 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();

View file

@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateRiceDto {
@IsOptional()
@IsString()
name?: string;
}

View file

@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class UpdateRiceDto {
@IsOptional()
@IsString()
name?: string;
}

View 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
View 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
View 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');
}
}
}

View 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
View 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);
});
});

View file

@ -0,0 +1 @@
This is an example zenrice file.

View file

@ -0,0 +1 @@
This is an example zenrice file (modified).

13
test/jest-e2e.json Normal file
View 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
}

View 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--

View 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}}

View 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--

View 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}}

View 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
View 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
View file

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

18
tsconfig.json Normal file
View 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"]
}