mirror of
https://github.com/zen-browser/rices.git
synced 2025-07-07 17:05:40 +02:00
feat: Add "version" and "os" fields to rice database
- The rice database now includes new fields: - `version`: Represents the version of the rice entry. - `os`: Represents the operating system associated with the rice entry. - These fields are required for all new rice entries. refactor: Stop uploading rice.json to GitHub - The `rice.json` file is no longer uploaded to GitHub during rice creation or updates. - This reduces redundancy as all metadata is now managed directly in the database (Supabase). fix: Improve exception handling with proper HTTP status codes - Enhanced exception handling to align with standard HTTP status codes: - `BadRequestException` for validation errors. - `ConflictException` for duplicate entries. - `NotFoundException` for missing resources. - Generic `InternalServerErrorException` for unexpected errors. - This ensures the API returns meaningful and accurate responses. feat: Enhance rice download to act as a standard HTTP GET - The `findOne` method now returns the raw content of the rice file directly as the response body. - Removes unnecessary JSON wrappers, allowing the endpoint to behave like a typical HTTP GET request. - Improved usability for clients consuming the API.
This commit is contained in:
parent
def257b9ba
commit
d44ea66b40
11 changed files with 215 additions and 80 deletions
|
@ -1,7 +1,9 @@
|
||||||
-- DROP TABLE IF EXISTS rices;
|
--DROP TABLE IF EXISTS rices;
|
||||||
|
|
||||||
CREATE TABLE rices (
|
CREATE TABLE rices (
|
||||||
id UUID NOT NULL, -- Unique identifier
|
id UUID NOT NULL, -- Unique identifier
|
||||||
|
version VARCHAR(10) NOT NULL, -- Data version
|
||||||
|
os VARCHAR(30) NOT NULL, -- Operating system
|
||||||
slug VARCHAR(75) NOT NULL, -- Unique user-friendly identifier
|
slug VARCHAR(75) NOT NULL, -- Unique user-friendly identifier
|
||||||
name VARCHAR(75) NOT NULL, -- Name of the rice
|
name VARCHAR(75) NOT NULL, -- Name of the rice
|
||||||
token UUID NOT NULL, -- Unique authorization token
|
token UUID NOT NULL, -- Unique authorization token
|
||||||
|
@ -14,7 +16,6 @@ CREATE TABLE rices (
|
||||||
UNIQUE (name) -- Ensure name is unique
|
UNIQUE (name) -- Ensure name is unique
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
24
src/filters/http-exception.filter.ts
Normal file
24
src/filters/http-exception.filter.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import {
|
||||||
|
ExceptionFilter,
|
||||||
|
Catch,
|
||||||
|
ArgumentsHost,
|
||||||
|
HttpException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
@Catch(HttpException)
|
||||||
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
|
catch(exception: HttpException, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
const status = exception.getStatus();
|
||||||
|
const exceptionResponse = exception.getResponse();
|
||||||
|
|
||||||
|
response.status(status).json({
|
||||||
|
statusCode: status,
|
||||||
|
...(typeof exceptionResponse === 'string'
|
||||||
|
? { message: exceptionResponse }
|
||||||
|
: exceptionResponse),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -336,6 +336,67 @@ export class GitHubService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an empty folder from the repository.
|
||||||
|
* Assumes the folder is already empty.
|
||||||
|
* @param folderPath Path of the folder in the repository.
|
||||||
|
* @param commitMessage Commit message for the deletion.
|
||||||
|
*/
|
||||||
|
async deleteFolder(folderPath: string, commitMessage: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// GitHub does not support direct folder deletion; instead, ensure no files remain
|
||||||
|
const files = await this.listFilesInDirectory(folderPath);
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
throw new Error(`Folder ${folderPath} is not empty. Cannot delete.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitHub API requires at least a dummy file like .gitkeep to keep directories
|
||||||
|
const gitkeepPath = `${folderPath}/.gitkeep`;
|
||||||
|
try {
|
||||||
|
const { data: existingFile } = await this.octokit.repos.getContent({
|
||||||
|
owner: this.repoOwner,
|
||||||
|
repo: this.repoName,
|
||||||
|
path: gitkeepPath,
|
||||||
|
ref: this.defaultBranch,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!('sha' in existingFile)) {
|
||||||
|
throw new Error(
|
||||||
|
`The .gitkeep file in ${folderPath} does not have a valid SHA`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha = existingFile.sha;
|
||||||
|
|
||||||
|
// Delete the .gitkeep file
|
||||||
|
await this.octokit.repos.deleteFile({
|
||||||
|
owner: this.repoOwner,
|
||||||
|
repo: this.repoName,
|
||||||
|
path: gitkeepPath,
|
||||||
|
message: commitMessage,
|
||||||
|
sha,
|
||||||
|
branch: this.defaultBranch,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Folder ${folderPath} deleted successfully.`);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (isOctokitResponseError(error) && error.status === 404) {
|
||||||
|
this.logger.warn(
|
||||||
|
`The .gitkeep file in ${folderPath} does not exist.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error deleting folder ${folderPath}: ${error}`);
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
`Failed to delete folder: ${error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears all files in the GitHub repository.
|
* Clears all files in the GitHub repository.
|
||||||
* Useful for cleaning the state before running tests.
|
* Useful for cleaning the state before running tests.
|
||||||
|
|
|
@ -3,11 +3,13 @@ import { AppModule } from './app.module';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { ThrottlerExceptionFilter } from './common/filters/throttler-exception.filter';
|
import { ThrottlerExceptionFilter } from './common/filters/throttler-exception.filter';
|
||||||
|
import { HttpExceptionFilter } from './filters/http-exception.filter';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
||||||
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
app.useGlobalFilters(new ThrottlerExceptionFilter());
|
app.useGlobalFilters(new ThrottlerExceptionFilter());
|
||||||
|
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
|
|
|
@ -4,6 +4,12 @@ export class CreateRiceDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
version!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
os!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
content!: string;
|
content!: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ import {
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CreateRiceDto } from './dto/create-rice.dto';
|
import { CreateRiceDto } from './dto/create-rice.dto';
|
||||||
import { UpdateRiceDto } from './dto/update-rice.dto';
|
import { UpdateRiceDto } from './dto/update-rice.dto';
|
||||||
|
@ -19,56 +20,76 @@ export class RicesService {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(createRiceDto: CreateRiceDto) {
|
async create(createRiceDto: CreateRiceDto) {
|
||||||
// Check if a rice with the same name already exists
|
try {
|
||||||
const existingRice = await this.supabaseService.getRiceByName(
|
// Ensure required fields are present
|
||||||
createRiceDto.name,
|
if (!createRiceDto.name || !createRiceDto.version || !createRiceDto.os) {
|
||||||
);
|
throw new BadRequestException(
|
||||||
if (existingRice) {
|
'Missing required fields: name, version, and os are mandatory.',
|
||||||
throw new ConflictException(
|
);
|
||||||
`A rice with the name '${createRiceDto.name}' already exists.`,
|
}
|
||||||
|
|
||||||
|
// Check if a rice with the same name already exists
|
||||||
|
const existingRice = await this.supabaseService.getRiceByName(
|
||||||
|
createRiceDto.name,
|
||||||
);
|
);
|
||||||
|
if (existingRice) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`A rice with the name '${createRiceDto.name}' already exists.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = createRiceDto.name
|
||||||
|
? `${generateSlug(createRiceDto.name)}-${uuidv4()}`
|
||||||
|
: uuidv4();
|
||||||
|
|
||||||
|
const token = uuidv4();
|
||||||
|
|
||||||
|
const encodedContent = Buffer.from(
|
||||||
|
JSON.stringify(createRiceDto.content),
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
id: uuidv4(),
|
||||||
|
token,
|
||||||
|
name: createRiceDto.name,
|
||||||
|
version: createRiceDto.version,
|
||||||
|
os: createRiceDto.os,
|
||||||
|
slug: slug,
|
||||||
|
visits: 0,
|
||||||
|
level: 0,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insert metadata into Supabase
|
||||||
|
await this.supabaseService.insertRice(metadata);
|
||||||
|
|
||||||
|
if (createRiceDto.content) {
|
||||||
|
const uploadedFilePath = `rices/${slug}/data.zenrice`;
|
||||||
|
await this.gitHubService.createOrUpdateFile(
|
||||||
|
uploadedFilePath,
|
||||||
|
encodedContent,
|
||||||
|
`Add file createRiceDto.content to rice ${slug}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { slug, token };
|
||||||
|
} catch (error) {
|
||||||
|
// Log the error for debugging
|
||||||
|
console.error('Error in create method:', error);
|
||||||
|
|
||||||
|
if (
|
||||||
|
error instanceof ConflictException ||
|
||||||
|
error instanceof BadRequestException
|
||||||
|
) {
|
||||||
|
throw error; // Or create a custom response
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
// Extract a user-friendly message if possible, or log details
|
||||||
|
throw new Error(`Rice creation failed: ${error.message}`); // More informative error message
|
||||||
|
} else {
|
||||||
|
// Catch unexpected errors
|
||||||
|
throw new Error('Internal Server Error'); // Only for truly unexpected issues
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const slug = createRiceDto.name
|
|
||||||
? `${generateSlug(createRiceDto.name)}-${uuidv4()}`
|
|
||||||
: uuidv4();
|
|
||||||
|
|
||||||
const token = uuidv4();
|
|
||||||
|
|
||||||
const encodedContent = Buffer.from(
|
|
||||||
JSON.stringify(createRiceDto.content),
|
|
||||||
).toString('base64');
|
|
||||||
|
|
||||||
const metadata = {
|
|
||||||
id: uuidv4(),
|
|
||||||
token,
|
|
||||||
name: createRiceDto.name || null,
|
|
||||||
slug: slug,
|
|
||||||
visits: 0,
|
|
||||||
level: 0,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.supabaseService.insertRice(metadata);
|
|
||||||
|
|
||||||
const metadataContent = JSON.stringify(metadata, null, 2);
|
|
||||||
const riceJsonPath = `rices/${slug}/rice.json`;
|
|
||||||
await this.gitHubService.createOrUpdateFile(
|
|
||||||
riceJsonPath,
|
|
||||||
metadataContent,
|
|
||||||
`Add rice ${slug}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (createRiceDto.content) {
|
|
||||||
const uploadedFilePath = `rices/${slug}/data.zenrice`;
|
|
||||||
await this.gitHubService.createOrUpdateFile(
|
|
||||||
uploadedFilePath,
|
|
||||||
encodedContent,
|
|
||||||
`Add file createRiceDto.content to rice ${slug}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { slug, token };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(slug: string) {
|
async findOne(slug: string) {
|
||||||
|
@ -90,7 +111,7 @@ export class RicesService {
|
||||||
// Remove unescaped double quotes at the beginning and end, if present
|
// Remove unescaped double quotes at the beginning and end, if present
|
||||||
const content = contentPrev.replace(/^"|"$/g, '');
|
const content = contentPrev.replace(/^"|"$/g, '');
|
||||||
|
|
||||||
return { slug, content };
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(slug: string, token: string, updateRiceDto: UpdateRiceDto) {
|
async update(slug: string, token: string, updateRiceDto: UpdateRiceDto) {
|
||||||
|
@ -111,6 +132,11 @@ export class RicesService {
|
||||||
await this.supabaseService.getRiceBySlug(slug);
|
await this.supabaseService.getRiceBySlug(slug);
|
||||||
if (!rice) throw new NotFoundException('Rice not found');
|
if (!rice) throw new NotFoundException('Rice not found');
|
||||||
if (rice.token !== token) throw new UnauthorizedException('Invalid token');
|
if (rice.token !== token) throw new UnauthorizedException('Invalid token');
|
||||||
|
if (!updateRiceDto.content) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Missing required fields: content is mandatory.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const updatedMetadata = {
|
const updatedMetadata = {
|
||||||
...rice,
|
...rice,
|
||||||
|
@ -119,14 +145,6 @@ export class RicesService {
|
||||||
|
|
||||||
await this.supabaseService.updateRice(slug, updatedMetadata);
|
await this.supabaseService.updateRice(slug, updatedMetadata);
|
||||||
|
|
||||||
const metadataContent = JSON.stringify(updatedMetadata, null, 2);
|
|
||||||
const riceJsonPath = `rices/${slug}/rice.json`;
|
|
||||||
await this.gitHubService.createOrUpdateFile(
|
|
||||||
riceJsonPath,
|
|
||||||
metadataContent,
|
|
||||||
`Update rice ${slug}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (updateRiceDto.content) {
|
if (updateRiceDto.content) {
|
||||||
const encodedContent = Buffer.from(
|
const encodedContent = Buffer.from(
|
||||||
JSON.stringify(updateRiceDto.content),
|
JSON.stringify(updateRiceDto.content),
|
||||||
|
@ -150,8 +168,25 @@ export class RicesService {
|
||||||
|
|
||||||
await this.supabaseService.deleteRice(slug);
|
await this.supabaseService.deleteRice(slug);
|
||||||
|
|
||||||
const riceJsonPath = `rices/${slug}/rice.json`;
|
const folderPath = `rices/${slug}`;
|
||||||
await this.gitHubService.deleteFile(riceJsonPath, `Remove rice ${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}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -169,27 +204,30 @@ export class RicesService {
|
||||||
// 2. Delete metadata from Supabase
|
// 2. Delete metadata from Supabase
|
||||||
await this.supabaseService.deleteRice(slug);
|
await this.supabaseService.deleteRice(slug);
|
||||||
|
|
||||||
// 3. Delete rice.json from GitHub
|
// 3. Delete data.zenrice from GitHub
|
||||||
const riceJsonPath = `rices/${slug}/rice.json`;
|
const riceJsonPath = `rices/${slug}/data.zenrice`;
|
||||||
await this.gitHubService.deleteFile(
|
await this.gitHubService.deleteFile(
|
||||||
riceJsonPath,
|
riceJsonPath,
|
||||||
`[MODERATION] Remove rice ${slug}`,
|
`[MODERATION] Remove rice ${slug}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 4. List and delete uploaded files from GitHub (if any)
|
// 4. List and delete uploaded files from GitHub (if any)
|
||||||
const uploadedFilesPath = `rices/${slug}`;
|
const filesPath = `rices/${slug}`;
|
||||||
const files =
|
const files = await this.gitHubService.listFilesInDirectory(filesPath);
|
||||||
await this.gitHubService.listFilesInDirectory(uploadedFilesPath);
|
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file !== 'rice.json') {
|
const filePath = `rices/${slug}/${file}`;
|
||||||
const filePath = `rices/${slug}/${file}`;
|
await this.gitHubService.deleteFile(
|
||||||
await this.gitHubService.deleteFile(
|
filePath,
|
||||||
filePath,
|
`[MODERATION] Remove file ${file} from rice ${slug}`,
|
||||||
`[MODERATION] Remove file ${file} from rice ${slug}`,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Finally, remove the folder itself
|
||||||
|
await this.gitHubService.deleteFolder(
|
||||||
|
filesPath,
|
||||||
|
`[MODERATION] Remove folder ${filesPath}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error removing rice by moderation:', error);
|
console.error('Error removing rice by moderation:', error);
|
||||||
if (error instanceof NotFoundException) {
|
if (error instanceof NotFoundException) {
|
||||||
|
|
|
@ -55,7 +55,7 @@ export class SupabaseService {
|
||||||
`Failed to fetch rice with slug ${slug}: ${error.message}`,
|
`Failed to fetch rice with slug ${slug}: ${error.message}`,
|
||||||
error.details,
|
error.details,
|
||||||
);
|
);
|
||||||
throw new Error(`Failed to fetch rice: ${error.message}`);
|
return null;
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,9 @@ POST {{baseUrl}}/rices
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "cool-zenrice-aurora",
|
"name": "cool-zenrice-aurora2",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"os": "EndeavourOS x86_64",
|
||||||
"content": "{'key':'value','description':'Example content'}"
|
"content": "{'key':'value','description':'Example content'}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
@baseUrl = http://localhost:3000
|
@baseUrl = http://localhost:3000
|
||||||
@previous_slug = cool-zenrice-aurora-265e3881-f9de-4bbf-bb22-b9c37dbf04bc
|
@previous_slug = cool-zenrice-aurora-e99096ae-00da-4d54-9a47-53b20eb57647
|
||||||
|
|
||||||
|
|
||||||
GET {{baseUrl}}/rices/{{previous_slug}}
|
GET {{baseUrl}}/rices/{{previous_slug}}
|
|
@ -1,6 +1,6 @@
|
||||||
@baseUrl = http://localhost:3000
|
@baseUrl = http://localhost:3000
|
||||||
@previous_slug = cool-zenrice-aurora2-1d1e74b3-8d6d-40ea-bd4f-a6f4ad893f88
|
@previous_slug = cool-zenrice-aurora-ef732cbc-fdde-4f76-b4e3-cff0ec8b6f39
|
||||||
@previous_token = be4545f4-d92b-416c-8b3b-50cc9a49dee9
|
@previous_token = b406f962-5c51-43a9-8382-40e0983a46e7
|
||||||
|
|
||||||
# {
|
# {
|
||||||
# "key": "value",
|
# "key": "value",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
@baseUrl = http://localhost:3000
|
@baseUrl = http://localhost:3000
|
||||||
@previous_slug = cool-zenrice-aurora-e8abd40d-8657-4ddd-99c5-912cced67497
|
@previous_slug = cool-zenrice-aurora2-b970a742-789c-4349-8a4d-da63c8bbe77d
|
||||||
@previous_token = 2ce5c580-46e5-4a28-adff-30ce5662f29d
|
@previous_token = 03fbfdb4-d3a5-4d64-8740-feac7d32e7a8
|
||||||
|
|
||||||
DELETE {{baseUrl}}/rices/{{previous_slug}}
|
DELETE {{baseUrl}}/rices/{{previous_slug}}
|
||||||
x-rices-token: {{previous_token}}
|
x-rices-token: {{previous_token}}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue