fix: restrict content image uploads to chiefs
This commit is contained in:
@@ -143,79 +143,77 @@ export const boardRouter = router({
|
|||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
writeArticle: accessAuthedInputProcedure(
|
writeArticle: accessAuthedInputProcedure(
|
||||||
z.object({
|
z.object({
|
||||||
isSecret: z.boolean(),
|
isSecret: z.boolean(),
|
||||||
title: z.string().trim().max(250),
|
title: z.string().trim().max(250),
|
||||||
content: z.string().trim().max(20000),
|
content: z.string().trim().max(20000),
|
||||||
})
|
})
|
||||||
)
|
).mutation(async ({ ctx, input }) => {
|
||||||
.mutation(async ({ ctx, input }) => {
|
const { general, permission } = await getBoardActor(ctx);
|
||||||
const { general, permission } = await getBoardActor(ctx);
|
assertBoardAccess(permission, input.isSecret);
|
||||||
assertBoardAccess(permission, input.isSecret);
|
|
||||||
|
|
||||||
if (!input.title && !input.content) {
|
if (!input.title && !input.content) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '제목과 내용이 둘다 비어있습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '제목과 내용이 둘다 비어있습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const post = await ctx.db.boardPost.create({
|
const post = await ctx.db.boardPost.create({
|
||||||
data: {
|
data: {
|
||||||
nationId: general.nationId,
|
nationId: general.nationId,
|
||||||
isSecret: input.isSecret,
|
isSecret: input.isSecret,
|
||||||
authorGeneralId: general.id,
|
authorGeneralId: general.id,
|
||||||
authorName: general.name,
|
authorName: general.name,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
contentHtml: input.content,
|
contentHtml: input.content,
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return { id: post.id };
|
return { id: post.id };
|
||||||
}),
|
}),
|
||||||
writeComment: accessAuthedInputProcedure(
|
writeComment: accessAuthedInputProcedure(
|
||||||
z.object({
|
z.object({
|
||||||
postId: z.number().int().positive(),
|
postId: z.number().int().positive(),
|
||||||
content: z.string().trim().max(2000),
|
content: z.string().trim().max(2000),
|
||||||
})
|
})
|
||||||
)
|
).mutation(async ({ ctx, input }) => {
|
||||||
.mutation(async ({ ctx, input }) => {
|
const { general, permission } = await getBoardActor(ctx);
|
||||||
const { general, permission } = await getBoardActor(ctx);
|
if (!input.content) {
|
||||||
if (!input.content) {
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '내용이 비어있습니다.' });
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '내용이 비어있습니다.' });
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const post = await ctx.db.boardPost.findFirst({
|
const post = await ctx.db.boardPost.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: input.postId,
|
id: input.postId,
|
||||||
nationId: general.nationId,
|
nationId: general.nationId,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
isSecret: true,
|
isSecret: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!post) {
|
if (!post) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: '게시물이 없습니다.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: '게시물이 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
assertBoardAccess(permission, post.isSecret);
|
assertBoardAccess(permission, post.isSecret);
|
||||||
|
|
||||||
const comment = await ctx.db.boardComment.create({
|
const comment = await ctx.db.boardComment.create({
|
||||||
data: {
|
data: {
|
||||||
postId: post.id,
|
postId: post.id,
|
||||||
nationId: general.nationId,
|
nationId: general.nationId,
|
||||||
isSecret: post.isSecret,
|
isSecret: post.isSecret,
|
||||||
authorGeneralId: general.id,
|
authorGeneralId: general.id,
|
||||||
authorName: general.name,
|
authorName: general.name,
|
||||||
contentText: input.content,
|
contentText: input.content,
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return { id: comment.id };
|
return { id: comment.id };
|
||||||
}),
|
}),
|
||||||
uploadImage: authedProcedure.input(z.object({ dataUrl: z.string().min(1) })).mutation(async ({ ctx, input }) => {
|
uploadImage: authedProcedure.input(z.object({ dataUrl: z.string().min(1) })).mutation(async ({ ctx, input }) => {
|
||||||
const { permission } = await getBoardActor(ctx);
|
const { permission } = await getBoardActor(ctx);
|
||||||
assertBoardAccess(permission, false);
|
assertBoardAccess(permission, true);
|
||||||
|
|
||||||
const buffer = parseDataUrl(input.dataUrl);
|
const buffer = parseDataUrl(input.dataUrl);
|
||||||
if (buffer.length > MAX_UPLOAD_BYTES) {
|
if (buffer.length > MAX_UPLOAD_BYTES) {
|
||||||
|
|||||||
@@ -267,7 +267,10 @@ describe('board router actor, nation, and secret permissions', () => {
|
|||||||
const upload = vi.fn(async ({ filename }: { filename: string }) => ({
|
const upload = vi.fn(async ({ filename }: { filename: string }) => ({
|
||||||
publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`,
|
publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`,
|
||||||
}));
|
}));
|
||||||
const fixture = buildContext({ contentImageUpload: { upload } });
|
const fixture = buildContext({
|
||||||
|
me: buildGeneral({ officerLevel: 5 }),
|
||||||
|
contentImageUpload: { upload },
|
||||||
|
});
|
||||||
const png = await sharp({
|
const png = await sharp({
|
||||||
create: { width: 64, height: 48, channels: 4, background: '#224466' },
|
create: { width: 64, height: 48, channels: 4, background: '#224466' },
|
||||||
})
|
})
|
||||||
@@ -278,15 +281,31 @@ describe('board router actor, nation, and secret permissions', () => {
|
|||||||
dataUrl: `data:image/png;base64,${png.toString('base64')}`,
|
dataUrl: `data:image/png;base64,${png.toString('base64')}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.url).toMatch(
|
expect(result.url).toMatch(/^https:\/\/sam-image\.hided\.net\/uploads\/core2026\/[a-f0-9]{32}\.webp$/);
|
||||||
/^https:\/\/sam-image\.hided\.net\/uploads\/core2026\/[a-f0-9]{32}\.webp$/
|
|
||||||
);
|
|
||||||
expect(upload).toHaveBeenCalledWith(
|
expect(upload).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ contentType: 'image/webp', body: expect.any(Buffer) })
|
expect.objectContaining({ contentType: 'image/webp', body: expect.any(Buffer) })
|
||||||
);
|
);
|
||||||
expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false });
|
expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects editor image uploads from an ordinary nation member', async () => {
|
||||||
|
const upload = vi.fn();
|
||||||
|
const fixture = buildContext({
|
||||||
|
me: buildGeneral({ officerLevel: 1 }),
|
||||||
|
contentImageUpload: { upload },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(fixture.context).board.uploadImage({
|
||||||
|
dataUrl: 'data:image/png;base64,AA==',
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: '권한이 부족합니다. 수뇌부가 아닙니다.',
|
||||||
|
});
|
||||||
|
expect(upload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('does not reveal whether another nation owns a requested comment target', async () => {
|
it('does not reveal whether another nation owns a requested comment target', async () => {
|
||||||
const fixture = buildContext({
|
const fixture = buildContext({
|
||||||
me: buildGeneral({ nationId: 3, officerLevel: 5 }),
|
me: buildGeneral({ nationId: 3, officerLevel: 5 }),
|
||||||
|
|||||||
Reference in New Issue
Block a user