generated from nhcarrigan/template
Compare commits
2 Commits
v1.0.0
...
3668a67a62
| Author | SHA1 | Date | |
|---|---|---|---|
| 3668a67a62 | |||
| 208c11d153 |
@@ -41,13 +41,14 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Create game (protected admin route)
|
// Create game (protected admin route)
|
||||||
app.post<{ Body: CreateGameDto; Reply: Game }>(
|
app.post<{ Body: CreateGameDto; Reply: Game | { error: string } }>(
|
||||||
"/",
|
"/",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
preHandler: [app.csrfProtection],
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
const game = await gameService.createGame(request.body);
|
const game = await gameService.createGame(request.body);
|
||||||
await AuditService.logFromRequest(request, {
|
await AuditService.logFromRequest(request, {
|
||||||
action: AuditAction.entryCreate,
|
action: AuditAction.entryCreate,
|
||||||
@@ -57,6 +58,12 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
details: `Created game: ${game.title}`,
|
details: `Created game: ${game.title}`,
|
||||||
});
|
});
|
||||||
return game;
|
return game;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return reply.code(400).send({ error: error.message });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -64,14 +71,15 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.put<{
|
app.put<{
|
||||||
Params: { id: string };
|
Params: { id: string };
|
||||||
Body: UpdateGameDto;
|
Body: UpdateGameDto;
|
||||||
Reply: Game | null;
|
Reply: Game | null | { error: string };
|
||||||
}>(
|
}>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
preHandler: [app.csrfProtection],
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const game = await gameService.updateGame(id, request.body);
|
const game = await gameService.updateGame(id, request.body);
|
||||||
if (game) {
|
if (game) {
|
||||||
@@ -84,6 +92,12 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return game;
|
return game;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return reply.code(400).send({ error: error.message });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
validateUrl,
|
validateUrl,
|
||||||
validateRating,
|
validateRating,
|
||||||
validateStringLength,
|
validateStringLength,
|
||||||
|
validateDataUrl,
|
||||||
MAX_LENGTHS,
|
MAX_LENGTHS,
|
||||||
} from "../utils/validation";
|
} from "../utils/validation";
|
||||||
|
|
||||||
@@ -44,10 +45,24 @@ export class BookService {
|
|||||||
throw new Error("Rating must be an integer between 0 and 10.");
|
throw new Error("Rating must be an integer between 0 and 10.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate cover image URL
|
if (data.coverImage) {
|
||||||
if (data.coverImage && !validateUrl(data.coverImage)) {
|
if (data.coverImage.startsWith("data:")) {
|
||||||
|
const sizeInBytes = data.coverImage.length * 0.75;
|
||||||
|
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
|
||||||
|
throw new Error("Cover image must be under 5MB.");
|
||||||
|
}
|
||||||
|
if (!validateDataUrl(data.coverImage)) {
|
||||||
|
throw new Error("Invalid image data URL.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||||
|
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||||
|
}
|
||||||
|
if (!validateUrl(data.coverImage)) {
|
||||||
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate tags
|
// Validate tags
|
||||||
if (data.tags) {
|
if (data.tags) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
validateUrl,
|
validateUrl,
|
||||||
validateRating,
|
validateRating,
|
||||||
validateStringLength,
|
validateStringLength,
|
||||||
|
validateDataUrl,
|
||||||
MAX_LENGTHS,
|
MAX_LENGTHS,
|
||||||
} from "../utils/validation";
|
} from "../utils/validation";
|
||||||
|
|
||||||
@@ -32,9 +33,6 @@ export class GameService {
|
|||||||
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
||||||
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
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
|
// Validate rating
|
||||||
if (!validateRating(data.rating)) {
|
if (!validateRating(data.rating)) {
|
||||||
@@ -42,9 +40,30 @@ export class GameService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate cover image URL
|
// Validate cover image URL
|
||||||
if (data.coverImage && !validateUrl(data.coverImage)) {
|
if (data.coverImage) {
|
||||||
|
if (data.coverImage.startsWith("data:")) {
|
||||||
|
// 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)) {
|
||||||
|
throw new Error("Invalid image data URL.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||||
|
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||||
|
}
|
||||||
|
if (!validateUrl(data.coverImage)) {
|
||||||
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate tags
|
// Validate tags
|
||||||
if (data.tags) {
|
if (data.tags) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
validateUrl,
|
validateUrl,
|
||||||
validateRating,
|
validateRating,
|
||||||
validateStringLength,
|
validateStringLength,
|
||||||
|
validateDataUrl,
|
||||||
MAX_LENGTHS,
|
MAX_LENGTHS,
|
||||||
} from "../utils/validation";
|
} from "../utils/validation";
|
||||||
|
|
||||||
@@ -42,9 +43,24 @@ export class MangaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate cover image URL
|
// Validate cover image URL
|
||||||
if (data.coverImage && !validateUrl(data.coverImage)) {
|
if (data.coverImage) {
|
||||||
|
if (data.coverImage.startsWith("data:")) {
|
||||||
|
const sizeInBytes = data.coverImage.length * 0.75;
|
||||||
|
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
|
||||||
|
throw new Error("Cover image must be under 5MB.");
|
||||||
|
}
|
||||||
|
if (!validateDataUrl(data.coverImage)) {
|
||||||
|
throw new Error("Invalid image data URL.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||||
|
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||||
|
}
|
||||||
|
if (!validateUrl(data.coverImage)) {
|
||||||
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate tags
|
// Validate tags
|
||||||
if (data.tags) {
|
if (data.tags) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
validateUrl,
|
validateUrl,
|
||||||
validateRating,
|
validateRating,
|
||||||
validateStringLength,
|
validateStringLength,
|
||||||
|
validateDataUrl,
|
||||||
MAX_LENGTHS,
|
MAX_LENGTHS,
|
||||||
} from "../utils/validation";
|
} from "../utils/validation";
|
||||||
|
|
||||||
@@ -42,8 +43,23 @@ export class MusicService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate cover art URL
|
// Validate cover art URL
|
||||||
if (data.coverArt && !validateUrl(data.coverArt)) {
|
if (data.coverArt) {
|
||||||
throw new Error("Invalid cover art URL. Only http and https URLs are allowed.");
|
if (data.coverArt.startsWith("data:")) {
|
||||||
|
const sizeInBytes = data.coverArt.length * 0.75;
|
||||||
|
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
|
||||||
|
throw new Error("Cover image must be under 5MB.");
|
||||||
|
}
|
||||||
|
if (!validateDataUrl(data.coverArt)) {
|
||||||
|
throw new Error("Invalid image data URL.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!validateStringLength(data.coverArt, MAX_LENGTHS.URL)) {
|
||||||
|
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||||
|
}
|
||||||
|
if (!validateUrl(data.coverArt)) {
|
||||||
|
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate tags
|
// Validate tags
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
validateUrl,
|
validateUrl,
|
||||||
validateRating,
|
validateRating,
|
||||||
validateStringLength,
|
validateStringLength,
|
||||||
|
validateDataUrl,
|
||||||
MAX_LENGTHS,
|
MAX_LENGTHS,
|
||||||
} from "../utils/validation";
|
} from "../utils/validation";
|
||||||
|
|
||||||
@@ -39,9 +40,24 @@ export class ShowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate cover image URL
|
// Validate cover image URL
|
||||||
if (data.coverImage && !validateUrl(data.coverImage)) {
|
if (data.coverImage) {
|
||||||
|
if (data.coverImage.startsWith("data:")) {
|
||||||
|
const sizeInBytes = data.coverImage.length * 0.75;
|
||||||
|
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
|
||||||
|
throw new Error("Cover image must be under 5MB.");
|
||||||
|
}
|
||||||
|
if (!validateDataUrl(data.coverImage)) {
|
||||||
|
throw new Error("Invalid image data URL.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||||
|
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||||
|
}
|
||||||
|
if (!validateUrl(data.coverImage)) {
|
||||||
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate tags
|
// Validate tags
|
||||||
if (data.tags) {
|
if (data.tags) {
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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+/=\s]+$/.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that a URL is safe and points to an allowed protocol.
|
* Validates that a URL is safe and points to an allowed protocol.
|
||||||
* Prevents javascript:, data:, vbscript:, and file: URLs.
|
* Prevents javascript:, data:, vbscript:, and file: URLs.
|
||||||
@@ -83,4 +91,5 @@ export const MAX_LENGTHS = {
|
|||||||
NOTES: 5000,
|
NOTES: 5000,
|
||||||
TAGS: 50, // per tag
|
TAGS: 50, // per tag
|
||||||
ISBN: 50,
|
ISBN: 50,
|
||||||
|
DATA_URL: 5 * 1024 * 1024, // 5MB in bytes (not chars)
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ process.on('SIGINT', () => {
|
|||||||
// Instantiate Fastify with some config
|
// Instantiate Fastify with some config
|
||||||
const server = Fastify({
|
const server = Fastify({
|
||||||
logger: true,
|
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.
|
// Register your application as a normal plugin.
|
||||||
|
|||||||
Reference in New Issue
Block a user