diff --git a/app/game-api/src/router/board/index.ts b/app/game-api/src/router/board/index.ts index c7b74803..e39c36a1 100644 --- a/app/game-api/src/router/board/index.ts +++ b/app/game-api/src/router/board/index.ts @@ -143,79 +143,77 @@ export const boardRouter = router({ })); }), writeArticle: accessAuthedInputProcedure( - z.object({ - isSecret: z.boolean(), - title: z.string().trim().max(250), - content: z.string().trim().max(20000), - }) - ) - .mutation(async ({ ctx, input }) => { - const { general, permission } = await getBoardActor(ctx); - assertBoardAccess(permission, input.isSecret); + z.object({ + isSecret: z.boolean(), + title: z.string().trim().max(250), + content: z.string().trim().max(20000), + }) + ).mutation(async ({ ctx, input }) => { + const { general, permission } = await getBoardActor(ctx); + assertBoardAccess(permission, input.isSecret); - if (!input.title && !input.content) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '제목과 내용이 둘다 비어있습니다.' }); - } + if (!input.title && !input.content) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '제목과 내용이 둘다 비어있습니다.' }); + } - const post = await ctx.db.boardPost.create({ - data: { - nationId: general.nationId, - isSecret: input.isSecret, - authorGeneralId: general.id, - authorName: general.name, - title: input.title, - contentHtml: input.content, - }, - select: { id: true }, - }); + const post = await ctx.db.boardPost.create({ + data: { + nationId: general.nationId, + isSecret: input.isSecret, + authorGeneralId: general.id, + authorName: general.name, + title: input.title, + contentHtml: input.content, + }, + select: { id: true }, + }); - return { id: post.id }; - }), + return { id: post.id }; + }), writeComment: accessAuthedInputProcedure( - z.object({ - postId: z.number().int().positive(), - content: z.string().trim().max(2000), - }) - ) - .mutation(async ({ ctx, input }) => { - const { general, permission } = await getBoardActor(ctx); - if (!input.content) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '내용이 비어있습니다.' }); - } + z.object({ + postId: z.number().int().positive(), + content: z.string().trim().max(2000), + }) + ).mutation(async ({ ctx, input }) => { + const { general, permission } = await getBoardActor(ctx); + if (!input.content) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '내용이 비어있습니다.' }); + } - const post = await ctx.db.boardPost.findFirst({ - where: { - id: input.postId, - nationId: general.nationId, - }, - select: { - id: true, - isSecret: true, - }, - }); - if (!post) { - throw new TRPCError({ code: 'NOT_FOUND', message: '게시물이 없습니다.' }); - } + const post = await ctx.db.boardPost.findFirst({ + where: { + id: input.postId, + nationId: general.nationId, + }, + select: { + id: true, + isSecret: true, + }, + }); + if (!post) { + throw new TRPCError({ code: 'NOT_FOUND', message: '게시물이 없습니다.' }); + } - assertBoardAccess(permission, post.isSecret); + assertBoardAccess(permission, post.isSecret); - const comment = await ctx.db.boardComment.create({ - data: { - postId: post.id, - nationId: general.nationId, - isSecret: post.isSecret, - authorGeneralId: general.id, - authorName: general.name, - contentText: input.content, - }, - select: { id: true }, - }); + const comment = await ctx.db.boardComment.create({ + data: { + postId: post.id, + nationId: general.nationId, + isSecret: post.isSecret, + authorGeneralId: general.id, + authorName: general.name, + contentText: input.content, + }, + 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 }) => { const { permission } = await getBoardActor(ctx); - assertBoardAccess(permission, false); + assertBoardAccess(permission, true); const buffer = parseDataUrl(input.dataUrl); if (buffer.length > MAX_UPLOAD_BYTES) { diff --git a/app/game-api/test/boardRouter.test.ts b/app/game-api/test/boardRouter.test.ts index c97edb7a..a8ce27ea 100644 --- a/app/game-api/test/boardRouter.test.ts +++ b/app/game-api/test/boardRouter.test.ts @@ -267,7 +267,10 @@ describe('board router actor, nation, and secret permissions', () => { const upload = vi.fn(async ({ filename }: { filename: string }) => ({ 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({ 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')}`, }); - expect(result.url).toMatch( - /^https:\/\/sam-image\.hided\.net\/uploads\/core2026\/[a-f0-9]{32}\.webp$/ - ); + expect(result.url).toMatch(/^https:\/\/sam-image\.hided\.net\/uploads\/core2026\/[a-f0-9]{32}\.webp$/); expect(upload).toHaveBeenCalledWith( expect.objectContaining({ contentType: 'image/webp', body: expect.any(Buffer) }) ); 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 () => { const fixture = buildContext({ me: buildGeneral({ nationId: 3, officerLevel: 5 }),