From f05fd437aa7b588f120ae56943d70adb81c1d90b Mon Sep 17 00:00:00 2001 From: compilando Date: Wed, 26 Feb 2025 17:36:53 +0100 Subject: [PATCH] spaces storage --- sql/ddl_1.0.0.sql | 44 +++-- src/shared/rices.controller.ts | 13 +- src/shared/shared.module.ts | 11 +- src/shared/shared.service.ts | 170 +++++++++++------- src/shared/spaces.controller.ts | 59 ++++++ src/supabase/supabase.service.ts | 3 +- test/restclient/02_download_rice.http | 5 - test/restclient/04_delete_rice.http | 6 - .../restclient/{ => rice}/01_create_rice.http | 0 .../01b_create_rice_same_token.http | 0 test/restclient/rice/02_download_rice.http | 5 + .../restclient/{ => rice}/03_update_rice.http | 4 +- test/restclient/rice/04_delete_rice.http | 6 + test/restclient/spaces/01_create_space.http | 30 ++++ 14 files changed, 256 insertions(+), 100 deletions(-) create mode 100644 src/shared/spaces.controller.ts delete mode 100644 test/restclient/02_download_rice.http delete mode 100644 test/restclient/04_delete_rice.http rename test/restclient/{ => rice}/01_create_rice.http (100%) rename test/restclient/{ => rice}/01b_create_rice_same_token.http (100%) create mode 100644 test/restclient/rice/02_download_rice.http rename test/restclient/{ => rice}/03_update_rice.http (95%) create mode 100644 test/restclient/rice/04_delete_rice.http create mode 100644 test/restclient/spaces/01_create_space.http diff --git a/sql/ddl_1.0.0.sql b/sql/ddl_1.0.0.sql index 7a2a44e..39e42ee 100644 --- a/sql/ddl_1.0.0.sql +++ b/sql/ddl_1.0.0.sql @@ -1,22 +1,32 @@ ---DROP TABLE IF EXISTS rices; +-- Drop tables if they exist +DROP TABLE IF EXISTS shared; +DROP TABLE IF EXISTS shared_types; -CREATE TABLE shared ( - id UUID NOT NULL, -- Unique identifier - slug VARCHAR(75) NOT NULL, -- Unique user-friendly identifier - type INTEGER DEFAULT 0 NOT NULL, -- Type: 1-WORKSPACE 2-RICE - version VARCHAR(10) NOT NULL, -- Data version - os VARCHAR(30) NOT NULL, -- Operating system - 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) - created_at TIMESTAMP DEFAULT NOW(), -- Creation date - updated_at TIMESTAMP, -- Last update date - PRIMARY KEY (id), -- Composite primary key - UNIQUE (slug) -- Ensure slug is unique +-- Create table for shared types +CREATE TABLE shared_types ( + key VARCHAR(50) PRIMARY KEY -- Type key (e.g., 'WORKSPACE', 'RICE') ); +-- Create table for shared items +CREATE TABLE shared ( + id UUID NOT NULL PRIMARY KEY, -- Unique identifier + slug VARCHAR(75) NOT NULL UNIQUE, -- Unique user-friendly identifier + type VARCHAR(15) NOT NULL REFERENCES shared_types(key) ON DELETE CASCADE, -- Foreign key to shared_types + version VARCHAR(10) NOT NULL, -- Data version + os VARCHAR(30) NOT NULL, -- Operating system + name VARCHAR(75) NOT NULL, -- Name of the rice + author VARCHAR(100) NOT NULL, -- Name of the author + 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) + created_at TIMESTAMP DEFAULT NOW(), -- Creation date + updated_at TIMESTAMP -- Last update date +); + +-- Insert default types +INSERT INTO shared_types (key) VALUES ('WORKSPACE'), ('RICE'); + +-- Create function to increment visit count CREATE OR REPLACE FUNCTION increment_visits(slug_param TEXT) RETURNS VOID AS $$ BEGIN @@ -24,4 +34,4 @@ BEGIN SET visits = visits + 1 WHERE slug = slug_param; END; -$$ LANGUAGE plpgsql; \ No newline at end of file +$$ LANGUAGE plpgsql; diff --git a/src/shared/rices.controller.ts b/src/shared/rices.controller.ts index a4a13a2..d7eabab 100644 --- a/src/shared/rices.controller.ts +++ b/src/shared/rices.controller.ts @@ -16,11 +16,12 @@ import { import { Response } from 'express'; import { SharedService } from './shared.service'; import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from '@nestjs/swagger'; +import { SHARED_TYPES } from './shared.module'; @ApiTags('rices') @Controller('rices') export class RicesController { - constructor(private readonly sharedService: SharedService) {} + constructor(private readonly sharedService: SharedService) { } @ApiOperation({ summary: 'Upload a new Rice' }) @ApiResponse({ status: 201, description: 'Rice successfully created.' }) @@ -50,7 +51,7 @@ export class RicesController { this.validateFileSize(contentString); // Validate file size - return this.sharedService.create(contentString, token, headers); + return this.sharedService.create(SHARED_TYPES.RICE, contentString, token, headers); } @ApiOperation({ summary: 'Get information about a Rice' }) @@ -60,7 +61,7 @@ export class RicesController { }) @Get(':slug') async getRice(@Param('slug') slug: string, @Res() res: Response) { - const riceMetadata = await this.sharedService.getRiceMetadata(slug); + const riceMetadata = await this.sharedService.getRiceMetadata(SHARED_TYPES.RICE, slug); const htmlContent = ` @@ -120,7 +121,7 @@ export class RicesController { this.validateFileSize(contentString); // Validate file size - return this.sharedService.update(slug, token, contentString, headers); + return this.sharedService.update(SHARED_TYPES.RICE, slug, token, contentString, headers); } @ApiOperation({ summary: 'Delete an existing Rice' }) @@ -131,7 +132,7 @@ export class RicesController { @Param('slug') slug: string, @Headers('x-zen-shared-token') token: string, ) { - await this.sharedService.remove(slug, token); + await this.sharedService.remove(SHARED_TYPES.RICE, slug, token); return; } @@ -150,7 +151,7 @@ export class RicesController { if (moderationSecret !== process.env.MODERATION_SECRET) { throw new UnauthorizedException('Invalid moderation secret'); } - await this.sharedService.moderateRemove(slug); + await this.sharedService.moderateRemove(SHARED_TYPES.RICE, slug); return; } diff --git a/src/shared/shared.module.ts b/src/shared/shared.module.ts index 61cc37b..dff4a49 100644 --- a/src/shared/shared.module.ts +++ b/src/shared/shared.module.ts @@ -4,12 +4,19 @@ import { Module } from '@nestjs/common'; import { SharedService } from './shared.service'; import { GitHubModule } from '../github/github.module'; import { RicesController } from './rices.controller'; +import { SpacesController } from './spaces.controller'; import { SupabaseService } from '../supabase/supabase.service'; +export const SHARED_TYPES = { + WORKSPACE: "WORKSPACE", + RICE: "RICE", +}; + + @Module({ imports: [GitHubModule], providers: [SharedService, SupabaseService], - controllers: [RicesController], + controllers: [RicesController, SpacesController], exports: [SharedService], }) -export class SharedModule {} +export class SharedModule { } diff --git a/src/shared/shared.service.ts b/src/shared/shared.service.ts index 41347b1..2e548b6 100644 --- a/src/shared/shared.service.ts +++ b/src/shared/shared.service.ts @@ -13,6 +13,7 @@ import { generateSlug } from './utils/slug.util'; import { ConfigService } from '@nestjs/config'; import { GitHubService } from '../github/github.service'; import { SupabaseService } from '../supabase/supabase.service'; +import { SHARED_TYPES } from './shared.module'; const userAgentRegex = /ZenBrowser\/(\d+\.\d\w?\.\d) \((.+)\)/; @@ -22,9 +23,10 @@ export class SharedService { private readonly gitHubService: GitHubService, private readonly supabaseService: SupabaseService, private readonly configService: ConfigService, - ) {} + ) { } async create( + type: string, content: string, token: string | null, headers: Record, @@ -36,7 +38,7 @@ export class SharedService { const userAgent = headers['user-agent']; if (!name || !author || !userAgent) { - throw new BadRequestException('Rice name and author are required!'); + throw new BadRequestException('shared name and author are required!'); } // Validate content @@ -45,14 +47,19 @@ export class SharedService { } try { - this.validateJsonStructure(content); + if (type == SHARED_TYPES.RICE) { + this.validateRicesJsonStructure(content); + } + else if (type == SHARED_TYPES.WORKSPACE) { + this.validateWorkspaceJsonStructure(content); + } content = this.sanitizeJson(content); content = this.minimizeJson(content); // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { - throw new BadRequestException('Invalid json request'); + throw new BadRequestException(e); } // Validate lengths @@ -106,29 +113,36 @@ export class SharedService { throw new BadRequestException(`Invalid name provided`); } + if (!type) { + throw new BadRequestException(`Invalid type provided`); + } + if (!token) { token = uuidv4(); } else { - const tokenMaxCount = this.configService.get( - 'MAX_RICES_BY_TOKEN', - 5, - ); - const tokenCount = await this.supabaseService.countSharedByToken(token); - if (tokenCount >= tokenMaxCount) { - throw new ConflictException( - `The token '${token}' is already associated with 5 or more rices.`, + if (type == SHARED_TYPES.RICE) { + const tokenMaxCount = this.configService.get( + 'MAX_RICES_BY_TOKEN', + 5, ); + const tokenCount = await this.supabaseService.countSharedByToken(token); + if (tokenCount >= tokenMaxCount) { + throw new ConflictException( + `The token '${token}' is already associated with 5 or more rices.`, + ); + } } } const metadata = { id: uuidv4(), - token, - name, - author, - version, - os, - slug, + slug: slug, + type: type, + version: version, + os: os, + name: name, + author: author, + token: token, visits: 0, level: 0, created_at: new Date().toISOString(), @@ -137,11 +151,11 @@ export class SharedService { // Insert metadata into Supabase await this.supabaseService.insertShared(metadata); - const uploadedFilePath = `rices/${slug}/data.zenrice`; + const uploadedFilePath = this.getSharedFilePath(type, slug); await this.gitHubService.createOrUpdateFile( uploadedFilePath, content, - `Add content to rice ${slug}`, + `Add content to shared ${slug}`, ); return { slug, token }; @@ -151,17 +165,40 @@ export class SharedService { } } - async findOne(slug: string) { + getShortSharedFilePath(type: string, slug: string) { + switch (type) { + case SHARED_TYPES.WORKSPACE: + return `spaces/${slug}`; + case SHARED_TYPES.RICE: + return `rices/${slug}`; + default: + throw new Error(`Unknown shared type: ${type}`); + } + } + + getSharedFilePath(type: string, slug: string) { + switch (type) { + case SHARED_TYPES.WORKSPACE: + return `spaces/${slug}/data.zenspace`; + case SHARED_TYPES.RICE: + return `rices/${slug}/data.zenrice`; + default: + throw new Error(`Unknown shared type: ${type}`); + } + } + + + async findOne(type: string, slug: string) { // Check if the rice exists in the database - const rice = await this.supabaseService.getSharedBySlug(slug); - if (!rice) throw new NotFoundException('Rice not found'); + const shared = await this.supabaseService.getSharedBySlug(type, slug); + if (!shared) throw new NotFoundException('shared not found'); // Fetch the file from GitHub - const filePath = `rices/${slug}/data.zenrice`; + const filePath = this.getSharedFilePath(type, slug); const fileContent = await this.gitHubService.getFileContent(filePath); if (!fileContent) { - throw new NotFoundException('Rice file not found in GitHub'); + throw new NotFoundException('Shared file not found in GitHub'); } // Remove unescaped double quotes at the beginning and end, if present @@ -170,14 +207,15 @@ export class SharedService { return fileContent; } - async getRiceMetadata(slug: string) { - const rice = await this.supabaseService.getSharedBySlug(slug); - if (!rice) throw new NotFoundException('Rice not found'); + async getRiceMetadata(type: string, slug: string) { + const shared = await this.supabaseService.getSharedBySlug(type, slug); + if (!shared) throw new NotFoundException('Shared not found'); - return rice; + return shared; } async update( + type: string, slug: string, token: string, content: string, @@ -203,7 +241,12 @@ export class SharedService { } try { - this.validateJsonStructure(content); + if (type == SHARED_TYPES.RICE) { + this.validateRicesJsonStructure(content); + } + else if (type == SHARED_TYPES.WORKSPACE) { + this.validateWorkspaceJsonStructure(content); + } content = this.sanitizeJson(content); content = this.minimizeJson(content); @@ -215,19 +258,19 @@ export class SharedService { const [, version, os] = match; - // Check if the rice exists - const rice = await this.supabaseService.getSharedBySlug(slug); - if (!rice) { - throw new NotFoundException('Rice not found'); + // Check if the shared exists + const shared = await this.supabaseService.getSharedBySlug(type, slug); + if (!shared) { + throw new NotFoundException('Shared not found'); } // Validate token, name, and author match the existing record - if (rice.token !== token) { + if (shared.token !== token) { throw new UnauthorizedException('Invalid token.'); } const updatedMetadata = { - ...rice, + ...shared, version, os, updated_at: new Date().toISOString(), @@ -235,33 +278,33 @@ export class SharedService { await this.supabaseService.updateShared(slug, updatedMetadata); - const uploadedFilePath = `rices/${slug}/data.zenrice`; + const uploadedFilePath = this.getSharedFilePath(type, slug); await this.gitHubService.createOrUpdateFile( uploadedFilePath, content, - `Update content in rice ${slug}`, + `Update content in shared ${slug}`, ); - return { message: `Rice ${slug} updated successfully.` }; + return { message: `shared ${slug} updated successfully.` }; } catch (error) { console.error('Error in update method:', error); throw error; } } - async remove(slug: string, token: string): Promise { - const rice = await this.supabaseService.getSharedBySlug(slug); - if (!rice) throw new NotFoundException('Rice not found'); - if (rice.token !== token) throw new UnauthorizedException('Invalid token'); + async remove(type: string, slug: string, token: string): Promise { + const shared = await this.supabaseService.getSharedBySlug(type, slug); + if (!shared) throw new NotFoundException('shared not found'); + if (shared.token !== token) throw new UnauthorizedException('Invalid token'); // Validate token, name, and author match the existing record - if (rice.token !== token) { + if (shared.token !== token) { throw new UnauthorizedException('Invalid token.'); } await this.supabaseService.deleteShared(slug); - const folderPath = `rices/${slug}`; + const folderPath = this.getShortSharedFilePath(type, slug); // List all files in the folder const files = await this.gitHubService.listFilesInDirectory(folderPath); @@ -271,7 +314,7 @@ export class SharedService { const filePath = `${folderPath}/${file}`; await this.gitHubService.deleteFile( filePath, - `Remove file ${file} in rice ${slug}`, + `Remove file ${file} in shared ${slug}`, ); } @@ -283,36 +326,37 @@ export class SharedService { } /** - * Delete a rice without checking the user's token. + * Delete a shared without checking the user's token. * Exclusive use for moderators with the secret key. */ - public async moderateRemove(slug: string): Promise { + public async moderateRemove(type: string, slug: string): Promise { try { - // 1. Check if rice exists in Supabase - const rice = await this.supabaseService.getSharedBySlug(slug); - if (!rice) { - throw new NotFoundException('Rice not found'); + // 1. Check if shared exists in Supabase + const shared = await this.supabaseService.getSharedBySlug(type, slug); + if (!shared) { + throw new NotFoundException('shared not found'); } // 2. Delete metadata from Supabase await this.supabaseService.deleteShared(slug); // 3. Delete data.zenrice from GitHub - const riceJsonPath = `rices/${slug}/data.zenrice`; + + const jsonPath = this.getSharedFilePath(type, slug); await this.gitHubService.deleteFile( - riceJsonPath, - `[MODERATION] Remove rice ${slug}`, + jsonPath, + `[MODERATION] Remove shared ${slug}`, ); // 4. List and delete uploaded files from GitHub (if any) - const filesPath = `rices/${slug}`; + const filesPath = this.getShortSharedFilePath(type, slug); const files = await this.gitHubService.listFilesInDirectory(filesPath); for (const file of files) { - const filePath = `rices/${slug}/${file}`; + const filePath = filesPath + `/${file}`; await this.gitHubService.deleteFile( filePath, - `[MODERATION] Remove file ${file} from rice ${slug}`, + `[MODERATION] Remove file ${file} from shared ${slug}`, ); } @@ -322,15 +366,19 @@ export class SharedService { `[MODERATION] Remove folder ${filesPath}`, ); } catch (error) { - console.error('Error removing rice by moderation:', error); + console.error('Error removing shared by moderation:', error); if (error instanceof NotFoundException) { throw error; } - throw new Error('Failed to remove rice by moderation'); + throw new Error('Failed to remove shared by moderation'); } } - validateJsonStructure(jsonString: string): boolean { + validateWorkspaceJsonStructure(jsonString: string): boolean { + return true; + } + + validateRicesJsonStructure(jsonString: string): boolean { const requiredKeys: string[] = [ 'userChrome', 'userContent', diff --git a/src/shared/spaces.controller.ts b/src/shared/spaces.controller.ts new file mode 100644 index 0000000..113e10f --- /dev/null +++ b/src/shared/spaces.controller.ts @@ -0,0 +1,59 @@ +import { + Controller, + Post, + Body, + Headers, + HttpCode, + HttpStatus, + BadRequestException, +} from '@nestjs/common'; +import { SharedService } from './shared.service'; +import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from '@nestjs/swagger'; +import { SHARED_TYPES } from './shared.module'; + +@ApiTags('spaces') +@Controller('spaces') +export class SpacesController { + constructor(private readonly sharedService: SharedService) { } + + @ApiOperation({ summary: 'Shared a Space' }) + @ApiResponse({ status: 201, description: 'Space successfully shared.' }) + @ApiHeader({ + name: 'x-zen-shared-name', + description: 'Name of the Space', + required: true, + }) + @ApiHeader({ + name: 'x-zen-shared-author', + description: 'Author of the Space', + required: true, + }) + @ApiHeader({ + name: 'User-Agent', + description: 'User-Agent', + required: true, + }) + @Post() + async createSpace( + @Body() content: string, + @Headers() headers: Record, + @Headers('x-zen-shared-token') token: string, + ) { + const contentString = + typeof content === 'string' ? content : JSON.stringify(content); + + this.validateFileSize(contentString); // Validate file size + + return this.sharedService.create(SHARED_TYPES.WORKSPACE, contentString, token, headers); + } + + private validateFileSize(content: string) { + const sizeInBytes = Buffer.byteLength(content, 'utf-8'); + const maxSizeInBytes = 1 * 1024 * 512; // 1 MB + if (sizeInBytes > maxSizeInBytes) { + throw new BadRequestException( + `The uploaded content exceeds the size limit of 512 KB.`, + ); + } + } +} diff --git a/src/supabase/supabase.service.ts b/src/supabase/supabase.service.ts index fbb02b4..da22df2 100644 --- a/src/supabase/supabase.service.ts +++ b/src/supabase/supabase.service.ts @@ -44,10 +44,11 @@ export class SupabaseService { return data; } - async getSharedBySlug(slug: string) { + async getSharedBySlug(type: string, slug: string) { const { data, error } = await this.supabase .from('shared') .select('*') + .eq('type', type) .eq('slug', slug) .single(); if (error) { diff --git a/test/restclient/02_download_rice.http b/test/restclient/02_download_rice.http deleted file mode 100644 index 1517c6c..0000000 --- a/test/restclient/02_download_rice.http +++ /dev/null @@ -1,5 +0,0 @@ -@baseUrl = http://localhost:3000 -@previous_slug = cool-zenrice-test-base-0ae004db-fdca-4df6-8833-1104ac1662f6 - - -GET {{baseUrl}}/rices/{{previous_slug}} \ No newline at end of file diff --git a/test/restclient/04_delete_rice.http b/test/restclient/04_delete_rice.http deleted file mode 100644 index 39c604f..0000000 --- a/test/restclient/04_delete_rice.http +++ /dev/null @@ -1,6 +0,0 @@ -@baseUrl = http://localhost:3000 -@previous_slug = cool-zenrice-aurora2-b970a742-789c-4349-8a4d-da63c8bbe77d -@previous_token = 03fbfdb4-d3a5-4d64-8740-feac7d32e7a8 - -DELETE {{baseUrl}}/rices/{{previous_slug}} -x-zen-shared-token: {{previous_token}} diff --git a/test/restclient/01_create_rice.http b/test/restclient/rice/01_create_rice.http similarity index 100% rename from test/restclient/01_create_rice.http rename to test/restclient/rice/01_create_rice.http diff --git a/test/restclient/01b_create_rice_same_token.http b/test/restclient/rice/01b_create_rice_same_token.http similarity index 100% rename from test/restclient/01b_create_rice_same_token.http rename to test/restclient/rice/01b_create_rice_same_token.http diff --git a/test/restclient/rice/02_download_rice.http b/test/restclient/rice/02_download_rice.http new file mode 100644 index 0000000..fc77a36 --- /dev/null +++ b/test/restclient/rice/02_download_rice.http @@ -0,0 +1,5 @@ +@baseUrl = http://localhost:3000 +@previous_slug = cool-zenrice-test-base-a069a4c2-237d-433f-ab1c-38c6e6ba5244 + + +GET {{baseUrl}}/rices/{{previous_slug}} \ No newline at end of file diff --git a/test/restclient/03_update_rice.http b/test/restclient/rice/03_update_rice.http similarity index 95% rename from test/restclient/03_update_rice.http rename to test/restclient/rice/03_update_rice.http index b8e394e..b67606e 100644 --- a/test/restclient/03_update_rice.http +++ b/test/restclient/rice/03_update_rice.http @@ -1,6 +1,6 @@ @baseUrl = http://localhost:3000 -@previous_slug = cool-zenrice-test-base64211-5f874c8c-71f7-4b45-830a-aa86c9328455 -@previous_token = 84780af0-191e-4f77-8c23-25165c89d27e +@previous_slug = cool-zenrice-test-base-1d576eeb-de28-4df8-a12f-bcfc8f0e9b6e +@previous_token = 00472a9f-8a8c-423d-b4a5-7137c4cc13f6 PUT {{baseUrl}}/rices/{{previous_slug}} Content-Type: application/json diff --git a/test/restclient/rice/04_delete_rice.http b/test/restclient/rice/04_delete_rice.http new file mode 100644 index 0000000..3594c1f --- /dev/null +++ b/test/restclient/rice/04_delete_rice.http @@ -0,0 +1,6 @@ +@baseUrl = http://localhost:3000 +@previous_slug = cool-zenrice-test-base-1d576eeb-de28-4df8-a12f-bcfc8f0e9b6e +@previous_token = 00472a9f-8a8c-423d-b4a5-7137c4cc13f6 + +DELETE {{baseUrl}}/rices/{{previous_slug}} +x-zen-shared-token: {{previous_token}} diff --git a/test/restclient/spaces/01_create_space.http b/test/restclient/spaces/01_create_space.http new file mode 100644 index 0000000..7fd41e3 --- /dev/null +++ b/test/restclient/spaces/01_create_space.http @@ -0,0 +1,30 @@ +@baseUrl = http://localhost:3000 + +POST {{baseUrl}}/spaces +Content-Type: application/json +x-zen-shared-name: cool-zenrice-test-base +x-zen-shared-author: jhon@doe.com +User-Agent: ZenBrowser/1.2b.0 (EndeavourOS x86_64) + +{ + "space_id": "UNIQUE-IDENTIFIER", + "owner_id": "USER_OR_TEAM_ID", + "name": "Project X Workspace", + "description": "Workspace for Project X collaboration", + "tabs": [ + { + "url": "https://example.com", + "title": "Example Site", + "pinned": true, + "archived": false, + "metadata": { + "last_accessed": "2024-02-20T14:30:00Z", + "preview_image": "base64_thumbnail" + } + } + ], + "permissions": { + "public_sharing": false, + "edit_restrictions": "owner_only" + } +} \ No newline at end of file