refactor: extract escapeRegex and validateTagSlugs server utils

Deduplicate tag validation and regex escaping into shared auto-imported
utils. Add tag validation to wiki patch/batch-tag routes. Remove
duplicate tags field from event schema.
This commit is contained in:
Jennie Robinson Faber 2026-04-09 23:51:56 +01:00
parent f585fabf21
commit a516f172fb
11 changed files with 33 additions and 31 deletions

View file

@ -1,5 +1,4 @@
import Event from "../../models/event.js"; import Event from "../../models/event.js";
import Tag from "../../models/tag.js";
import { connectDB } from "../../utils/mongoose.js"; import { connectDB } from "../../utils/mongoose.js";
import { requireAdmin } from "../../utils/auth.js"; import { requireAdmin } from "../../utils/auth.js";
import { validateBody } from "../../utils/validateBody.js"; import { validateBody } from "../../utils/validateBody.js";
@ -13,18 +12,7 @@ export default defineEventHandler(async (event) => {
await connectDB(); await connectDB();
// Validate tag slugs against Tag collection await validateTagSlugs(body.tags);
if (body.tags && body.tags.length > 0) {
const foundTags = await Tag.find({ slug: { $in: body.tags } });
const foundSlugs = new Set(foundTags.map((t) => t.slug));
const invalid = body.tags.filter((s) => !foundSlugs.has(s));
if (invalid.length > 0) {
throw createError({
statusCode: 400,
statusMessage: `Unknown tag slugs: ${invalid.join(", ")}`,
});
}
}
const eventData = { const eventData = {
...body, ...body,

View file

@ -1,5 +1,4 @@
import Event from '../../../models/event.js' import Event from '../../../models/event.js'
import Tag from '../../../models/tag.js'
import { connectDB } from '../../../utils/mongoose.js' import { connectDB } from '../../../utils/mongoose.js'
import { requireAdmin } from '../../../utils/auth.js' import { requireAdmin } from '../../../utils/auth.js'
@ -12,18 +11,7 @@ export default defineEventHandler(async (event) => {
await connectDB() await connectDB()
// Validate tag slugs against Tag collection await validateTagSlugs(body.tags)
if (body.tags && body.tags.length > 0) {
const foundTags = await Tag.find({ slug: { $in: body.tags } })
const foundSlugs = new Set(foundTags.map(t => t.slug))
const invalid = body.tags.filter(s => !foundSlugs.has(s))
if (invalid.length > 0) {
throw createError({
statusCode: 400,
statusMessage: `Unknown tag slugs: ${invalid.join(', ')}`
})
}
}
const updateData = { const updateData = {
...body, ...body,

View file

@ -14,6 +14,8 @@ export default defineEventHandler(async (event) => {
await connectDB() await connectDB()
await validateTagSlugs(body.tags)
const article = await WikiArticle.findByIdAndUpdate( const article = await WikiArticle.findByIdAndUpdate(
id, id,
{ tags: body.tags }, { tags: body.tags },

View file

@ -30,6 +30,8 @@ export default defineEventHandler(async (event) => {
await connectDB() await connectDB()
await validateTagSlugs([...(body.addTags || []), ...(body.removeTags || [])])
const filter = body.articleIds const filter = body.articleIds
? { _id: { $in: body.articleIds } } ? { _id: { $in: body.articleIds } }
: { collection: body.collection } : { collection: body.collection }

View file

@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
filter.collection = collection filter.collection = collection
} }
if (search) { if (search) {
filter.title = { $regex: search, $options: 'i' } filter.title = { $regex: escapeRegex(search), $options: 'i' }
} }
const articles = await WikiArticle.find(filter) const articles = await WikiArticle.find(filter)

View file

@ -42,8 +42,7 @@ export default defineEventHandler(async (event) => {
} }
if (search) { if (search) {
// Escape regex metacharacters to prevent ReDoS const escaped = escapeRegex(search);
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
andConditions.push({ andConditions.push({
$or: [ $or: [
{ name: { $regex: escaped, $options: "i" } }, { name: { $regex: escaped, $options: "i" } },

View file

@ -183,7 +183,6 @@ const eventSchema = new mongoose.Schema({
refundAmount: Number, refundAmount: Number,
}, },
], ],
tags: [{ type: String }],
createdBy: { type: String, required: true }, createdBy: { type: String, required: true },
createdAt: { type: Date, default: Date.now }, createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }, updatedAt: { type: Date, default: Date.now },

View file

@ -0,0 +1,3 @@
export function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

View file

@ -0,0 +1,14 @@
import Tag from '../models/tag.js'
export async function validateTagSlugs(slugs) {
if (!slugs?.length) return
const foundTags = await Tag.find({ slug: { $in: slugs } })
const foundSlugs = new Set(foundTags.map(t => t.slug))
const invalid = slugs.filter(s => !foundSlugs.has(s))
if (invalid.length > 0) {
throw createError({
statusCode: 400,
statusMessage: `Unknown tag slugs: ${invalid.join(', ')}`
})
}
}

View file

@ -29,6 +29,7 @@ vi.mock('../../../server/utils/schemas.js', () => ({
import Tag from '../../../server/models/tag.js' import Tag from '../../../server/models/tag.js'
import { validateBody } from '../../../server/utils/validateBody.js' import { validateBody } from '../../../server/utils/validateBody.js'
import { validateTagSlugs } from '../../../server/utils/validateTagSlugs.js'
import createHandler from '../../../server/api/admin/events.post.js' import createHandler from '../../../server/api/admin/events.post.js'
import updateHandler from '../../../server/api/admin/events/[id].put.js' import updateHandler from '../../../server/api/admin/events/[id].put.js'
import { createMockEvent } from '../helpers/createMockEvent.js' import { createMockEvent } from '../helpers/createMockEvent.js'
@ -51,8 +52,9 @@ const validEventBody = {
describe('Event tag validation', () => { describe('Event tag validation', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
// Override the global validateBody stub (from setup.js) to use our mock // Override global stubs (from setup.js) to use the real/mocked implementations
globalThis.validateBody = validateBody globalThis.validateBody = validateBody
globalThis.validateTagSlugs = validateTagSlugs
}) })
describe('create route (events.post)', () => { describe('create route (events.post)', () => {

View file

@ -41,3 +41,8 @@ vi.stubGlobal('requireAuth', vi.fn())
vi.stubGlobal('requireAdmin', vi.fn()) vi.stubGlobal('requireAdmin', vi.fn())
vi.stubGlobal('validateBody', vi.fn(async (event) => readBody(event))) vi.stubGlobal('validateBody', vi.fn(async (event) => readBody(event)))
vi.stubGlobal('logActivity', vi.fn()) vi.stubGlobal('logActivity', vi.fn())
vi.stubGlobal('validateTagSlugs', vi.fn())
// Real server/utils that are safe to use as-is in tests
import { escapeRegex } from '../../server/utils/escapeRegex.js'
vi.stubGlobal('escapeRegex', escapeRegex)