diff --git a/sql/ddl_1.0.0.sql b/sql/ddl_1.0.0.sql index 7420d46..ac2fe7f 100644 --- a/sql/ddl_1.0.0.sql +++ b/sql/ddl_1.0.0.sql @@ -2,10 +2,11 @@ CREATE TABLE rices ( id UUID NOT NULL, -- Unique identifier + slug VARCHAR(75) NOT NULL, -- Unique user-friendly 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 + author VARCHAR(100) NOT NULL, -- Name of the rice token UUID NOT NULL, -- Unique authorization token visits INTEGER DEFAULT 0 NOT NULL, -- Visit counter, initialized to 0 level INTEGER DEFAULT 0 NOT NULL, -- Level: 0 (Public), 1 (Verified) diff --git a/src/rices/rices.controller.ts b/src/rices/rices.controller.ts index c2ce7f7..710602d 100644 --- a/src/rices/rices.controller.ts +++ b/src/rices/rices.controller.ts @@ -6,24 +6,14 @@ import { Delete, Param, Body, - 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, - ApiBody, -} from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from '@nestjs/swagger'; @ApiTags('rices') @Controller('rices') @@ -32,47 +22,72 @@ export class RicesController { @ApiOperation({ summary: 'Upload a new Rice' }) @ApiResponse({ status: 201, description: 'Rice successfully created.' }) - @ApiConsumes('multipart/form-data') - @ApiBody({ - description: 'Data required to create a rice', - schema: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Name of the rice', - example: 'My First Rice', - }, - content: { - type: 'string', - description: 'The JSON content to upload', - }, - }, - }, + @ApiHeader({ + name: 'X-Zen-Rice-Name', + description: 'Name of the rice', + required: true, + }) + @ApiHeader({ + name: 'X-Zen-Rice-Author', + description: 'Author of the rice', + required: true, + }) + @ApiHeader({ + name: 'User-Agent', + description: 'User-Agent in the format ZenBrowser/ ()', + required: true, }) @Post() - async createRice(@Body() createRiceDto: CreateRiceDto) { - return this.ricesService.create(createRiceDto); + async createRice( + @Body() content: string, + @Headers() headers: Record, + ) { + const contentString = + typeof content === 'string' ? content : JSON.stringify(content); + return this.ricesService.create(contentString, headers); } @ApiOperation({ summary: 'Get information about a Rice' }) @ApiResponse({ status: 200, description: 'Returns metadata of the Rice.' }) @Get(':slug') + /************* ✨ Codeium Command ⭐ *************/ + /** + * Retrieve metadata of a rice with the given slug. + * @param slug Slug of the rice. + * @returns Metadata of the rice if found, otherwise throws a NotFoundException. + */ + /****** c6f70808-e78d-4b17-a285-d2fd79527659 *******/ async getRice(@Param('slug') slug: string) { return this.ricesService.findOne(slug); } @ApiOperation({ summary: 'Update an existing Rice' }) @ApiResponse({ status: 200, description: 'Rice successfully updated.' }) - @ApiConsumes('multipart/form-data') + @ApiHeader({ + name: 'X-Zen-Rice-Name', + description: 'Name of the rice', + required: true, + }) + @ApiHeader({ + name: 'X-Zen-Rice-Author', + description: 'Author of the rice', + required: true, + }) + @ApiHeader({ + name: 'User-Agent', + description: 'User-Agent in the format ZenBrowser/ ()', + required: true, + }) @Put(':slug') - @UseInterceptors(FileInterceptor('file')) async updateRice( @Param('slug') slug: string, - @Headers('x-rices-token') token: string, - @Body() updateRiceDto: UpdateRiceDto, + @Body() content: string, + @Headers() headers: Record, + @Headers('x-zen-rices-token') token: string, ) { - return this.ricesService.update(slug, token, updateRiceDto); + const contentString = + typeof content === 'string' ? content : JSON.stringify(content); + return this.ricesService.update(slug, token, contentString, headers); } @ApiOperation({ summary: 'Delete an existing Rice' }) @@ -81,15 +96,12 @@ export class RicesController { @Delete(':slug') async removeRice( @Param('slug') slug: string, - @Headers('x-rices-token') token: string, + @Headers('x-zen-rices-token') token: string, ) { await this.ricesService.remove(slug, token); return; } - // ========================================= - // NEW ENDPOINT FOR MODERATION DELETION - // ========================================= @ApiOperation({ summary: 'Forcefully delete a Rice (moderation)', description: @@ -102,12 +114,9 @@ export class RicesController { @Param('slug') slug: 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(slug); return; } diff --git a/src/rices/rices.service.ts b/src/rices/rices.service.ts index 8e4226e..707f2a0 100644 --- a/src/rices/rices.service.ts +++ b/src/rices/rices.service.ts @@ -5,8 +5,6 @@ import { ConflictException, BadRequestException, } 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'; @@ -19,42 +17,82 @@ export class RicesService { private readonly supabaseService: SupabaseService, ) {} - async create(createRiceDto: CreateRiceDto) { + async create(content: string, headers: Record) { try { - // Ensure required fields are present - if (!createRiceDto.name || !createRiceDto.version || !createRiceDto.os) { + // Validate headers + const name = headers['x-zen-rice-name']; + const author = headers['x-zen-rice-author']; + const userAgent = headers['user-agent']; + + if (!name || !author || !userAgent) { throw new BadRequestException( - 'Missing required fields: name, version, and os are mandatory.', + 'Missing required headers: X-Zen-Rice-Name, X-Zen-Rice-Author, and User-Agent are mandatory.', + ); + } + + // Validate content + if (typeof content !== 'string') { + throw new BadRequestException('The request body must be a string.'); + } + + // Validate lengths + if (name.length > 75) { + throw new BadRequestException( + `The value of X-Zen-Rice-Name exceeds the maximum allowed length of 75 characters.`, + ); + } + + if (author.length > 100) { + throw new BadRequestException( + `The value of X-Zen-Rice-Author exceeds the maximum allowed length of 100 characters.`, + ); + } + + // Parse version and OS from User-Agent + const userAgentRegex = /ZenBrowser\/(\d+\.\d+\.\d+) \((.+)\)/; + const match = userAgent.match(userAgentRegex); + + if (!match) { + throw new BadRequestException( + 'Invalid User-Agent format. Expected format: ZenBrowser/ ().', + ); + } + + const [, version, os] = match; + // Validate version and OS lengths + if (version.length > 10) { + throw new BadRequestException( + `The version in User-Agent exceeds the maximum allowed length of 10 characters.`, + ); + } + + if (os.length > 30) { + throw new BadRequestException( + `The operating system in User-Agent exceeds the maximum allowed length of 30 characters.`, ); } // Check if a rice with the same name already exists - const existingRice = await this.supabaseService.getRiceByName( - createRiceDto.name, - ); + const existingRice = await this.supabaseService.getRiceByName(name); if (existingRice) { throw new ConflictException( - `A rice with the name '${createRiceDto.name}' already exists.`, + `A rice with the name '${name}' already exists.`, ); } - const slug = createRiceDto.name - ? `${generateSlug(createRiceDto.name)}-${uuidv4()}` - : uuidv4(); - + const slug = `${generateSlug(name)}-${uuidv4()}`; const token = uuidv4(); - const encodedContent = Buffer.from( - JSON.stringify(createRiceDto.content), - ).toString('base64'); + const encodedContent = Buffer.from(content).toString('base64'); const metadata = { id: uuidv4(), token, - name: createRiceDto.name, - version: createRiceDto.version, - os: createRiceDto.os, - slug: slug, + name, + author, + version, + os, + slug, visits: 0, level: 0, created_at: new Date().toISOString(), @@ -63,32 +101,17 @@ export class RicesService { // 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}`, - ); - } + const uploadedFilePath = `rices/${slug}/data.zenrice`; + await this.gitHubService.createOrUpdateFile( + uploadedFilePath, + encodedContent, + `Add 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 - } + throw error; } } @@ -114,51 +137,76 @@ export class RicesService { return content; } - async update(slug: string, token: string, updateRiceDto: UpdateRiceDto) { - /************* ✨ Codeium Command ⭐ *************/ - /** - * Updates the metadata and content of a rice entry identified by its slug. - * - * @param slug - The unique identifier for the rice entry. - * @param token - The authorization token to verify the request. - * @param updateRiceDto - Data Transfer Object containing fields to update. - * - * @returns A confirmation message indicating successful update. - * - * @throws NotFoundException - If the rice entry does not exist. - * @throws UnauthorizedException - If the provided token is invalid. - */ - /****** bf5f61f3-c1dc-40a0-85e6-288824144ead *******/ const rice = - 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.', - ); - } + async update( + slug: string, + token: string, + content: string, + headers: Record, + ) { + try { + // Extract fields from headers + const name = headers['x-zen-rice-name']; + const author = headers['x-zen-rice-author']; + const userAgent = headers['user-agent']; - const updatedMetadata = { - ...rice, - updated_at: new Date().toISOString(), - }; + if (!name || !author || !userAgent) { + throw new BadRequestException( + 'Missing required headers: X-Zen-Rice-Name, X-Zen-Rice-Author, and User-Agent are mandatory.', + ); + } - await this.supabaseService.updateRice(slug, updatedMetadata); + // Parse version and OS from User-Agent + const userAgentRegex = /ZenBrowser\/(\d+\.\d+\.\d+) \((.+)\)/; + const match = userAgent.match(userAgentRegex); - if (updateRiceDto.content) { - const encodedContent = Buffer.from( - JSON.stringify(updateRiceDto.content), - ).toString('base64'); + if (!match) { + throw new BadRequestException( + 'Invalid User-Agent format. Expected format: ZenBrowser/ ().', + ); + } + const [, version, os] = match; + + // Check if the rice exists + const rice = await this.supabaseService.getRiceBySlug(slug); + if (!rice) { + throw new NotFoundException('Rice not found'); + } + + // Validate token, name, and author match the existing record + if (rice.token !== token) { + throw new UnauthorizedException('Invalid token.'); + } + + // Validate name and author match the existing record + if (rice.name !== name || rice.author !== author) { + throw new UnauthorizedException( + 'Provided name and author do not match the existing record.', + ); + } + + const updatedMetadata = { + ...rice, + version, + os, + updated_at: new Date().toISOString(), + }; + + await this.supabaseService.updateRice(slug, updatedMetadata); + + const encodedContent = Buffer.from(content).toString('base64'); const uploadedFilePath = `rices/${slug}/data.zenrice`; await this.gitHubService.createOrUpdateFile( uploadedFilePath, encodedContent, - `Update file updateRiceDto.content in rice ${slug}`, + `Update content in rice ${slug}`, ); - } - return { message: `ok` }; + return { message: `Rice ${slug} updated successfully.` }; + } catch (error) { + console.error('Error in update method:', error); + throw error; + } } async remove(slug: string, token: string): Promise { @@ -166,6 +214,11 @@ export class RicesService { if (!rice) throw new NotFoundException('Rice not found'); if (rice.token !== token) throw new UnauthorizedException('Invalid token'); + // Validate token, name, and author match the existing record + if (rice.token !== token) { + throw new UnauthorizedException('Invalid token.'); + } + await this.supabaseService.deleteRice(slug); const folderPath = `rices/${slug}`; diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 4e49582..f1a026a 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -87,7 +87,7 @@ describe('Rices API E2E', () => { const updateResponse = await request(app.getHttpServer()) .put(`/rices/${slug}`) - .set('x-rices-token', token) + .set('x-zen-rices-token', token) .field('name', 'Updated Rice') .attach('file', path.join(__dirname, 'files', 'example_update.zenrice')) .expect(200); @@ -116,7 +116,7 @@ describe('Rices API E2E', () => { await request(app.getHttpServer()) .delete(`/rices/${slug}`) - .set('x-rices-token', token) + .set('x-zen-rices-token', token) .expect(204); const riceInDatabase = await supabaseService.getRiceBySlug(slug); diff --git a/test/restclient/01_create_rice.http b/test/restclient/01_create_rice.http index 15651a8..d19bfcc 100644 --- a/test/restclient/01_create_rice.http +++ b/test/restclient/01_create_rice.http @@ -1,19 +1,74 @@ @baseUrl = http://localhost:3000 -# { -# "key": "value", -# "description": "Example content" -# } - -# {'key':'value','description':'Example content'} - POST {{baseUrl}}/rices Content-Type: application/json +X-Zen-Rice-Name: cool-zenrice-aurora +X-Zen-Rice-Author: jhon@doe.com +User-Agent: ZenBrowser/1.0.0 (EndeavourOS x86_64) { - "name": "cool-zenrice-aurora2", - "version": "1.0.0", - "os": "EndeavourOS x86_64", - "content": "{'key':'value','description':'Example content'}" -} - + "userChrome": "", + "userContent": null, + "enabledMods": [ + "5bb07b6e-c89f-4f4a-a0ed-e483cc535594" + ], + "preferences": { + "theme.custom_menubutton.default": "Firefox", + "theme.custom_menubutton.custom": "url(chrome://branding/content/icon32.png)", + "zen.view.use-single-toolbar": true, + "zen.view.sidebar-expanded": true, + "zen.tabs.vertical.right-side": false, + "zen.view.experimental-no-window-controls": false, + "zen.view.hide-window-controls": true, + "browser.uiCustomization.state": "{\"placements\":{\"widget-overflow-fixed-list\":[],\"unified-extensions-area\":[\"ublock0_raymondhill_net-browser-action\",\"addon_darkreader_org-browser-action\",\"_7a7a4a92-a2a0-41d1-9fd7-1e92480d612d_-browser-action\",\"cookieautodelete_kennydo_com-browser-action\",\"tab-unloader-we_afnankhan-browser-action\"],\"nav-bar\":[\"back-button\",\"forward-button\",\"stop-reload-button\",\"customizableui-special-spring1\",\"urlbar-container\",\"customizableui-special-spring2\",\"wrapper-sidebar-button\",\"unified-extensions-button\"],\"toolbar-menubar\":[\"menubar-items\"],\"TabsToolbar\":[\"tabbrowser-tabs\"],\"vertical-tabs\":[],\"PersonalToolbar\":[\"personal-bookmarks\"],\"zen-sidebar-top-buttons\":[\"zen-sidepanel-button\"],\"zen-sidebar-icons-wrapper\":[\"zen-profile-button\",\"zen-workspaces-button\",\"downloads-button\"]},\"seen\":[\"_7a7a4a92-a2a0-41d1-9fd7-1e92480d612d_-browser-action\",\"developer-button\",\"cookieautodelete_kennydo_com-browser-action\",\"tab-unloader-we_afnankhan-browser-action\",\"addon_darkreader_org-browser-action\",\"ublock0_raymondhill_net-browser-action\"],\"dirtyAreaCache\":[\"unified-extensions-area\",\"nav-bar\",\"toolbar-menubar\",\"TabsToolbar\",\"vertical-tabs\",\"PersonalToolbar\",\"zen-sidebar-top-buttons\",\"zen-sidebar-icons-wrapper\"],\"currentVersion\":20,\"newElementCount\":2}" + }, + "workspaceThemes": [ + { + "type": "gradient", + "gradientColors": [ + { + "c": [ + 124, + 133, + 255 + ], + "isCustom": false + }, + { + "c": [ + 69, + 255, + 86 + ], + "isCustom": false + } + ], + "opacity": 0.5, + "rotation": 45, + "texture": 0 + }, + { + "type": "gradient", + "gradientColors": [ + { + "c": [ + 255, + 133, + 65 + ], + "isCustom": false + } + ], + "opacity": 0.6, + "rotation": 45, + "texture": null + }, + { + "type": "gradient", + "gradientColors": [], + "opacity": 0.5, + "rotation": 45, + "texture": null + } + ] +} \ No newline at end of file diff --git a/test/restclient/02_download_rice.http b/test/restclient/02_download_rice.http index f0e98da..68c019a 100644 --- a/test/restclient/02_download_rice.http +++ b/test/restclient/02_download_rice.http @@ -1,5 +1,5 @@ @baseUrl = http://localhost:3000 -@previous_slug = cool-zenrice-aurora-e99096ae-00da-4d54-9a47-53b20eb57647 +@previous_slug = cool-zenrice-aurora-249dd7f2-d669-4d1f-892c-df4caa6fcbfe 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 606416f..8c347e8 100644 --- a/test/restclient/03_update_rice.http +++ b/test/restclient/03_update_rice.http @@ -1,19 +1,78 @@ @baseUrl = http://localhost:3000 -@previous_slug = cool-zenrice-aurora-ef732cbc-fdde-4f76-b4e3-cff0ec8b6f39 -@previous_token = b406f962-5c51-43a9-8382-40e0983a46e7 - -# { -# "key": "value", -# "description": "Example updated content" -# } - -# {'key':'value','description':'Example updated content'} +@previous_slug = cool-zenrice-aurora-249dd7f2-d669-4d1f-892c-df4caa6fcbfe +@previous_token = 528bb297-274f-4fe1-87f1-956b9b26e0df PUT {{baseUrl}}/rices/{{previous_slug}} Content-Type: application/json -x-rices-token: {{previous_token}} +x-zen-rices-token: {{previous_token}} +X-Zen-Rice-Name: cool-zenrice-aurora +X-Zen-Rice-Author: jhon@doe.com +User-Agent: ZenBrowser/1.0.0 (EndeavourOS x86_64) { - "content": "{'key':'value','description':'Example updated content'}" -} - + "userChrome": "", + "userContent": null, + "enabledMods": [ + "5bb07b6e-c89f-4f4a-a0ed-e483cc535594", + "5bb07b6e-c89f-4f4a-a0ed-e483cc535594" + ], + "preferences": { + "theme.custom_menubutton.default": "Firefox", + "theme.custom_menubutton.custom": "url(chrome://branding/content/icon32.png)", + "zen.view.use-single-toolbar": true, + "zen.view.sidebar-expanded": true, + "zen.tabs.vertical.right-side": false, + "zen.view.experimental-no-window-controls": false, + "zen.view.hide-window-controls": true, + "browser.uiCustomization.state": "{\"placements\":{\"widget-overflow-fixed-list\":[],\"unified-extensions-area\":[\"ublock0_raymondhill_net-browser-action\",\"addon_darkreader_org-browser-action\",\"_7a7a4a92-a2a0-41d1-9fd7-1e92480d612d_-browser-action\",\"cookieautodelete_kennydo_com-browser-action\",\"tab-unloader-we_afnankhan-browser-action\"],\"nav-bar\":[\"back-button\",\"forward-button\",\"stop-reload-button\",\"customizableui-special-spring1\",\"urlbar-container\",\"customizableui-special-spring2\",\"wrapper-sidebar-button\",\"unified-extensions-button\"],\"toolbar-menubar\":[\"menubar-items\"],\"TabsToolbar\":[\"tabbrowser-tabs\"],\"vertical-tabs\":[],\"PersonalToolbar\":[\"personal-bookmarks\"],\"zen-sidebar-top-buttons\":[\"zen-sidepanel-button\"],\"zen-sidebar-icons-wrapper\":[\"zen-profile-button\",\"zen-workspaces-button\",\"downloads-button\"]},\"seen\":[\"_7a7a4a92-a2a0-41d1-9fd7-1e92480d612d_-browser-action\",\"developer-button\",\"cookieautodelete_kennydo_com-browser-action\",\"tab-unloader-we_afnankhan-browser-action\",\"addon_darkreader_org-browser-action\",\"ublock0_raymondhill_net-browser-action\"],\"dirtyAreaCache\":[\"unified-extensions-area\",\"nav-bar\",\"toolbar-menubar\",\"TabsToolbar\",\"vertical-tabs\",\"PersonalToolbar\",\"zen-sidebar-top-buttons\",\"zen-sidebar-icons-wrapper\"],\"currentVersion\":20,\"newElementCount\":2}" + }, + "workspaceThemes": [ + { + "type": "gradient", + "gradientColors": [ + { + "c": [ + 124, + 133, + 255 + ], + "isCustom": false + }, + { + "c": [ + 69, + 255, + 86 + ], + "isCustom": false + } + ], + "opacity": 0.5, + "rotation": 45, + "texture": 0 + }, + { + "type": "gradient", + "gradientColors": [ + { + "c": [ + 255, + 133, + 65 + ], + "isCustom": false + } + ], + "opacity": 0.6, + "rotation": 45, + "texture": null + }, + { + "type": "gradient", + "gradientColors": [], + "opacity": 0.5, + "rotation": 45, + "texture": null + } + ] +} \ No newline at end of file diff --git a/test/restclient/04_delete_rice.http b/test/restclient/04_delete_rice.http index 8489dd9..f157d94 100644 --- a/test/restclient/04_delete_rice.http +++ b/test/restclient/04_delete_rice.http @@ -3,4 +3,4 @@ @previous_token = 03fbfdb4-d3a5-4d64-8740-feac7d32e7a8 DELETE {{baseUrl}}/rices/{{previous_slug}} -x-rices-token: {{previous_token}} +x-zen-rices-token: {{previous_token}}