mirror of
https://github.com/zen-browser/rices.git
synced 2025-07-08 01:10:12 +02:00
shared refactor
This commit is contained in:
parent
49b7ba2c78
commit
079cde591e
16 changed files with 6592 additions and 90 deletions
401
src/shared/shared.service.ts
Normal file
401
src/shared/shared.service.ts
Normal file
|
@ -0,0 +1,401 @@
|
|||
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);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue