Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
2bc3f58
feat(reaction-roles): add edit endpoint + image-url media
LucasSantana-Dev Jun 23, 2026
edc9fb2
feat(guilds): add guild emojis endpoint for reaction roles
LucasSantana-Dev Jun 23, 2026
de62663
feat(reaction-roles): persist embed content for edit prefill
LucasSantana-Dev Jun 23, 2026
f7ae3ed
feat(reaction-roles): edit mode, emoji picker, formatting, media url
LucasSantana-Dev Jun 23, 2026
6f1deef
feat(reaction-roles): json export + import
LucasSantana-Dev Jun 23, 2026
1fa61b5
feat(reaction-roles): image file upload via discord attachment
LucasSantana-Dev Jun 23, 2026
f327ddf
feat(reaction-roles): image upload ui with file picker
LucasSantana-Dev Jun 23, 2026
4bb214d
test(reaction-roles): cover emojipicker unicode/server/click-outside
LucasSantana-Dev Jun 23, 2026
b39ec7f
test(reaction-roles): cover importdialog + formattingtoolbar
LucasSantana-Dev Jun 23, 2026
b33c609
test(reaction-roles): cover messageform submit + file-upload branches
LucasSantana-Dev Jun 23, 2026
7bd6202
test(reaction-roles): raise timeout for the 25-role cap test
LucasSantana-Dev Jun 23, 2026
77463a3
fix(reaction-roles): encode discord urls + dedup request build
LucasSantana-Dev Jun 23, 2026
6ef5286
fix(reaction-roles): inline snowflake guards at discord request sinks
LucasSantana-Dev Jun 23, 2026
9dc39b5
fix(reaction-roles): bound multipart upload limits + suppress s5693 fp
LucasSantana-Dev Jun 23, 2026
7a26932
fix(reaction-roles): type the multipart payload parser, drop any/unused
LucasSantana-Dev Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/backend/src/errors/AppError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export class AppError extends Error {
return new AppError(415, message)
}

static badGateway(message = 'Bad gateway'): AppError {
return new AppError(502, message)
}

static serviceUnavailable(message = 'Service unavailable'): AppError {
return new AppError(503, message)
}
Expand Down
12 changes: 12 additions & 0 deletions packages/backend/src/routes/guilds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,16 @@ export function setupGuildRoutes(app: Express): void {
res.json({ roles })
}),
)

app.get(
'/api/guilds/:guildId/emojis',
requireAuth,
validateParams(guildIdParam),
requireGuildModuleAccess('overview'),
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
const guildId = getGuildId(req)
const emojis = await guildService.getGuildEmojis(guildId)
res.json({ emojis })
}),
)
}
206 changes: 193 additions & 13 deletions packages/backend/src/routes/roles.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Express, Response } from 'express'
import type { Express, Response, NextFunction } from 'express'
import { requireAuth, type AuthenticatedRequest } from '../middleware/auth'
import { requireGuildModuleAccess } from '../middleware/guildAccess'
import { validateParams, validateBody } from '../middleware/validate'
Expand All @@ -11,11 +11,88 @@ import {
roleManagementService,
} from '@lucky/shared/services'
import { guildService } from '../services/GuildService'
import multer from 'multer'

function p(val: string | string[]): string {
return typeof val === 'string' ? val : val[0]
}

// File upload middleware for reaction roles images
const imageUpload = multer({
storage: multer.memoryStorage(),
// Bound every multipart dimension, not just the file, so a malformed/hostile
// request can't exhaust memory (DoS): one 8MB image + the small JSON payload.
limits: {
fileSize: 8 * 1024 * 1024, // 8MB per file
files: 1,
fields: 20,
fieldSize: 256 * 1024, // the `payload` JSON field
parts: 25,
},
fileFilter: (req, file, cb) => {
const validMimetypes = [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
]
if (validMimetypes.includes(file.mimetype)) {
cb(null, true)
} else {
cb(
new Error(
'Invalid image file type. Only PNG, JPEG, GIF, and WebP are allowed',
),
)
}
},
})

// Wrapper to handle multer errors
const handleImageUpload = imageUpload.single('image')
const imageUploadHandler = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
) => {
handleImageUpload(req, res, (err: unknown) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return next(
AppError.payloadTooLarge('File size exceeds 8MB limit'),
)
}
return next(AppError.badRequest(err.message))
} else if (err) {
return next(
AppError.badRequest(
err instanceof Error ? err.message : 'Image upload failed',
),
)
}
next()
})
}

// Parse the reaction-role payload from either a JSON body or the `payload`
// field of a multipart (file-upload) request.
function parseReactionRolePayload(req: AuthenticatedRequest): unknown {
if (req.is('multipart/form-data')) {
const raw = (req.body as Record<string, unknown>).payload
if (typeof raw !== 'string') {
throw AppError.badRequest(
'Missing payload field in multipart request',
)
}
try {
return JSON.parse(raw) as unknown
} catch {
throw AppError.badRequest('Invalid JSON in payload field')
}
}
return req.body
}

export function setupRolesRoutes(app: Express): void {
app.get(
'/api/guilds/:guildId/reaction-roles',
Expand All @@ -35,32 +112,117 @@ export function setupRolesRoutes(app: Express): void {
requireAuth,
requireGuildModuleAccess('overview', 'manage'),
validateParams(s.guildIdParam),
validateBody(s.createReactionRoleBody),
imageUploadHandler,
Comment thread
LucasSantana-Dev marked this conversation as resolved.
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
const guildId = p(req.params.guildId)
const botToken = process.env.DISCORD_TOKEN?.trim()
if (!botToken) {
throw AppError.serviceUnavailable('Bot token not configured')
}
const { channelId, title, description, roles } = req.body as {
channelId: string
title: string
description: string
roles: Array<{
roleId: string
label: string
emoji?: string
style?: 'Primary' | 'Secondary' | 'Success' | 'Danger'
}>

const payload = parseReactionRolePayload(req)

// Validate parsed payload with schema
const validationResult = s.createReactionRoleBody.safeParse(payload)
if (!validationResult.success) {
const errors = validationResult.error.flatten()
throw AppError.badRequest(
`Validation failed: ${JSON.stringify(errors)}`,
)
}

const { channelId, title, description, imageUrl, roles } =
validationResult.data

const imageFile = req.file
? {
buffer: req.file.buffer,
filename: req.file.originalname,
contentType: req.file.mimetype,
}
: undefined

const result =
await reactionRolesService.createReactionRoleMessageFromDashboard(
{ guildId, channelId, title, description, botToken, roles },
{
guildId,
channelId,
title,
description,
imageUrl,
imageFile,
botToken,
roles,
},
)
res.status(201).json(result)
}),
)

app.put(
'/api/guilds/:guildId/reaction-roles/:messageId',
requireAuth,
writeLimiter,
requireGuildModuleAccess('overview', 'manage'),
validateParams(s.messageIdParam),
imageUploadHandler,
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
const guildId = p(req.params.guildId)
const messageId = p(req.params.messageId)
const botToken = process.env.DISCORD_TOKEN?.trim()
if (!botToken) {
throw AppError.serviceUnavailable('Bot token not configured')
}

const payload = parseReactionRolePayload(req)

// Validate parsed payload with schema
const validationResult = s.updateReactionRoleBody.safeParse(payload)
if (!validationResult.success) {
const errors = validationResult.error.flatten()
throw AppError.badRequest(
`Validation failed: ${JSON.stringify(errors)}`,
)
}

const { title, description, imageUrl, roles } =
validationResult.data

const imageFile = req.file
? {
buffer: req.file.buffer,
filename: req.file.originalname,
contentType: req.file.mimetype,
}
: undefined

try {
const result =
await reactionRolesService.updateReactionRoleMessage({
guildId,
messageId,
title,
description,
imageUrl,
imageFile,
botToken,
roles,
})
res.json(result)
} catch (error) {
const message =
error instanceof Error ? error.message : 'Unknown error'
if (message === 'Reaction role message not found') {
throw AppError.notFound('Reaction role message not found')
}
if (message.startsWith('Discord API error')) {
throw AppError.badGateway(message)
}
throw AppError.badRequest(message)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}),
)

app.delete(
'/api/guilds/:guildId/reaction-roles/:messageId',
requireAuth,
Expand Down Expand Up @@ -125,6 +287,12 @@ export function setupRolesRoutes(app: Express): void {
error instanceof Error
? error.message
: 'Failed to create role'
if (
message.startsWith('Discord API error') ||
message === 'No bot token available'
) {
throw AppError.badGateway(message)
}
throw AppError.badRequest(message)
}
}),
Expand Down Expand Up @@ -157,6 +325,12 @@ export function setupRolesRoutes(app: Express): void {
if (message === 'Role not found') {
throw AppError.notFound('Role not found')
}
if (
message.startsWith('Discord API error') ||
message === 'No bot token available'
) {
throw AppError.badGateway(message)
}
throw AppError.badRequest(message)
}
}),
Expand All @@ -183,6 +357,12 @@ export function setupRolesRoutes(app: Express): void {
if (message === 'Role not found') {
throw AppError.notFound(message)
}
if (
message.startsWith('Discord API error') ||
message === 'No bot token available'
) {
throw AppError.badGateway(message)
}
throw AppError.badRequest(message)
}
}),
Expand Down
19 changes: 19 additions & 0 deletions packages/backend/src/schemas/management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,24 @@ const createReactionRoleBody = z
channelId: z.string().regex(/^\d{17,20}$/, 'Invalid channel ID'),
title: z.string().min(1).max(256),
description: z.string().min(1).max(4096),
imageUrl: z.string().url().max(2048).optional(),
roles: z.array(reactionRoleEntrySchema).min(1).max(25),
})
.strict()
.refine(
(data) =>
new Set(data.roles.map((r) => r.roleId)).size === data.roles.length,
{
message: 'Duplicate roleId entries are not allowed',
path: ['roles'],
},
)

const updateReactionRoleBody = z
.object({
title: z.string().min(1).max(256),
description: z.string().min(1).max(4096),
imageUrl: z.string().url().max(2048).optional(),
roles: z.array(reactionRoleEntrySchema).min(1).max(25),
})
.strict()
Expand Down Expand Up @@ -143,5 +161,6 @@ export const managementSchemas = {
roleUpsertBody,
bulkDeleteBody,
createReactionRoleBody,
updateReactionRoleBody,
messageIdParam,
}
Loading
Loading