spaces storage

This commit is contained in:
compilando 2025-02-26 17:36:53 +01:00
parent 079cde591e
commit f05fd437aa
14 changed files with 256 additions and 100 deletions

View file

@ -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 ( -- Create table for shared types
id UUID NOT NULL, -- Unique identifier CREATE TABLE shared_types (
slug VARCHAR(75) NOT NULL, -- Unique user-friendly identifier key VARCHAR(50) PRIMARY KEY -- Type key (e.g., 'WORKSPACE', 'RICE')
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 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) CREATE OR REPLACE FUNCTION increment_visits(slug_param TEXT)
RETURNS VOID AS $$ RETURNS VOID AS $$
BEGIN BEGIN

View file

@ -16,11 +16,12 @@ import {
import { Response } from 'express'; import { Response } from 'express';
import { SharedService } from './shared.service'; import { SharedService } from './shared.service';
import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from '@nestjs/swagger';
import { SHARED_TYPES } from './shared.module';
@ApiTags('rices') @ApiTags('rices')
@Controller('rices') @Controller('rices')
export class RicesController { export class RicesController {
constructor(private readonly sharedService: SharedService) {} constructor(private readonly sharedService: SharedService) { }
@ApiOperation({ summary: 'Upload a new Rice' }) @ApiOperation({ summary: 'Upload a new Rice' })
@ApiResponse({ status: 201, description: 'Rice successfully created.' }) @ApiResponse({ status: 201, description: 'Rice successfully created.' })
@ -50,7 +51,7 @@ export class RicesController {
this.validateFileSize(contentString); // Validate file size 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' }) @ApiOperation({ summary: 'Get information about a Rice' })
@ -60,7 +61,7 @@ export class RicesController {
}) })
@Get(':slug') @Get(':slug')
async getRice(@Param('slug') slug: string, @Res() res: Response) { 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 = `<!DOCTYPE html> const htmlContent = `<!DOCTYPE html>
<html lang="en"> <html lang="en">
@ -120,7 +121,7 @@ export class RicesController {
this.validateFileSize(contentString); // Validate file size 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' }) @ApiOperation({ summary: 'Delete an existing Rice' })
@ -131,7 +132,7 @@ export class RicesController {
@Param('slug') slug: string, @Param('slug') slug: string,
@Headers('x-zen-shared-token') token: string, @Headers('x-zen-shared-token') token: string,
) { ) {
await this.sharedService.remove(slug, token); await this.sharedService.remove(SHARED_TYPES.RICE, slug, token);
return; return;
} }
@ -150,7 +151,7 @@ export class RicesController {
if (moderationSecret !== process.env.MODERATION_SECRET) { if (moderationSecret !== process.env.MODERATION_SECRET) {
throw new UnauthorizedException('Invalid moderation secret'); throw new UnauthorizedException('Invalid moderation secret');
} }
await this.sharedService.moderateRemove(slug); await this.sharedService.moderateRemove(SHARED_TYPES.RICE, slug);
return; return;
} }

View file

@ -4,12 +4,19 @@ import { Module } from '@nestjs/common';
import { SharedService } from './shared.service'; import { SharedService } from './shared.service';
import { GitHubModule } from '../github/github.module'; import { GitHubModule } from '../github/github.module';
import { RicesController } from './rices.controller'; import { RicesController } from './rices.controller';
import { SpacesController } from './spaces.controller';
import { SupabaseService } from '../supabase/supabase.service'; import { SupabaseService } from '../supabase/supabase.service';
export const SHARED_TYPES = {
WORKSPACE: "WORKSPACE",
RICE: "RICE",
};
@Module({ @Module({
imports: [GitHubModule], imports: [GitHubModule],
providers: [SharedService, SupabaseService], providers: [SharedService, SupabaseService],
controllers: [RicesController], controllers: [RicesController, SpacesController],
exports: [SharedService], exports: [SharedService],
}) })
export class SharedModule {} export class SharedModule { }

View file

@ -13,6 +13,7 @@ import { generateSlug } from './utils/slug.util';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { GitHubService } from '../github/github.service'; import { GitHubService } from '../github/github.service';
import { SupabaseService } from '../supabase/supabase.service'; import { SupabaseService } from '../supabase/supabase.service';
import { SHARED_TYPES } from './shared.module';
const userAgentRegex = /ZenBrowser\/(\d+\.\d\w?\.\d) \((.+)\)/; const userAgentRegex = /ZenBrowser\/(\d+\.\d\w?\.\d) \((.+)\)/;
@ -22,9 +23,10 @@ export class SharedService {
private readonly gitHubService: GitHubService, private readonly gitHubService: GitHubService,
private readonly supabaseService: SupabaseService, private readonly supabaseService: SupabaseService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
) {} ) { }
async create( async create(
type: string,
content: string, content: string,
token: string | null, token: string | null,
headers: Record<string, string>, headers: Record<string, string>,
@ -36,7 +38,7 @@ export class SharedService {
const userAgent = headers['user-agent']; const userAgent = headers['user-agent'];
if (!name || !author || !userAgent) { if (!name || !author || !userAgent) {
throw new BadRequestException('Rice name and author are required!'); throw new BadRequestException('shared name and author are required!');
} }
// Validate content // Validate content
@ -45,14 +47,19 @@ export class SharedService {
} }
try { 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.sanitizeJson(content);
content = this.minimizeJson(content); content = this.minimizeJson(content);
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) { } catch (e) {
throw new BadRequestException('Invalid json request'); throw new BadRequestException(e);
} }
// Validate lengths // Validate lengths
@ -106,29 +113,36 @@ export class SharedService {
throw new BadRequestException(`Invalid name provided`); throw new BadRequestException(`Invalid name provided`);
} }
if (!type) {
throw new BadRequestException(`Invalid type provided`);
}
if (!token) { if (!token) {
token = uuidv4(); token = uuidv4();
} else { } else {
const tokenMaxCount = this.configService.get<number>( if (type == SHARED_TYPES.RICE) {
'MAX_RICES_BY_TOKEN', const tokenMaxCount = this.configService.get<number>(
5, '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 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 = { const metadata = {
id: uuidv4(), id: uuidv4(),
token, slug: slug,
name, type: type,
author, version: version,
version, os: os,
os, name: name,
slug, author: author,
token: token,
visits: 0, visits: 0,
level: 0, level: 0,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
@ -137,11 +151,11 @@ export class SharedService {
// Insert metadata into Supabase // Insert metadata into Supabase
await this.supabaseService.insertShared(metadata); await this.supabaseService.insertShared(metadata);
const uploadedFilePath = `rices/${slug}/data.zenrice`; const uploadedFilePath = this.getSharedFilePath(type, slug);
await this.gitHubService.createOrUpdateFile( await this.gitHubService.createOrUpdateFile(
uploadedFilePath, uploadedFilePath,
content, content,
`Add content to rice ${slug}`, `Add content to shared ${slug}`,
); );
return { slug, token }; 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 // Check if the rice exists in the database
const rice = await this.supabaseService.getSharedBySlug(slug); const shared = await this.supabaseService.getSharedBySlug(type, slug);
if (!rice) throw new NotFoundException('Rice not found'); if (!shared) throw new NotFoundException('shared not found');
// Fetch the file from GitHub // Fetch the file from GitHub
const filePath = `rices/${slug}/data.zenrice`; const filePath = this.getSharedFilePath(type, slug);
const fileContent = await this.gitHubService.getFileContent(filePath); const fileContent = await this.gitHubService.getFileContent(filePath);
if (!fileContent) { 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 // Remove unescaped double quotes at the beginning and end, if present
@ -170,14 +207,15 @@ export class SharedService {
return fileContent; return fileContent;
} }
async getRiceMetadata(slug: string) { async getRiceMetadata(type: string, slug: string) {
const rice = await this.supabaseService.getSharedBySlug(slug); const shared = await this.supabaseService.getSharedBySlug(type, slug);
if (!rice) throw new NotFoundException('Rice not found'); if (!shared) throw new NotFoundException('Shared not found');
return rice; return shared;
} }
async update( async update(
type: string,
slug: string, slug: string,
token: string, token: string,
content: string, content: string,
@ -203,7 +241,12 @@ export class SharedService {
} }
try { 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.sanitizeJson(content);
content = this.minimizeJson(content); content = this.minimizeJson(content);
@ -215,19 +258,19 @@ export class SharedService {
const [, version, os] = match; const [, version, os] = match;
// Check if the rice exists // Check if the shared exists
const rice = await this.supabaseService.getSharedBySlug(slug); const shared = await this.supabaseService.getSharedBySlug(type, slug);
if (!rice) { if (!shared) {
throw new NotFoundException('Rice not found'); throw new NotFoundException('Shared not found');
} }
// Validate token, name, and author match the existing record // Validate token, name, and author match the existing record
if (rice.token !== token) { if (shared.token !== token) {
throw new UnauthorizedException('Invalid token.'); throw new UnauthorizedException('Invalid token.');
} }
const updatedMetadata = { const updatedMetadata = {
...rice, ...shared,
version, version,
os, os,
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
@ -235,33 +278,33 @@ export class SharedService {
await this.supabaseService.updateShared(slug, updatedMetadata); await this.supabaseService.updateShared(slug, updatedMetadata);
const uploadedFilePath = `rices/${slug}/data.zenrice`; const uploadedFilePath = this.getSharedFilePath(type, slug);
await this.gitHubService.createOrUpdateFile( await this.gitHubService.createOrUpdateFile(
uploadedFilePath, uploadedFilePath,
content, 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) { } catch (error) {
console.error('Error in update method:', error); console.error('Error in update method:', error);
throw error; throw error;
} }
} }
async remove(slug: string, token: string): Promise<void> { async remove(type: string, slug: string, token: string): Promise<void> {
const rice = await this.supabaseService.getSharedBySlug(slug); const shared = await this.supabaseService.getSharedBySlug(type, slug);
if (!rice) throw new NotFoundException('Rice not found'); if (!shared) throw new NotFoundException('shared not found');
if (rice.token !== token) throw new UnauthorizedException('Invalid token'); if (shared.token !== token) throw new UnauthorizedException('Invalid token');
// Validate token, name, and author match the existing record // Validate token, name, and author match the existing record
if (rice.token !== token) { if (shared.token !== token) {
throw new UnauthorizedException('Invalid token.'); throw new UnauthorizedException('Invalid token.');
} }
await this.supabaseService.deleteShared(slug); await this.supabaseService.deleteShared(slug);
const folderPath = `rices/${slug}`; const folderPath = this.getShortSharedFilePath(type, slug);
// List all files in the folder // List all files in the folder
const files = await this.gitHubService.listFilesInDirectory(folderPath); const files = await this.gitHubService.listFilesInDirectory(folderPath);
@ -271,7 +314,7 @@ export class SharedService {
const filePath = `${folderPath}/${file}`; const filePath = `${folderPath}/${file}`;
await this.gitHubService.deleteFile( await this.gitHubService.deleteFile(
filePath, 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. * Exclusive use for moderators with the secret key.
*/ */
public async moderateRemove(slug: string): Promise<void> { public async moderateRemove(type: string, slug: string): Promise<void> {
try { try {
// 1. Check if rice exists in Supabase // 1. Check if shared exists in Supabase
const rice = await this.supabaseService.getSharedBySlug(slug); const shared = await this.supabaseService.getSharedBySlug(type, slug);
if (!rice) { if (!shared) {
throw new NotFoundException('Rice not found'); throw new NotFoundException('shared not found');
} }
// 2. Delete metadata from Supabase // 2. Delete metadata from Supabase
await this.supabaseService.deleteShared(slug); await this.supabaseService.deleteShared(slug);
// 3. Delete data.zenrice from GitHub // 3. Delete data.zenrice from GitHub
const riceJsonPath = `rices/${slug}/data.zenrice`;
const jsonPath = this.getSharedFilePath(type, slug);
await this.gitHubService.deleteFile( await this.gitHubService.deleteFile(
riceJsonPath, jsonPath,
`[MODERATION] Remove rice ${slug}`, `[MODERATION] Remove shared ${slug}`,
); );
// 4. List and delete uploaded files from GitHub (if any) // 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); const files = await this.gitHubService.listFilesInDirectory(filesPath);
for (const file of files) { for (const file of files) {
const filePath = `rices/${slug}/${file}`; const filePath = filesPath + `/${file}`;
await this.gitHubService.deleteFile( await this.gitHubService.deleteFile(
filePath, 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}`, `[MODERATION] Remove folder ${filesPath}`,
); );
} catch (error) { } catch (error) {
console.error('Error removing rice by moderation:', error); console.error('Error removing shared by moderation:', error);
if (error instanceof NotFoundException) { if (error instanceof NotFoundException) {
throw error; 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[] = [ const requiredKeys: string[] = [
'userChrome', 'userChrome',
'userContent', 'userContent',

View file

@ -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<string, string>,
@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.`,
);
}
}
}

View file

@ -44,10 +44,11 @@ export class SupabaseService {
return data; return data;
} }
async getSharedBySlug(slug: string) { async getSharedBySlug(type: string, slug: string) {
const { data, error } = await this.supabase const { data, error } = await this.supabase
.from('shared') .from('shared')
.select('*') .select('*')
.eq('type', type)
.eq('slug', slug) .eq('slug', slug)
.single(); .single();
if (error) { if (error) {

View file

@ -1,5 +0,0 @@
@baseUrl = http://localhost:3000
@previous_slug = cool-zenrice-test-base-0ae004db-fdca-4df6-8833-1104ac1662f6
GET {{baseUrl}}/rices/{{previous_slug}}

View file

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

View file

@ -0,0 +1,5 @@
@baseUrl = http://localhost:3000
@previous_slug = cool-zenrice-test-base-a069a4c2-237d-433f-ab1c-38c6e6ba5244
GET {{baseUrl}}/rices/{{previous_slug}}

View file

@ -1,6 +1,6 @@
@baseUrl = http://localhost:3000 @baseUrl = http://localhost:3000
@previous_slug = cool-zenrice-test-base64211-5f874c8c-71f7-4b45-830a-aa86c9328455 @previous_slug = cool-zenrice-test-base-1d576eeb-de28-4df8-a12f-bcfc8f0e9b6e
@previous_token = 84780af0-191e-4f77-8c23-25165c89d27e @previous_token = 00472a9f-8a8c-423d-b4a5-7137c4cc13f6
PUT {{baseUrl}}/rices/{{previous_slug}} PUT {{baseUrl}}/rices/{{previous_slug}}
Content-Type: application/json Content-Type: application/json

View file

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

View file

@ -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"
}
}