diff --git a/sql/ddl_1.0.0.sql b/sql/ddl_1.0.0.sql index 5d28623..7420d46 100644 --- a/sql/ddl_1.0.0.sql +++ b/sql/ddl_1.0.0.sql @@ -1,7 +1,9 @@ --- DROP TABLE IF EXISTS rices; +--DROP TABLE IF EXISTS rices; CREATE TABLE rices ( id UUID NOT NULL, -- Unique identifier + version VARCHAR(10) NOT NULL, -- Data version + os VARCHAR(30) NOT NULL, -- Operating system slug VARCHAR(75) NOT NULL, -- Unique user-friendly identifier name VARCHAR(75) NOT NULL, -- Name of the rice token UUID NOT NULL, -- Unique authorization token @@ -14,7 +16,6 @@ CREATE TABLE rices ( UNIQUE (name) -- Ensure name is unique ); - CREATE OR REPLACE FUNCTION increment_visits(slug_param TEXT) RETURNS VOID AS $$ BEGIN diff --git a/src/filters/http-exception.filter.ts b/src/filters/http-exception.filter.ts new file mode 100644 index 0000000..9990112 --- /dev/null +++ b/src/filters/http-exception.filter.ts @@ -0,0 +1,24 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, +} from '@nestjs/common'; +import { Response } from 'express'; + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + + response.status(status).json({ + statusCode: status, + ...(typeof exceptionResponse === 'string' + ? { message: exceptionResponse } + : exceptionResponse), + }); + } +} diff --git a/src/github/github.service.ts b/src/github/github.service.ts index 1790de1..c9d31bb 100644 --- a/src/github/github.service.ts +++ b/src/github/github.service.ts @@ -336,6 +336,67 @@ export class GitHubService implements OnModuleInit { } } + /** + * Deletes an empty folder from the repository. + * Assumes the folder is already empty. + * @param folderPath Path of the folder in the repository. + * @param commitMessage Commit message for the deletion. + */ + async deleteFolder(folderPath: string, commitMessage: string): Promise { + try { + // GitHub does not support direct folder deletion; instead, ensure no files remain + const files = await this.listFilesInDirectory(folderPath); + + if (files.length > 0) { + throw new Error(`Folder ${folderPath} is not empty. Cannot delete.`); + } + + // GitHub API requires at least a dummy file like .gitkeep to keep directories + const gitkeepPath = `${folderPath}/.gitkeep`; + try { + const { data: existingFile } = await this.octokit.repos.getContent({ + owner: this.repoOwner, + repo: this.repoName, + path: gitkeepPath, + ref: this.defaultBranch, + }); + + if (!('sha' in existingFile)) { + throw new Error( + `The .gitkeep file in ${folderPath} does not have a valid SHA`, + ); + } + + const sha = existingFile.sha; + + // Delete the .gitkeep file + await this.octokit.repos.deleteFile({ + owner: this.repoOwner, + repo: this.repoName, + path: gitkeepPath, + message: commitMessage, + sha, + branch: this.defaultBranch, + }); + + this.logger.log(`Folder ${folderPath} deleted successfully.`); + } catch (error: any) { + if (isOctokitResponseError(error) && error.status === 404) { + this.logger.warn( + `The .gitkeep file in ${folderPath} does not exist.`, + ); + } else { + throw error; + } + } + } catch (error) { + this.logger.error(`Error deleting folder ${folderPath}: ${error}`); + throw new InternalServerErrorException( + `Failed to delete folder: ${error}`, + ); + } + } + /** * Clears all files in the GitHub repository. * Useful for cleaning the state before running tests. diff --git a/src/main.ts b/src/main.ts index afaf23a..ee730b1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,11 +3,13 @@ import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { ThrottlerExceptionFilter } from './common/filters/throttler-exception.filter'; +import { HttpExceptionFilter } from './filters/http-exception.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes(new ValidationPipe({ transform: true })); + app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalFilters(new ThrottlerExceptionFilter()); const config = new DocumentBuilder() diff --git a/src/rices/dto/create-rice.dto.ts b/src/rices/dto/create-rice.dto.ts index 6de8a8f..4187737 100644 --- a/src/rices/dto/create-rice.dto.ts +++ b/src/rices/dto/create-rice.dto.ts @@ -4,6 +4,12 @@ export class CreateRiceDto { @IsString() name!: string; + @IsString() + version!: string; + + @IsString() + os!: string; + @IsString() content!: string; } diff --git a/src/rices/rices.service.ts b/src/rices/rices.service.ts index b2ecdd2..8e4226e 100644 --- a/src/rices/rices.service.ts +++ b/src/rices/rices.service.ts @@ -3,6 +3,7 @@ import { NotFoundException, UnauthorizedException, ConflictException, + BadRequestException, } from '@nestjs/common'; import { CreateRiceDto } from './dto/create-rice.dto'; import { UpdateRiceDto } from './dto/update-rice.dto'; @@ -19,56 +20,76 @@ export class RicesService { ) {} async create(createRiceDto: CreateRiceDto) { - // Check if a rice with the same name already exists - const existingRice = await this.supabaseService.getRiceByName( - createRiceDto.name, - ); - if (existingRice) { - throw new ConflictException( - `A rice with the name '${createRiceDto.name}' already exists.`, + try { + // Ensure required fields are present + if (!createRiceDto.name || !createRiceDto.version || !createRiceDto.os) { + throw new BadRequestException( + 'Missing required fields: name, version, and os are mandatory.', + ); + } + + // Check if a rice with the same name already exists + const existingRice = await this.supabaseService.getRiceByName( + createRiceDto.name, ); + if (existingRice) { + throw new ConflictException( + `A rice with the name '${createRiceDto.name}' already exists.`, + ); + } + + const slug = createRiceDto.name + ? `${generateSlug(createRiceDto.name)}-${uuidv4()}` + : uuidv4(); + + const token = uuidv4(); + + const encodedContent = Buffer.from( + JSON.stringify(createRiceDto.content), + ).toString('base64'); + + const metadata = { + id: uuidv4(), + token, + name: createRiceDto.name, + version: createRiceDto.version, + os: createRiceDto.os, + slug: slug, + visits: 0, + level: 0, + created_at: new Date().toISOString(), + }; + + // Insert metadata into Supabase + await this.supabaseService.insertRice(metadata); + + if (createRiceDto.content) { + const uploadedFilePath = `rices/${slug}/data.zenrice`; + await this.gitHubService.createOrUpdateFile( + uploadedFilePath, + encodedContent, + `Add file createRiceDto.content to rice ${slug}`, + ); + } + + return { slug, token }; + } catch (error) { + // Log the error for debugging + console.error('Error in create method:', error); + + if ( + error instanceof ConflictException || + error instanceof BadRequestException + ) { + throw error; // Or create a custom response + } else if (error instanceof Error) { + // Extract a user-friendly message if possible, or log details + throw new Error(`Rice creation failed: ${error.message}`); // More informative error message + } else { + // Catch unexpected errors + throw new Error('Internal Server Error'); // Only for truly unexpected issues + } } - - const slug = createRiceDto.name - ? `${generateSlug(createRiceDto.name)}-${uuidv4()}` - : uuidv4(); - - const token = uuidv4(); - - const encodedContent = Buffer.from( - JSON.stringify(createRiceDto.content), - ).toString('base64'); - - const metadata = { - id: uuidv4(), - token, - name: createRiceDto.name || null, - slug: slug, - visits: 0, - level: 0, - created_at: new Date().toISOString(), - }; - - await this.supabaseService.insertRice(metadata); - - const metadataContent = JSON.stringify(metadata, null, 2); - const riceJsonPath = `rices/${slug}/rice.json`; - await this.gitHubService.createOrUpdateFile( - riceJsonPath, - metadataContent, - `Add rice ${slug}`, - ); - - if (createRiceDto.content) { - const uploadedFilePath = `rices/${slug}/data.zenrice`; - await this.gitHubService.createOrUpdateFile( - uploadedFilePath, - encodedContent, - `Add file createRiceDto.content to rice ${slug}`, - ); - } - - return { slug, token }; } async findOne(slug: string) { @@ -90,7 +111,7 @@ export class RicesService { // Remove unescaped double quotes at the beginning and end, if present const content = contentPrev.replace(/^"|"$/g, ''); - return { slug, content }; + return content; } async update(slug: string, token: string, updateRiceDto: UpdateRiceDto) { @@ -111,6 +132,11 @@ export class RicesService { await this.supabaseService.getRiceBySlug(slug); if (!rice) throw new NotFoundException('Rice not found'); if (rice.token !== token) throw new UnauthorizedException('Invalid token'); + if (!updateRiceDto.content) { + throw new BadRequestException( + 'Missing required fields: content is mandatory.', + ); + } const updatedMetadata = { ...rice, @@ -119,14 +145,6 @@ export class RicesService { await this.supabaseService.updateRice(slug, updatedMetadata); - const metadataContent = JSON.stringify(updatedMetadata, null, 2); - const riceJsonPath = `rices/${slug}/rice.json`; - await this.gitHubService.createOrUpdateFile( - riceJsonPath, - metadataContent, - `Update rice ${slug}`, - ); - if (updateRiceDto.content) { const encodedContent = Buffer.from( JSON.stringify(updateRiceDto.content), @@ -150,8 +168,25 @@ export class RicesService { await this.supabaseService.deleteRice(slug); - const riceJsonPath = `rices/${slug}/rice.json`; - await this.gitHubService.deleteFile(riceJsonPath, `Remove rice ${slug}`); + const folderPath = `rices/${slug}`; + + // List all files in the folder + const files = await this.gitHubService.listFilesInDirectory(folderPath); + + // Delete all files within the folder + for (const file of files) { + const filePath = `${folderPath}/${file}`; + await this.gitHubService.deleteFile( + filePath, + `Remove file ${file} in rice ${slug}`, + ); + } + + // Finally, remove the folder itself + await this.gitHubService.deleteFolder( + folderPath, + `Remove folder ${folderPath}`, + ); } /** @@ -169,27 +204,30 @@ export class RicesService { // 2. Delete metadata from Supabase await this.supabaseService.deleteRice(slug); - // 3. Delete rice.json from GitHub - const riceJsonPath = `rices/${slug}/rice.json`; + // 3. Delete data.zenrice from GitHub + const riceJsonPath = `rices/${slug}/data.zenrice`; await this.gitHubService.deleteFile( riceJsonPath, `[MODERATION] Remove rice ${slug}`, ); // 4. List and delete uploaded files from GitHub (if any) - const uploadedFilesPath = `rices/${slug}`; - const files = - await this.gitHubService.listFilesInDirectory(uploadedFilesPath); + const filesPath = `rices/${slug}`; + const files = await this.gitHubService.listFilesInDirectory(filesPath); for (const file of files) { - if (file !== 'rice.json') { - const filePath = `rices/${slug}/${file}`; - await this.gitHubService.deleteFile( - filePath, - `[MODERATION] Remove file ${file} from rice ${slug}`, - ); - } + const filePath = `rices/${slug}/${file}`; + await this.gitHubService.deleteFile( + filePath, + `[MODERATION] Remove file ${file} from rice ${slug}`, + ); } + + // 4. Finally, remove the folder itself + await this.gitHubService.deleteFolder( + filesPath, + `[MODERATION] Remove folder ${filesPath}`, + ); } catch (error) { console.error('Error removing rice by moderation:', error); if (error instanceof NotFoundException) { diff --git a/src/supabase/supabase.service.ts b/src/supabase/supabase.service.ts index 7e2be5a..b433da6 100644 --- a/src/supabase/supabase.service.ts +++ b/src/supabase/supabase.service.ts @@ -55,7 +55,7 @@ export class SupabaseService { `Failed to fetch rice with slug ${slug}: ${error.message}`, error.details, ); - throw new Error(`Failed to fetch rice: ${error.message}`); + return null; } return data; } diff --git a/test/restclient/01_create_rice.http b/test/restclient/01_create_rice.http index c59db4f..15651a8 100644 --- a/test/restclient/01_create_rice.http +++ b/test/restclient/01_create_rice.http @@ -11,7 +11,9 @@ POST {{baseUrl}}/rices Content-Type: application/json { - "name": "cool-zenrice-aurora", + "name": "cool-zenrice-aurora2", + "version": "1.0.0", + "os": "EndeavourOS x86_64", "content": "{'key':'value','description':'Example content'}" } diff --git a/test/restclient/02_download_rice.http b/test/restclient/02_download_rice.http index 670225d..f0e98da 100644 --- a/test/restclient/02_download_rice.http +++ b/test/restclient/02_download_rice.http @@ -1,4 +1,5 @@ @baseUrl = http://localhost:3000 -@previous_slug = cool-zenrice-aurora-265e3881-f9de-4bbf-bb22-b9c37dbf04bc +@previous_slug = cool-zenrice-aurora-e99096ae-00da-4d54-9a47-53b20eb57647 + GET {{baseUrl}}/rices/{{previous_slug}} \ No newline at end of file diff --git a/test/restclient/03_update_rice.http b/test/restclient/03_update_rice.http index ed10088..606416f 100644 --- a/test/restclient/03_update_rice.http +++ b/test/restclient/03_update_rice.http @@ -1,6 +1,6 @@ @baseUrl = http://localhost:3000 -@previous_slug = cool-zenrice-aurora2-1d1e74b3-8d6d-40ea-bd4f-a6f4ad893f88 -@previous_token = be4545f4-d92b-416c-8b3b-50cc9a49dee9 +@previous_slug = cool-zenrice-aurora-ef732cbc-fdde-4f76-b4e3-cff0ec8b6f39 +@previous_token = b406f962-5c51-43a9-8382-40e0983a46e7 # { # "key": "value", diff --git a/test/restclient/04_delete_rice.http b/test/restclient/04_delete_rice.http index 99a7d0c..8489dd9 100644 --- a/test/restclient/04_delete_rice.http +++ b/test/restclient/04_delete_rice.http @@ -1,6 +1,6 @@ @baseUrl = http://localhost:3000 -@previous_slug = cool-zenrice-aurora-e8abd40d-8657-4ddd-99c5-912cced67497 -@previous_token = 2ce5c580-46e5-4a28-adff-30ce5662f29d +@previous_slug = cool-zenrice-aurora2-b970a742-789c-4349-8a4d-da63c8bbe77d +@previous_token = 03fbfdb4-d3a5-4d64-8740-feac7d32e7a8 DELETE {{baseUrl}}/rices/{{previous_slug}} x-rices-token: {{previous_token}}