import { Injectable, NotFoundException, UnauthorizedException, ConflictException, BadRequestException, } from '@nestjs/common'; import xss from 'xss'; import { minify } from 'csso'; import { v4 as uuidv4 } from 'uuid'; import { generateSlug } from './utils/slug.util'; import { ConfigService } from '@nestjs/config'; import { GitHubService } from '../github/github.service'; import { SupabaseService } from '../supabase/supabase.service'; const userAgentRegex = /ZenBrowser\/(\d+\.\d\w?\.\d) \((.+)\)/; @Injectable() export class SharedService { constructor( private readonly gitHubService: GitHubService, private readonly supabaseService: SupabaseService, private readonly configService: ConfigService, ) {} async create( content: string, token: string | null, headers: Record, ) { try { // Validate headers const name = headers['x-zen-shared-name']; const author = headers['x-zen-shared-author']; const userAgent = headers['user-agent']; if (!name || !author || !userAgent) { throw new BadRequestException('Rice name and author are required!'); } // Validate content if (typeof content !== 'string') { throw new BadRequestException('The request body must be a string.'); } try { this.validateJsonStructure(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'); } // Validate lengths if (name.length > 75) { throw new BadRequestException( `The value of x-zen-shared-name exceeds the maximum allowed length of 75 characters.`, ); } if (author.length > 100) { throw new BadRequestException( `The value of x-zen-shared-author exceeds the maximum allowed length of 100 characters.`, ); } // Parse version and OS from User-Agent const match = userAgent.match(userAgentRegex); if (!match) { throw new BadRequestException('Invalid request'); } 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(name); if (existingRice) { throw new ConflictException( `A rice with the name '${name}' already exists.`, ); }*/ let slug: string; try { slug = `${generateSlug(name)}-${uuidv4()}`; // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { // If generateSlug throws an error, rethrow as a BadRequestException throw new BadRequestException(`Invalid name 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.`, ); } } const metadata = { id: uuidv4(), token, name, author, version, os, slug, visits: 0, level: 0, created_at: new Date().toISOString(), }; // Insert metadata into Supabase await this.supabaseService.insertShared(metadata); const uploadedFilePath = `rices/${slug}/data.zenrice`; await this.gitHubService.createOrUpdateFile( uploadedFilePath, content, `Add content to rice ${slug}`, ); return { slug, token }; } catch (error) { console.error('Error in create method:', error); throw error; } } async findOne(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'); // Fetch the file from GitHub const filePath = `rices/${slug}/data.zenrice`; const fileContent = await this.gitHubService.getFileContent(filePath); if (!fileContent) { throw new NotFoundException('Rice file not found in GitHub'); } // Remove unescaped double quotes at the beginning and end, if present // const content = contentPrev.replace(/^"|"$/g, ''); return fileContent; } async getRiceMetadata(slug: string) { const rice = await this.supabaseService.getSharedBySlug(slug); if (!rice) throw new NotFoundException('Rice not found'); return rice; } async update( slug: string, token: string, content: string, headers: Record, ) { try { // Extract fields from headers const userAgent = headers['user-agent']; if (!userAgent) { throw new BadRequestException( 'Missing required headers: User-Agent is mandatory.', ); } // Parse version and OS from User-Agent // It must have the following format: // example version: 1.0.2-b.1 const match = userAgent.match(userAgentRegex); if (!match) { throw new BadRequestException('Invalid request'); } try { this.validateJsonStructure(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'); } const [, version, os] = match; // Check if the rice exists const rice = await this.supabaseService.getSharedBySlug(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.'); } const updatedMetadata = { ...rice, version, os, updated_at: new Date().toISOString(), }; await this.supabaseService.updateShared(slug, updatedMetadata); const uploadedFilePath = `rices/${slug}/data.zenrice`; await this.gitHubService.createOrUpdateFile( uploadedFilePath, content, `Update content in rice ${slug}`, ); return { message: `Rice ${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'); // Validate token, name, and author match the existing record if (rice.token !== token) { throw new UnauthorizedException('Invalid token.'); } await this.supabaseService.deleteShared(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}`, ); } /** * Delete a rice without checking the user's token. * Exclusive use for moderators with the secret key. */ public async moderateRemove(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'); } // 2. Delete metadata from Supabase await this.supabaseService.deleteShared(slug); // 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 filesPath = `rices/${slug}`; const files = await this.gitHubService.listFilesInDirectory(filesPath); for (const file of files) { 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) { throw error; } throw new Error('Failed to remove rice by moderation'); } } validateJsonStructure(jsonString: string): boolean { const requiredKeys: string[] = [ 'userChrome', 'userContent', 'enabledMods', 'preferences', 'workspaceThemes', ]; let json: Record; // Validate JSON string try { json = JSON.parse(jsonString); } catch { throw new BadRequestException('Invalid JSON string.'); } // Ensure the parsed JSON is an object if (typeof json !== 'object' || json === null) { throw new BadRequestException('The parsed JSON is not a valid object.'); } // Check for missing keys const missingKeys = requiredKeys.filter((key) => !(key in json)); if (missingKeys.length > 0) { throw new BadRequestException( `The JSON is missing the following required keys: ${missingKeys.join(', ')}`, ); } return true; } // Método para minificar los campos CSS private minimizeJson(jsonString: string): string { const json = JSON.parse(jsonString); ['userChrome', 'userContent'].forEach((key) => { if (json[key] && typeof json[key] === 'string') { json[key] = minify(json[key]).css; } }); return JSON.stringify(json); } private sanitizeJson(jsonString: string): string { const json = JSON.parse(jsonString); const sanitizedJson = Object.keys(json).reduce( (acc, key) => { const value = json[key]; if (typeof value === 'string') { acc[key] = xss(value); // Limpia las cadenas de texto } else if (typeof value === 'object' && value !== null) { acc[key] = JSON.parse(this.sanitizeJson(JSON.stringify(value))); // Recursión para objetos anidados } else { acc[key] = value; // Otros tipos permanecen igual } return acc; }, {} as Record, ); return JSON.stringify(sanitizedJson); } }