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 (
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;
$$ LANGUAGE plpgsql;

View file

@ -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 = `<!DOCTYPE html>
<html lang="en">
@ -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;
}

View file

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

View file

@ -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<string, string>,
@ -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<number>(
'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<number>(
'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<void> {
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<void> {
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<void> {
public async moderateRemove(type: string, slug: string): Promise<void> {
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',

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

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

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