rices/src/shared/shared.service.ts
2025-02-25 20:30:00 +01:00

401 lines
11 KiB
TypeScript

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<string, string>,
) {
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<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,
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<string, string>,
) {
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<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');
// 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<void> {
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<string, unknown>;
// 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<string, unknown>,
);
return JSON.stringify(sanitizedJson);
}
}