generated from nhcarrigan/template
feat: base64 uploads, reusable forms, Discord roles, and UX improvements #66
@@ -41,22 +41,29 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
);
|
||||
|
||||
// Create game (protected admin route)
|
||||
app.post<{ Body: CreateGameDto; Reply: Game }>(
|
||||
app.post<{ Body: CreateGameDto; Reply: Game | { error: string } }>(
|
||||
"/",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const game = await gameService.createGame(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.entryCreate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "game",
|
||||
resourceId: game.id,
|
||||
details: `Created game: ${game.title}`,
|
||||
});
|
||||
return game;
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const game = await gameService.createGame(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.entryCreate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "game",
|
||||
resourceId: game.id,
|
||||
details: `Created game: ${game.title}`,
|
||||
});
|
||||
return game;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return reply.code(400).send({ error: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -64,26 +71,33 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.put<{
|
||||
Params: { id: string };
|
||||
Body: UpdateGameDto;
|
||||
Reply: Game | null;
|
||||
Reply: Game | null | { error: string };
|
||||
}>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const game = await gameService.updateGame(id, request.body);
|
||||
if (game) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.entryUpdate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "game",
|
||||
resourceId: id,
|
||||
details: `Updated game: ${game.title}`,
|
||||
});
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { id } = request.params;
|
||||
const game = await gameService.updateGame(id, request.body);
|
||||
if (game) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.entryUpdate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "game",
|
||||
resourceId: id,
|
||||
details: `Updated game: ${game.title}`,
|
||||
});
|
||||
}
|
||||
return game;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return reply.code(400).send({ error: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return game;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -33,9 +33,6 @@ export class GameService {
|
||||
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
||||
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
||||
}
|
||||
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||
}
|
||||
|
||||
// Validate rating
|
||||
if (!validateRating(data.rating)) {
|
||||
@@ -45,8 +42,14 @@ export class GameService {
|
||||
// Validate cover image URL
|
||||
if (data.coverImage) {
|
||||
if (data.coverImage.startsWith("data:")) {
|
||||
const sizeInBytes = data.coverImage.length * 0.75;
|
||||
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
|
||||
// Extract just the base64 data (after the comma)
|
||||
const base64Data = data.coverImage.split(",")[1];
|
||||
if (!base64Data) {
|
||||
throw new Error("Invalid image data URL format.");
|
||||
}
|
||||
// Calculate decoded size: base64 is ~4/3 larger than original, so multiply by 0.75
|
||||
const decodedSizeInBytes = base64Data.length * 0.75;
|
||||
if (decodedSizeInBytes > MAX_LENGTHS.DATA_URL) {
|
||||
throw new Error("Cover image must be under 5MB.");
|
||||
}
|
||||
if (!validateDataUrl(data.coverImage)) {
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
|
||||
/**
|
||||
* Validates that a URL is a proper base64 data string.
|
||||
* Allows whitespace in the base64 data which may occur during transmission.
|
||||
*/
|
||||
export function validateDataUrl(url: string): boolean {
|
||||
return /^data:image\/(jpeg|png|gif|webp|svg\+xml);base64,[A-Za-z0-9+/=]+$/.test(url);
|
||||
return /^data:image\/(jpeg|png|gif|webp|svg\+xml);base64,[A-Za-z0-9+/=\s]+$/.test(url);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ process.on('SIGINT', () => {
|
||||
// Instantiate Fastify with some config
|
||||
const server = Fastify({
|
||||
logger: true,
|
||||
bodyLimit: 1048576, // 1MB max body size
|
||||
bodyLimit: 10485760, // 10MB max body size (to accommodate base64-encoded images)
|
||||
});
|
||||
|
||||
// Register your application as a normal plugin.
|
||||
|
||||
Reference in New Issue
Block a user