Add validation schemas and improve texture handling

Introduces zod-based validation schemas for Minecraft and Mojang API endpoints. Refactors texture route to support hash-based file serving and removes the old static texture route. Updates database schema for player properties and adds an event to clean expired certificates. Improves ValidationError formatting, adjusts skin/cape URL construction, and adds SSRF protection for skin uploads.
This commit is contained in:
2025-12-28 09:02:10 +01:00
parent 1fe46a03fd
commit 5cfadfd7ac
29 changed files with 490 additions and 24 deletions

View File

@@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
GET: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
error: {
code: 401,
message: "Unauthorized"
}
}
}

View File

@@ -0,0 +1,28 @@
const z = require("zod")
module.exports = {
DELETE: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
error: {
code: 401,
message: "Unauthorized"
}
},
PUT: {
headers: z.object({
"content-type": z.string()
.regex(/application\/json/i, { message: "Content-Type must be application/json" }),
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
body: z.object({
capeId: z.string().uuid({ message: "Invalid Cape UUID." })
}),
error: {
code: 400,
message: "profile does not own cape",
error: "IllegalArgumentException"
}
}
}

View File

@@ -0,0 +1,18 @@
const z = require("zod")
module.exports = {
POST: {
headers: z.object({
"content-type": z.string()
.regex(/application\/json/i, { message: "Content-Type must be application/json" })
}),
body: z.array(z.string().trim().min(1))
.min(1, { message: "RequestPayload is an empty array." })
.max(10, { message: "RequestPayload has more than 10 elements." }),
error: {
code: 400,
error: "CONSTRAINT_VIOLATION",
errorMessage: "size must be between 1 and 10"
}
}
}

View File

@@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
GET: {
params: z.object({
username: z.string().min(1)
}),
error: {
code: 404,
message: "Not Found"
}
}
}

View File

@@ -0,0 +1,22 @@
const z = require("zod")
const nameSchema = z.string()
.min(1)
.max(16)
.regex(/^[a-zA-Z0-9_]+$/, { message: "Name can only contain alphanumeric characters and underscores." });
module.exports = {
PUT: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
params: z.object({
name: nameSchema
}),
error: {
code: 400,
error: "CONSTRAINT_VIOLATION",
errorMessage: "Invalid profile name"
}
}
}

View File

@@ -0,0 +1,21 @@
const z = require("zod")
const nameSchema = z.string()
.min(1)
.max(16)
.regex(/^[a-zA-Z0-9_]+$/, { message: "Name can only contain alphanumeric characters and underscores." });
module.exports = {
GET: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
params: z.object({
name: nameSchema
}),
error: {
code: 401,
message: "Unauthorized"
}
}
}

View File

@@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
GET: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
error: {
code: 401,
message: "Unauthorized"
}
}
}

View File

@@ -0,0 +1,24 @@
const z = require("zod")
module.exports = {
POST: {
headers: z.object({
"content-type": z.string()
.regex(/application\/json/i, { message: "Content-Type must be application/json" }),
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
body: z.object({
variant: z.enum(["classic", "slim"], {
errorMap: () => ({ message: "Variant must be 'classic' or 'slim'." })
}),
url: z.string()
.url({ message: "Invalid URL format." })
.max(2048, { message: "URL is too long." })
}),
error: {
code: 400,
message: "Invalid skin URL or variant.",
error: "IllegalArgumentException"
}
}
}

View File

@@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
DELETE: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
error: {
code: 401,
message: "Unauthorized"
}
}
}

View File

@@ -0,0 +1,29 @@
const z = require("zod")
module.exports = {
GET: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
error: {
code: 401,
message: "Unauthorized"
}
},
POST: {
headers: z.object({
"content-type": z.string()
.regex(/application\/json/i, { message: "Content-Type must be application/json" }),
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
body: z.object({
profanityFilterPreferences: z.object({
profanityFilterOn: z.boolean({ required_error: "profanityFilterOn is required" })
})
}),
error: {
code: 400,
message: "Invalid attributes format."
}
}
}

View File

@@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
POST: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
error: {
code: 401,
message: "Unauthorized"
}
}
}

View File

@@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
GET: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
error: {
code: 401,
message: "Unauthorized"
}
}
}

View File

@@ -0,0 +1,28 @@
const z = require("zod")
module.exports = {
PUT: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
params: z.object({
uuid: z.string().uuid({ message: "Invalid UUID." })
}),
error: {
code: 400,
message: "Invalid UUID"
}
},
DELETE: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
params: z.object({
uuid: z.string().uuid({ message: "Invalid UUID." })
}),
error: {
code: 400,
message: "Invalid UUID"
}
}
}

View File

@@ -0,0 +1,29 @@
const z = require("zod")
module.exports = {
GET: {
headers: z.object({
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
error: {
code: 401,
message: "Unauthorized"
}
},
POST: {
headers: z.object({
"content-type": z.string()
.regex(/application\/json/i, { message: "Content-Type must be application/json" }),
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
body: z.object({
profanityFilterPreferences: z.object({
profanityFilterOn: z.boolean({ required_error: "profanityFilterOn is required" })
}).optional()
}),
error: {
code: 401,
message: "Unauthorized"
}
}
}

View File

@@ -0,0 +1,18 @@
const z = require("zod")
module.exports = {
POST: {
headers: z.object({
"content-type": z.string()
.regex(/application\/json/i, { message: "Content-Type must be application/json" })
}),
body: z.array(z.string().trim().min(1))
.min(1, { message: "RequestPayload is an empty array." })
.max(10, { message: "RequestPayload has more than 10 elements." }),
error: {
code: 400,
error: "CONSTRAINT_VIOLATION",
errorMessage: "size must be between 1 and 10"
}
}
}

View File

@@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
GET: {
params: z.object({
username: z.string().min(1, { message: "Username is required." })
}),
error: {
code: 404,
message: "Not Found"
}
}
}

View File

@@ -0,0 +1,18 @@
const z = require("zod")
module.exports = {
POST: {
headers: z.object({
"content-type": z.string()
.regex(/application\/json/i, { message: "Content-Type must be application/json" })
}),
body: z.array(z.string().trim().min(1))
.min(1, { message: "RequestPayload is an empty array." })
.max(10, { message: "RequestPayload has more than 10 elements." }),
error: {
code: 400,
error: "CONSTRAINT_VIOLATION",
errorMessage: "size must be between 1 and 10"
}
}
}

View File

@@ -0,0 +1,18 @@
const z = require("zod")
module.exports = {
GET: {
headers: z.object({
"content-type": z.string()
.regex(/application\/json/i, { message: "Content-Type must be application/json" })
.optional()
}),
params: z.object({
uuid: z.string().uuid({ message: "Invalid UUID format." })
}),
error: {
code: 204,
message: "No content"
}
}
}

View File

@@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
GET: {
params: z.object({
username: z.string().min(1, { message: "Username is required." })
}),
error: {
code: 404,
message: "Not Found"
}
}
}

View File

@@ -0,0 +1,16 @@
const z = require("zod")
module.exports = {
GET: {
params: z.object({
uuid: z.string().length(32).regex(/^[0-9a-fA-F]+$/, { message: "Invalid UUID (no dashes expected)." })
}),
query: z.object({
unsigned: z.enum(["true", "false"]).optional()
}),
error: {
code: 204,
message: "No content (UUID not found)"
}
}
}