feat(board): W3-3 포스팅 보드 — 공지/블로그 + OG 미리보기(SSRF 방어) + 유니티 피드 감시

- posts/post_categories/unity_feed_sources/unity_feed_items 4테이블: docs/board-ddl.sql 권위 + db/schema.sql 동기, 멱등(IF NOT EXISTS/DO $$). FK·CHECK·부분 UNIQUE(unity_feed_items source_id,guid)
- 포스팅 CRUD: PostController 공개 목록(keyset 페이징 (created_at,id)) + 상세 + 작성/수정/삭제. POST_WRITE enforcement 연결(W1 PermissionGate, 임시 role 체크 0). 작성자무관 POST_WRITE 보유자 편집
- 마크다운 본문: PostMarkdownService commonmark 0.22.0 → jsoup 1.17.2 Safelist allowlist sanitize, 저장 시 1회 캐시(body_sanitized_html). script/on*/javascript:/iframe/object 제거(adversarial 감사 SOUND)
- ★SsrfSafeFetcher 공용 외부 fetch 관문: scheme allowlist + 전 resolved IP 공인검증(사설/루프백/링크로컬/메타데이터169.254.169.254 + CGNAT 100.64/10 + class-E/benchmarking/NAT64/6to4 차단) + 포트 allowlist{80/443/8080/8443} + hostname-connect(HTTPS SNI 정합) + 매홉 재검증(redirect NEVER, MAX 3) + peer 재검증(rebinding) + size cap + timeout + Content-Type + graceful
- OG 미리보기(OgPreviewService) + 유니티 피드 폴링(UnityFeedPoller @Scheduled, SchedulingConfig @EnableScheduling, FeedParser RSS/Atom XXE 방어 disallow-doctype) + guid dedupe(ON CONFLICT DO NOTHING)
- 카테고리/피드 운영: PostAdminController/UnityFeedAdminController /admin/** ADMIN 인터셉터 + CSRF 전수. 카테고리 삭제 FK 보호
- 신규 6매퍼 #{} only(${} 0). @MockBean 8. JSP 5(scriptlet+HtmlUtils.htmlEscape, JSTL 의존 부재 정합)

검증: 컨테이너 ./mvnw -o test 287/287 GREEN(신규 44), 회귀 0. L2 격리 throwaway DB contract PASS(keyset row-comparison·alias·insertIgnoreDup ON CONFLICT·FK 거부). adversarial SSRF 감사: rebinding/redirect/scheme/IP인코딩/IPv6 차단 실증, XSS sanitize SOUND. 실 dev DB 무접촉.

backward 보정(동일 커밋 통합): (1) Host restricted-header→fetch 전건 empty+테스트 vacuous → hostname-connect 전환(HTTPS SNI 정합, JVM 플래그 불요) (2) CGNAT 등 차단범위 확장 (3) FeedParser RFC1123 요일-날짜 불일치 robust 파싱.

알려진 잔여: option-b hostname-connect 는 connect 시 재resolve 로 rebinding TOCTOU 창 존재(DNS 캐시로 최소화). 위협모델상 LOW(fetch URL 제출자=ADMIN 피드등록/POST_WRITE OG). HTTPS 실 TLS = L3 스모크 권고.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD
This commit is contained in:
이정수 2026-06-29 11:46:28 +09:00
parent e28fa60ca6
commit 6047a39ca5
32 changed files with 5327 additions and 0 deletions

View File

@ -906,3 +906,118 @@ CREATE INDEX IF NOT EXISTS "idx_game_views_dedupe" ON "game_views" ("game_id", "
-- 5) games 비정규화 방문수 카운터
ALTER TABLE "games" ADD COLUMN IF NOT EXISTS "view_count" integer DEFAULT 0 NOT NULL;
CREATE INDEX IF NOT EXISTS "idx_games_view_count" ON "games" ("view_count");
-- ---------------------------------------------------------------------------
-- W3-3 포스팅 보드: post_categories / posts / unity_feed_sources / unity_feed_items
-- (권위 DDL — docs/board-ddl.sql 와 동일)
-- ---------------------------------------------------------------------------
-- 1) post_categories (운영자 CRUD 카테고리 — D1/G2)
CREATE SEQUENCE IF NOT EXISTS "post_categories_id_seq";
CREATE TABLE IF NOT EXISTS "post_categories" (
"id" bigint DEFAULT nextval('post_categories_id_seq'::regclass) NOT NULL,
"name" character varying(80) NOT NULL,
"slug" character varying(80) NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "post_categories_id_seq" OWNED BY "post_categories"."id";
CREATE UNIQUE INDEX IF NOT EXISTS "ux_post_categories_slug"
ON "post_categories" ("slug");
-- 2) posts (D1) — body_markdown 원본 + body_sanitized_html 캐시(D4), og_* 캐시(D5)
CREATE SEQUENCE IF NOT EXISTS "posts_id_seq";
CREATE TABLE IF NOT EXISTS "posts" (
"id" bigint DEFAULT nextval('posts_id_seq'::regclass) NOT NULL,
"category_id" bigint NOT NULL,
"author_user_id" bigint NOT NULL,
"title" character varying(200) NOT NULL,
"body_markdown" text NOT NULL,
"body_sanitized_html" text NOT NULL,
"link_url" character varying(2048),
"og_title" character varying(300),
"og_description" character varying(600),
"og_image_url" character varying(2048),
"og_site_name" character varying(200),
"og_fetched_at" timestamp with time zone,
"status" character varying(20) DEFAULT 'PUBLISHED' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"is_delete" boolean DEFAULT false NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "posts_id_seq" OWNED BY "posts"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_category_id_fkey') THEN
ALTER TABLE "posts"
ADD CONSTRAINT "posts_category_id_fkey"
FOREIGN KEY ("category_id") REFERENCES "post_categories" ("id");
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_author_user_id_fkey') THEN
ALTER TABLE "posts"
ADD CONSTRAINT "posts_author_user_id_fkey"
FOREIGN KEY ("author_user_id") REFERENCES "users" ("id");
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_status_check') THEN
ALTER TABLE "posts"
ADD CONSTRAINT "posts_status_check"
CHECK ("status" IN ('DRAFT', 'PUBLISHED'));
END IF;
END
$$;
CREATE INDEX IF NOT EXISTS "idx_posts_published_keyset"
ON "posts" ("status", "is_delete", "created_at" DESC, "id" DESC);
CREATE INDEX IF NOT EXISTS "idx_posts_category_keyset"
ON "posts" ("category_id", "created_at" DESC, "id" DESC)
WHERE "is_delete" = false AND "status" = 'PUBLISHED';
-- 3) unity_feed_sources (외부 피드 감시 소스 — D6/G5)
CREATE SEQUENCE IF NOT EXISTS "unity_feed_sources_id_seq";
CREATE TABLE IF NOT EXISTS "unity_feed_sources" (
"id" bigint DEFAULT nextval('unity_feed_sources_id_seq'::regclass) NOT NULL,
"name" character varying(120) NOT NULL,
"feed_url" character varying(2048) NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"last_polled_at" timestamp with time zone,
"last_seen_guid" character varying(512),
"last_error" character varying(500),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "unity_feed_sources_id_seq" OWNED BY "unity_feed_sources"."id";
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_sources_url"
ON "unity_feed_sources" ("feed_url");
-- 4) unity_feed_items (감지된 새 글 — 운영자 알림 표면 + dedupe 영속화)
CREATE SEQUENCE IF NOT EXISTS "unity_feed_items_id_seq";
CREATE TABLE IF NOT EXISTS "unity_feed_items" (
"id" bigint DEFAULT nextval('unity_feed_items_id_seq'::regclass) NOT NULL,
"source_id" bigint NOT NULL,
"guid" character varying(512) NOT NULL,
"title" character varying(500) NOT NULL,
"link_url" character varying(2048) NOT NULL,
"published_at" timestamp with time zone,
"is_acknowledged" boolean DEFAULT false NOT NULL,
"detected_at" timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "unity_feed_items_id_seq" OWNED BY "unity_feed_items"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'unity_feed_items_source_id_fkey') THEN
ALTER TABLE "unity_feed_items"
ADD CONSTRAINT "unity_feed_items_source_id_fkey"
FOREIGN KEY ("source_id") REFERENCES "unity_feed_sources" ("id");
END IF;
END
$$;
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_items_source_guid"
ON "unity_feed_items" ("source_id", "guid");
CREATE INDEX IF NOT EXISTS "idx_unity_feed_items_unack"
ON "unity_feed_items" ("is_acknowledged", "detected_at" DESC)
WHERE "is_acknowledged" = false;

112
docs/board-ddl.sql Normal file
View File

@ -0,0 +1,112 @@
-- W3-3 포스팅 보드. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
-- posts / post_categories / unity_feed_sources / unity_feed_items. 추가만, 파괴 없음.
-- 1) post_categories (운영자 CRUD 카테고리 — D1/G2)
CREATE SEQUENCE IF NOT EXISTS "post_categories_id_seq";
CREATE TABLE IF NOT EXISTS "post_categories" (
"id" bigint DEFAULT nextval('post_categories_id_seq'::regclass) NOT NULL,
"name" character varying(80) NOT NULL,
"slug" character varying(80) NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "post_categories_id_seq" OWNED BY "post_categories"."id";
CREATE UNIQUE INDEX IF NOT EXISTS "ux_post_categories_slug"
ON "post_categories" ("slug");
-- 2) posts (D1) — body_markdown 원본 + body_sanitized_html 캐시(D4), og_* 캐시(D5)
CREATE SEQUENCE IF NOT EXISTS "posts_id_seq";
CREATE TABLE IF NOT EXISTS "posts" (
"id" bigint DEFAULT nextval('posts_id_seq'::regclass) NOT NULL,
"category_id" bigint NOT NULL,
"author_user_id" bigint NOT NULL,
"title" character varying(200) NOT NULL,
"body_markdown" text NOT NULL,
"body_sanitized_html" text NOT NULL,
"link_url" character varying(2048),
"og_title" character varying(300),
"og_description" character varying(600),
"og_image_url" character varying(2048),
"og_site_name" character varying(200),
"og_fetched_at" timestamp with time zone,
"status" character varying(20) DEFAULT 'PUBLISHED' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"is_delete" boolean DEFAULT false NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "posts_id_seq" OWNED BY "posts"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_category_id_fkey') THEN
ALTER TABLE "posts"
ADD CONSTRAINT "posts_category_id_fkey"
FOREIGN KEY ("category_id") REFERENCES "post_categories" ("id");
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_author_user_id_fkey') THEN
ALTER TABLE "posts"
ADD CONSTRAINT "posts_author_user_id_fkey"
FOREIGN KEY ("author_user_id") REFERENCES "users" ("id");
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_status_check') THEN
ALTER TABLE "posts"
ADD CONSTRAINT "posts_status_check"
CHECK ("status" IN ('DRAFT', 'PUBLISHED'));
END IF;
END
$$;
CREATE INDEX IF NOT EXISTS "idx_posts_published_keyset"
ON "posts" ("status", "is_delete", "created_at" DESC, "id" DESC);
CREATE INDEX IF NOT EXISTS "idx_posts_category_keyset"
ON "posts" ("category_id", "created_at" DESC, "id" DESC)
WHERE "is_delete" = false AND "status" = 'PUBLISHED';
-- 3) unity_feed_sources (외부 피드 감시 소스 — D6/G5)
CREATE SEQUENCE IF NOT EXISTS "unity_feed_sources_id_seq";
CREATE TABLE IF NOT EXISTS "unity_feed_sources" (
"id" bigint DEFAULT nextval('unity_feed_sources_id_seq'::regclass) NOT NULL,
"name" character varying(120) NOT NULL,
"feed_url" character varying(2048) NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"last_polled_at" timestamp with time zone,
"last_seen_guid" character varying(512),
"last_error" character varying(500),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "unity_feed_sources_id_seq" OWNED BY "unity_feed_sources"."id";
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_sources_url"
ON "unity_feed_sources" ("feed_url");
-- 4) unity_feed_items (감지된 새 글 — 운영자 알림 표면 + dedupe 영속화)
CREATE SEQUENCE IF NOT EXISTS "unity_feed_items_id_seq";
CREATE TABLE IF NOT EXISTS "unity_feed_items" (
"id" bigint DEFAULT nextval('unity_feed_items_id_seq'::regclass) NOT NULL,
"source_id" bigint NOT NULL,
"guid" character varying(512) NOT NULL,
"title" character varying(500) NOT NULL,
"link_url" character varying(2048) NOT NULL,
"published_at" timestamp with time zone,
"is_acknowledged" boolean DEFAULT false NOT NULL,
"detected_at" timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "unity_feed_items_id_seq" OWNED BY "unity_feed_items"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'unity_feed_items_source_id_fkey') THEN
ALTER TABLE "unity_feed_items"
ADD CONSTRAINT "unity_feed_items_source_id_fkey"
FOREIGN KEY ("source_id") REFERENCES "unity_feed_sources" ("id");
END IF;
END
$$;
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_items_source_guid"
ON "unity_feed_items" ("source_id", "guid");
CREATE INDEX IF NOT EXISTS "idx_unity_feed_items_unack"
ON "unity_feed_items" ("is_acknowledged", "detected_at" DESC)
WHERE "is_acknowledged" = false;

10
pom.xml
View File

@ -84,6 +84,16 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.22.0</version>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.17.2</version>
</dependency>
</dependencies>
<build>

View File

@ -0,0 +1,9 @@
package com.pandoli365.bibimbap.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
@Configuration
@EnableScheduling
public class SchedulingConfig {
}

View File

@ -0,0 +1,164 @@
package com.pandoli365.bibimbap.controller;
import com.pandoli365.bibimbap.data.PostCategoryData;
import com.pandoli365.bibimbap.mapper.PostCategoriesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.LinkedHashMap;
import java.util.Map;
@Controller
public class PostAdminController {
private final PostCategoriesMapper postCategoriesMapper;
public PostAdminController(PostCategoriesMapper postCategoriesMapper) {
this.postCategoriesMapper = postCategoriesMapper;
}
@GetMapping("/admin/post-categories")
public String postCategoriesPage(HttpServletRequest request, Model model) {
model.addAttribute("categories", postCategoriesMapper.listActive());
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession()));
return "admin-post-categories";
}
@PostMapping("/admin/post-categories")
@Transactional
public ResponseEntity<Map<String, Object>> createCategory(
@RequestParam(name = "name", required = false) String name,
@RequestParam(name = "slug", required = false) String slug,
@RequestParam(name = "sortOrder", required = false) Integer sortOrder,
HttpServletRequest request
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
String normalizedName = trimToNull(name);
String normalizedSlug = trimToNull(slug);
if (normalizedName == null || normalizedName.length() > 80) {
return response(HttpStatus.BAD_REQUEST, "카테고리 이름을 80자 이내로 입력해 주세요.");
}
if (normalizedSlug == null || normalizedSlug.length() > 80) {
return response(HttpStatus.BAD_REQUEST, "슬러그를 80자 이내로 입력해 주세요.");
}
int order = sortOrder == null ? 0 : sortOrder;
PostCategoryData category = new PostCategoryData();
category.setName(normalizedName);
category.setSlug(normalizedSlug);
category.setSortOrder(order);
category.setIsActive(true);
try {
postCategoriesMapper.insert(category);
} catch (DuplicateKeyException e) {
return response(HttpStatus.CONFLICT, "이미 사용 중인 슬러그입니다.");
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("message", "카테고리를 등록했습니다.");
body.put("categoryId", category.getId());
return ResponseEntity.ok(body);
}
@PostMapping("/admin/post-categories/{id}")
@Transactional
public ResponseEntity<Map<String, Object>> updateCategory(
@PathVariable("id") long id,
@RequestParam(name = "name", required = false) String name,
@RequestParam(name = "slug", required = false) String slug,
@RequestParam(name = "sortOrder", required = false) Integer sortOrder,
@RequestParam(name = "isActive", required = false) Boolean isActive,
HttpServletRequest request
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
PostCategoryData existing = postCategoriesMapper.getById(id);
if (existing == null) {
return response(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다.");
}
String normalizedName = trimToNull(name);
if (normalizedName != null) {
if (normalizedName.length() > 80) {
return response(HttpStatus.BAD_REQUEST, "카테고리 이름을 80자 이내로 입력해 주세요.");
}
existing.setName(normalizedName);
}
String normalizedSlug = trimToNull(slug);
if (normalizedSlug != null) {
if (normalizedSlug.length() > 80) {
return response(HttpStatus.BAD_REQUEST, "슬러그를 80자 이내로 입력해 주세요.");
}
existing.setSlug(normalizedSlug);
}
if (sortOrder != null) {
existing.setSortOrder(sortOrder);
}
if (isActive != null) {
existing.setIsActive(isActive);
}
try {
postCategoriesMapper.update(existing);
} catch (DuplicateKeyException e) {
return response(HttpStatus.CONFLICT, "이미 사용 중인 슬러그입니다.");
}
return response(HttpStatus.OK, "카테고리를 수정했습니다.");
}
@PostMapping("/admin/post-categories/{id}/delete")
@Transactional
public ResponseEntity<Map<String, Object>> deleteCategory(
@PathVariable("id") long id,
HttpServletRequest request
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
PostCategoryData existing = postCategoriesMapper.getById(id);
if (existing == null) {
return response(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다.");
}
if (postCategoriesMapper.countPostsByCategory(id) > 0) {
return response(HttpStatus.CONFLICT,
"소속 PUBLISHED 포스트가 있어 삭제할 수 없습니다. 비활성화(is_active=false)를 권장합니다.");
}
postCategoriesMapper.delete(id);
return response(HttpStatus.OK, "카테고리를 삭제했습니다.");
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String text = value.trim();
return text.isBlank() ? null : text;
}
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", status.value());
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
}

View File

@ -0,0 +1,414 @@
package com.pandoli365.bibimbap.controller;
import com.pandoli365.bibimbap.data.PostCategoryData;
import com.pandoli365.bibimbap.data.PostData;
import com.pandoli365.bibimbap.mapper.PostCategoriesMapper;
import com.pandoli365.bibimbap.mapper.PostsMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.security.PermissionGate;
import com.pandoli365.bibimbap.security.PermissionKeys;
import com.pandoli365.bibimbap.service.OgPreviewService;
import com.pandoli365.bibimbap.service.OgPreviewService.OgPreview;
import com.pandoli365.bibimbap.service.PostMarkdownService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.server.ResponseStatusException;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@Controller
public class PostController {
private static final int PAGE_SIZE = 20;
private static final int MAX_TITLE = 200;
private static final int MAX_BODY = 20000;
private static final int MAX_LINK_URL = 2048;
private static final Set<String> STATUSES = Set.of("DRAFT", "PUBLISHED");
private static final String DEFAULT_STATUS = "PUBLISHED";
private final PostsMapper postsMapper;
private final PostCategoriesMapper postCategoriesMapper;
private final PermissionGate gate;
private final PostMarkdownService postMarkdownService;
private final OgPreviewService ogPreviewService;
public PostController(PostsMapper postsMapper,
PostCategoriesMapper postCategoriesMapper,
PermissionGate gate,
PostMarkdownService postMarkdownService,
OgPreviewService ogPreviewService) {
this.postsMapper = postsMapper;
this.postCategoriesMapper = postCategoriesMapper;
this.gate = gate;
this.postMarkdownService = postMarkdownService;
this.ogPreviewService = ogPreviewService;
}
@GetMapping("/posts")
public String list(
@RequestParam(name = "categoryId", required = false) Long categoryId,
@RequestParam(name = "cursorCreatedAt", required = false) String cursorCreatedAt,
@RequestParam(name = "cursorId", required = false) Long cursorId,
Model model
) {
OffsetDateTime cursor = parseOffsetDateTime(cursorCreatedAt);
List<PostData> rows = postsMapper.listPublishedKeyset(cursor, cursorId, categoryId, PAGE_SIZE + 1);
boolean hasNext = rows.size() > PAGE_SIZE;
if (hasNext) {
rows = rows.subList(0, PAGE_SIZE);
}
model.addAttribute("posts", rows);
model.addAttribute("categories", postCategoriesMapper.listActive());
model.addAttribute("categoryId", categoryId);
model.addAttribute("hasNext", hasNext);
if (hasNext && !rows.isEmpty()) {
PostData last = rows.get(rows.size() - 1);
model.addAttribute("nextCursorCreatedAt", last.getCreatedAt());
model.addAttribute("nextCursorId", last.getId());
}
return "posts-list";
}
@GetMapping("/posts/{id}")
public String detail(@PathVariable("id") long id, Model model) {
PostData post = postsMapper.getPublished(id);
if (post == null) {
return "redirect:/posts";
}
model.addAttribute("post", post);
return "posts-detail";
}
@GetMapping("/posts/new")
public String createForm(HttpSession session, Model model) {
if (sessionUserId(session) == null) {
return "redirect:/login";
}
requireWritePermission(session);
model.addAttribute("categories", postCategoriesMapper.listActive());
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(session));
model.addAttribute("mode", "new");
return "posts-form";
}
@PostMapping("/posts")
@Transactional
public ResponseEntity<Map<String, Object>> create(
@RequestParam(name = "categoryId", required = false) Long categoryId,
@RequestParam(name = "title", required = false) String title,
@RequestParam(name = "bodyMarkdown", required = false) String bodyMarkdown,
@RequestParam(name = "linkUrl", required = false) String linkUrl,
@RequestParam(name = "status", required = false) String status,
HttpServletRequest request,
HttpSession session
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
Long userId = sessionUserId(session);
if (userId == null) {
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
}
if (!gate.has(session, PermissionKeys.POST_WRITE.name())) {
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
}
Validated validated = validate(title, bodyMarkdown, linkUrl, status, categoryId);
if (validated.error != null) {
return validated.error;
}
PostData post = new PostData();
post.setCategoryId(categoryId);
post.setAuthorUserId(userId);
post.setTitle(validated.title);
post.setBodyMarkdown(validated.bodyMarkdown);
post.setBodySanitizedHtml(postMarkdownService.render(validated.bodyMarkdown));
post.setLinkUrl(validated.linkUrl);
post.setStatus(validated.status);
applyOgPreview(post, validated.linkUrl);
postsMapper.insert(post);
if (post.getId() == null) {
return response(HttpStatus.INTERNAL_SERVER_ERROR, "포스트 등록 결과를 확인하지 못했습니다.");
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("message", "포스트가 등록되었습니다.");
body.put("postId", post.getId());
body.put("location", "/posts/" + post.getId());
return ResponseEntity.ok(body);
}
@GetMapping("/posts/{id}/edit")
public String editForm(@PathVariable("id") long id, HttpSession session, Model model) {
if (sessionUserId(session) == null) {
return "redirect:/login";
}
requireWritePermission(session);
PostData post = postsMapper.getPublished(id);
if (post == null) {
return "redirect:/posts";
}
model.addAttribute("post", post);
model.addAttribute("categories", postCategoriesMapper.listActive());
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(session));
model.addAttribute("mode", "edit");
return "posts-form";
}
@PostMapping("/posts/{id}")
@Transactional
public ResponseEntity<Map<String, Object>> update(
@PathVariable("id") long id,
@RequestParam(name = "categoryId", required = false) Long categoryId,
@RequestParam(name = "title", required = false) String title,
@RequestParam(name = "bodyMarkdown", required = false) String bodyMarkdown,
@RequestParam(name = "linkUrl", required = false) String linkUrl,
@RequestParam(name = "status", required = false) String status,
HttpServletRequest request,
HttpSession session
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
Long userId = sessionUserId(session);
if (userId == null) {
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
}
if (!gate.has(session, PermissionKeys.POST_WRITE.name())) {
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
}
Validated validated = validate(title, bodyMarkdown, linkUrl, status, categoryId);
if (validated.error != null) {
return validated.error;
}
PostData existing = postsMapper.getPublished(id);
if (existing == null) {
return response(HttpStatus.NOT_FOUND, "포스트를 찾을 수 없습니다.");
}
existing.setCategoryId(categoryId);
existing.setTitle(validated.title);
existing.setBodyMarkdown(validated.bodyMarkdown);
existing.setBodySanitizedHtml(postMarkdownService.render(validated.bodyMarkdown));
existing.setStatus(validated.status);
boolean linkChanged = !equalsLink(existing.getLinkUrl(), validated.linkUrl);
existing.setLinkUrl(validated.linkUrl);
if (linkChanged) {
clearOgPreview(existing);
applyOgPreview(existing, validated.linkUrl);
}
postsMapper.update(existing);
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("message", "포스트가 수정되었습니다.");
body.put("postId", existing.getId());
body.put("location", "/posts/" + existing.getId());
return ResponseEntity.ok(body);
}
@PostMapping("/posts/{id}/delete")
@Transactional
public ResponseEntity<Map<String, Object>> delete(
@PathVariable("id") long id,
HttpServletRequest request,
HttpSession session
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
Long userId = sessionUserId(session);
if (userId == null) {
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
}
if (!gate.has(session, PermissionKeys.POST_WRITE.name())) {
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
}
PostData existing = postsMapper.getPublished(id);
if (existing == null) {
return response(HttpStatus.NOT_FOUND, "포스트를 찾을 수 없습니다.");
}
postsMapper.softDelete(id);
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("message", "포스트가 삭제되었습니다.");
body.put("postId", id);
return ResponseEntity.ok(body);
}
private void requireWritePermission(HttpSession session) {
if (!gate.has(session, PermissionKeys.POST_WRITE.name())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "권한이 없습니다.");
}
}
private Validated validate(String title, String bodyMarkdown, String linkUrl, String status, Long categoryId) {
Validated v = new Validated();
String normalizedTitle = trimToNull(title);
if (normalizedTitle == null || normalizedTitle.length() > MAX_TITLE) {
v.error = response(HttpStatus.BAD_REQUEST, "제목을 " + MAX_TITLE + "자 이내로 입력해 주세요.");
return v;
}
v.title = normalizedTitle;
String normalizedBody = bodyMarkdown == null ? "" : bodyMarkdown;
if (normalizedBody.length() > MAX_BODY) {
v.error = response(HttpStatus.BAD_REQUEST, "본문은 " + MAX_BODY + "자 이내로 입력해 주세요.");
return v;
}
v.bodyMarkdown = normalizedBody;
v.linkUrl = normalizeLinkUrl(linkUrl);
if (v.linkUrl != null && v.linkUrl.length() > MAX_LINK_URL) {
v.error = response(HttpStatus.BAD_REQUEST, "링크 URL은 " + MAX_LINK_URL + "자 이내로 입력해 주세요.");
return v;
}
String normalizedStatus = trimToNull(status);
if (normalizedStatus == null) {
normalizedStatus = DEFAULT_STATUS;
}
if (!STATUSES.contains(normalizedStatus)) {
v.error = response(HttpStatus.BAD_REQUEST, "공개 상태를 확인해 주세요.");
return v;
}
v.status = normalizedStatus;
if (categoryId == null) {
v.error = response(HttpStatus.BAD_REQUEST, "카테고리를 선택해 주세요.");
return v;
}
PostCategoryData category = postCategoriesMapper.getActive(categoryId);
if (category == null) {
v.error = response(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다.");
return v;
}
return v;
}
// OG fetch graceful: 실패해도 작성/수정은 성공시킨다.
private void applyOgPreview(PostData post, String linkUrl) {
if (linkUrl == null) {
return;
}
Optional<OgPreview> preview = ogPreviewService.fetch(linkUrl);
if (preview.isEmpty()) {
return;
}
OgPreview og = preview.get();
post.setOgTitle(og.title());
post.setOgDescription(og.description());
post.setOgImageUrl(og.imageUrl());
post.setOgSiteName(og.siteName());
post.setOgFetchedAt(OffsetDateTime.now());
}
private void clearOgPreview(PostData post) {
post.setOgTitle(null);
post.setOgDescription(null);
post.setOgImageUrl(null);
post.setOgSiteName(null);
post.setOgFetchedAt(null);
}
private boolean equalsLink(String a, String b) {
if (a == null) {
return b == null;
}
return a.equals(b);
}
private String normalizeLinkUrl(String linkUrl) {
String text = trimToNull(linkUrl);
if (text == null) {
return null;
}
String lower = text.toLowerCase();
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
return null;
}
return text;
}
private OffsetDateTime parseOffsetDateTime(String value) {
String text = trimToNull(value);
if (text == null) {
return null;
}
try {
return OffsetDateTime.parse(text);
} catch (DateTimeParseException e) {
return null;
}
}
private Long sessionUserId(HttpSession session) {
if (session == null) {
return null;
}
Object userId = session.getAttribute("userId");
if (userId instanceof Number number) {
return number.longValue();
}
if (userId instanceof String text) {
try {
return Long.parseLong(text);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String text = value.trim();
return text.isBlank() ? null : text;
}
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", status.value());
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
private static final class Validated {
private String title;
private String bodyMarkdown;
private String linkUrl;
private String status;
private ResponseEntity<Map<String, Object>> error;
}
}

View File

@ -0,0 +1,202 @@
package com.pandoli365.bibimbap.controller;
import com.pandoli365.bibimbap.data.UnityFeedSourceData;
import com.pandoli365.bibimbap.mapper.UnityFeedItemsMapper;
import com.pandoli365.bibimbap.mapper.UnityFeedSourcesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.security.SsrfSafeFetcher;
import com.pandoli365.bibimbap.service.UnityFeedPoller;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Controller
public class UnityFeedAdminController {
private final UnityFeedSourcesMapper sourcesMapper;
private final UnityFeedItemsMapper itemsMapper;
private final SsrfSafeFetcher ssrfSafeFetcher;
private final UnityFeedPoller unityFeedPoller;
public UnityFeedAdminController(
UnityFeedSourcesMapper sourcesMapper,
UnityFeedItemsMapper itemsMapper,
SsrfSafeFetcher ssrfSafeFetcher,
UnityFeedPoller unityFeedPoller
) {
this.sourcesMapper = sourcesMapper;
this.itemsMapper = itemsMapper;
this.ssrfSafeFetcher = ssrfSafeFetcher;
this.unityFeedPoller = unityFeedPoller;
}
@GetMapping("/admin/unity-feeds")
public String unityFeedsPage(HttpServletRequest request, Model model) {
model.addAttribute("sources", sourcesMapper.listAll());
model.addAttribute("unackItems", itemsMapper.listUnacknowledged(50));
model.addAttribute("unackCount", itemsMapper.countUnacknowledged());
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession()));
return "admin-unity-feeds";
}
@PostMapping("/admin/unity-feeds")
@Transactional
public ResponseEntity<Map<String, Object>> createSource(
@RequestParam(name = "name", required = false) String name,
@RequestParam(name = "feedUrl", required = false) String feedUrl,
HttpServletRequest request
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
String normalizedName = trimToNull(name);
String normalizedUrl = trimToNull(feedUrl);
if (normalizedName == null || normalizedName.length() > 120) {
return response(HttpStatus.BAD_REQUEST, "피드 이름을 120자 이내로 입력해 주세요.");
}
if (normalizedUrl == null || normalizedUrl.length() > 2048) {
return response(HttpStatus.BAD_REQUEST, "피드 URL 을 2048자 이내로 입력해 주세요.");
}
URI uri;
try {
uri = new URI(normalizedUrl);
} catch (URISyntaxException e) {
return response(HttpStatus.BAD_REQUEST, "피드 URL 이 안전하지 않거나 접근 불가합니다.");
}
if (!ssrfSafeFetcher.isFetchableUrl(uri)) {
return response(HttpStatus.BAD_REQUEST, "피드 URL 이 안전하지 않거나 접근 불가합니다.");
}
UnityFeedSourceData source = new UnityFeedSourceData();
source.setName(normalizedName);
source.setFeedUrl(normalizedUrl);
try {
sourcesMapper.insert(source);
} catch (DuplicateKeyException e) {
return response(HttpStatus.CONFLICT, "이미 등록된 피드 URL 입니다.");
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("message", "피드 소스를 등록했습니다.");
body.put("sourceId", source.getId());
return ResponseEntity.ok(body);
}
@PostMapping("/admin/unity-feeds/{id}/toggle")
@Transactional
public ResponseEntity<Map<String, Object>> toggleSource(
@PathVariable("id") long id,
HttpServletRequest request
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
UnityFeedSourceData existing = sourcesMapper.getById(id);
if (existing == null) {
return response(HttpStatus.NOT_FOUND, "피드 소스를 찾을 수 없습니다.");
}
sourcesMapper.toggle(id);
UnityFeedSourceData updated = sourcesMapper.getById(id);
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("message", "피드 소스 상태를 변경했습니다.");
body.put("isActive", updated == null ? null : updated.getIsActive());
return ResponseEntity.ok(body);
}
@PostMapping("/admin/unity-feeds/{id}/delete")
@Transactional
public ResponseEntity<Map<String, Object>> deleteSource(
@PathVariable("id") long id,
HttpServletRequest request
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
UnityFeedSourceData existing = sourcesMapper.getById(id);
if (existing == null) {
return response(HttpStatus.NOT_FOUND, "피드 소스를 찾을 수 없습니다.");
}
sourcesMapper.delete(id);
return response(HttpStatus.OK, "피드 소스를 삭제했습니다.");
}
@PostMapping("/admin/unity-feeds/items/{itemId}/ack")
@Transactional
public ResponseEntity<Map<String, Object>> acknowledgeItem(
@PathVariable("itemId") long itemId,
HttpServletRequest request
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
itemsMapper.acknowledge(itemId);
return response(HttpStatus.OK, "확인 처리했습니다.");
}
@PostMapping("/admin/unity-feeds/poll")
public ResponseEntity<Map<String, Object>> pollNow(HttpServletRequest request) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
int newCount = 0;
List<UnityFeedSourceData> sources = sourcesMapper.listAll();
if (sources != null) {
for (UnityFeedSourceData source : sources) {
if (source == null || source.getId() == null) {
continue;
}
if (!Boolean.TRUE.equals(source.getIsActive())) {
continue;
}
newCount += unityFeedPoller.pollOnce(source.getId());
}
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("message", "폴링을 완료했습니다.");
body.put("newCount", newCount);
return ResponseEntity.ok(body);
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String text = value.trim();
return text.isBlank() ? null : text;
}
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", status.value());
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
}

View File

@ -0,0 +1,70 @@
package com.pandoli365.bibimbap.data;
import java.time.OffsetDateTime;
public class PostCategoryData {
private Long id;
private String name;
private String slug;
private Integer sortOrder;
private Boolean isActive;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@ -0,0 +1,188 @@
package com.pandoli365.bibimbap.data;
import java.time.OffsetDateTime;
public class PostData {
private Long id;
private Long categoryId;
private Long authorUserId;
private String title;
private String bodyMarkdown;
private String bodySanitizedHtml;
private String linkUrl;
private String ogTitle;
private String ogDescription;
private String ogImageUrl;
private String ogSiteName;
private OffsetDateTime ogFetchedAt;
private String status;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
private OffsetDateTime deletedAt;
private Boolean isDelete;
private String authorDisplayName;
private String categoryName;
private String categorySlug;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getCategoryId() {
return categoryId;
}
public void setCategoryId(Long categoryId) {
this.categoryId = categoryId;
}
public Long getAuthorUserId() {
return authorUserId;
}
public void setAuthorUserId(Long authorUserId) {
this.authorUserId = authorUserId;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getBodyMarkdown() {
return bodyMarkdown;
}
public void setBodyMarkdown(String bodyMarkdown) {
this.bodyMarkdown = bodyMarkdown;
}
public String getBodySanitizedHtml() {
return bodySanitizedHtml;
}
public void setBodySanitizedHtml(String bodySanitizedHtml) {
this.bodySanitizedHtml = bodySanitizedHtml;
}
public String getLinkUrl() {
return linkUrl;
}
public void setLinkUrl(String linkUrl) {
this.linkUrl = linkUrl;
}
public String getOgTitle() {
return ogTitle;
}
public void setOgTitle(String ogTitle) {
this.ogTitle = ogTitle;
}
public String getOgDescription() {
return ogDescription;
}
public void setOgDescription(String ogDescription) {
this.ogDescription = ogDescription;
}
public String getOgImageUrl() {
return ogImageUrl;
}
public void setOgImageUrl(String ogImageUrl) {
this.ogImageUrl = ogImageUrl;
}
public String getOgSiteName() {
return ogSiteName;
}
public void setOgSiteName(String ogSiteName) {
this.ogSiteName = ogSiteName;
}
public OffsetDateTime getOgFetchedAt() {
return ogFetchedAt;
}
public void setOgFetchedAt(OffsetDateTime ogFetchedAt) {
this.ogFetchedAt = ogFetchedAt;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public OffsetDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(OffsetDateTime deletedAt) {
this.deletedAt = deletedAt;
}
public Boolean getIsDelete() {
return isDelete;
}
public void setIsDelete(Boolean isDelete) {
this.isDelete = isDelete;
}
public String getAuthorDisplayName() {
return authorDisplayName;
}
public void setAuthorDisplayName(String authorDisplayName) {
this.authorDisplayName = authorDisplayName;
}
public String getCategoryName() {
return categoryName;
}
public void setCategoryName(String categoryName) {
this.categoryName = categoryName;
}
public String getCategorySlug() {
return categorySlug;
}
public void setCategorySlug(String categorySlug) {
this.categorySlug = categorySlug;
}
}

View File

@ -0,0 +1,89 @@
package com.pandoli365.bibimbap.data;
import java.time.OffsetDateTime;
public class UnityFeedItemData {
private Long id;
private Long sourceId;
private String guid;
private String title;
private String linkUrl;
private OffsetDateTime publishedAt;
private Boolean isAcknowledged;
private OffsetDateTime detectedAt;
private String sourceName;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getSourceId() {
return sourceId;
}
public void setSourceId(Long sourceId) {
this.sourceId = sourceId;
}
public String getGuid() {
return guid;
}
public void setGuid(String guid) {
this.guid = guid;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getLinkUrl() {
return linkUrl;
}
public void setLinkUrl(String linkUrl) {
this.linkUrl = linkUrl;
}
public OffsetDateTime getPublishedAt() {
return publishedAt;
}
public void setPublishedAt(OffsetDateTime publishedAt) {
this.publishedAt = publishedAt;
}
public Boolean getIsAcknowledged() {
return isAcknowledged;
}
public void setIsAcknowledged(Boolean isAcknowledged) {
this.isAcknowledged = isAcknowledged;
}
public OffsetDateTime getDetectedAt() {
return detectedAt;
}
public void setDetectedAt(OffsetDateTime detectedAt) {
this.detectedAt = detectedAt;
}
public String getSourceName() {
return sourceName;
}
public void setSourceName(String sourceName) {
this.sourceName = sourceName;
}
}

View File

@ -0,0 +1,79 @@
package com.pandoli365.bibimbap.data;
import java.time.OffsetDateTime;
public class UnityFeedSourceData {
private Long id;
private String name;
private String feedUrl;
private Boolean isActive;
private OffsetDateTime lastPolledAt;
private String lastSeenGuid;
private String lastError;
private OffsetDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getFeedUrl() {
return feedUrl;
}
public void setFeedUrl(String feedUrl) {
this.feedUrl = feedUrl;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public OffsetDateTime getLastPolledAt() {
return lastPolledAt;
}
public void setLastPolledAt(OffsetDateTime lastPolledAt) {
this.lastPolledAt = lastPolledAt;
}
public String getLastSeenGuid() {
return lastSeenGuid;
}
public void setLastSeenGuid(String lastSeenGuid) {
this.lastSeenGuid = lastSeenGuid;
}
public String getLastError() {
return lastError;
}
public void setLastError(String lastError) {
this.lastError = lastError;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@ -0,0 +1,100 @@
package com.pandoli365.bibimbap.mapper;
import com.pandoli365.bibimbap.data.PostCategoryData;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface PostCategoriesMapper {
@Select("""
SELECT
id,
name,
slug,
sort_order AS sortOrder,
is_active AS isActive,
created_at AS createdAt,
updated_at AS updatedAt
FROM post_categories
WHERE is_active = true
ORDER BY sort_order ASC, id ASC
""")
List<PostCategoryData> listActive();
@Select("""
SELECT
id,
name,
slug,
sort_order AS sortOrder,
is_active AS isActive,
created_at AS createdAt,
updated_at AS updatedAt
FROM post_categories
WHERE id = #{id}
AND is_active = true
""")
PostCategoryData getActive(@Param("id") long id);
@Select("""
SELECT
id,
name,
slug,
sort_order AS sortOrder,
is_active AS isActive,
created_at AS createdAt,
updated_at AS updatedAt
FROM post_categories
WHERE id = #{id}
""")
PostCategoryData getById(@Param("id") long id);
@Insert("""
INSERT INTO post_categories (
name,
slug,
sort_order
) VALUES (
#{name},
#{slug},
#{sortOrder}
)
""")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
int insert(PostCategoryData c);
@Update("""
UPDATE post_categories
SET name = #{name},
slug = #{slug},
sort_order = #{sortOrder},
is_active = #{isActive},
updated_at = now()
WHERE id = #{id}
""")
int update(PostCategoryData c);
@Delete("""
DELETE FROM post_categories
WHERE id = #{id}
""")
int delete(@Param("id") long id);
@Select("""
SELECT count(*)
FROM posts
WHERE category_id = #{categoryId}
AND is_delete = false
AND status = 'PUBLISHED'
""")
int countPostsByCategory(@Param("categoryId") long categoryId);
}

View File

@ -0,0 +1,144 @@
package com.pandoli365.bibimbap.mapper;
import com.pandoli365.bibimbap.data.PostData;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.OffsetDateTime;
import java.util.List;
@Mapper
public interface PostsMapper {
@Select("""
SELECT
p.id,
p.category_id AS categoryId,
p.author_user_id AS authorUserId,
p.title,
p.body_markdown AS bodyMarkdown,
p.body_sanitized_html AS bodySanitizedHtml,
p.link_url AS linkUrl,
p.og_title AS ogTitle,
p.og_description AS ogDescription,
p.og_image_url AS ogImageUrl,
p.og_site_name AS ogSiteName,
p.og_fetched_at AS ogFetchedAt,
p.status,
p.created_at AS createdAt,
p.updated_at AS updatedAt,
p.deleted_at AS deletedAt,
p.is_delete AS isDelete,
u.display_name AS authorDisplayName,
c.name AS categoryName,
c.slug AS categorySlug
FROM posts p
JOIN users u ON u.id = p.author_user_id
JOIN post_categories c ON c.id = p.category_id
WHERE p.status = 'PUBLISHED'
AND p.is_delete = false
AND (#{categoryId} IS NULL OR p.category_id = #{categoryId})
AND (
#{cursorCreatedAt} IS NULL
OR (p.created_at, p.id) < (#{cursorCreatedAt}, #{cursorId})
)
ORDER BY p.created_at DESC, p.id DESC
LIMIT #{limit}
""")
List<PostData> listPublishedKeyset(@Param("cursorCreatedAt") OffsetDateTime cursorCreatedAt,
@Param("cursorId") Long cursorId,
@Param("categoryId") Long categoryId,
@Param("limit") int limit);
@Select("""
SELECT
p.id,
p.category_id AS categoryId,
p.author_user_id AS authorUserId,
p.title,
p.body_markdown AS bodyMarkdown,
p.body_sanitized_html AS bodySanitizedHtml,
p.link_url AS linkUrl,
p.og_title AS ogTitle,
p.og_description AS ogDescription,
p.og_image_url AS ogImageUrl,
p.og_site_name AS ogSiteName,
p.og_fetched_at AS ogFetchedAt,
p.status,
p.created_at AS createdAt,
p.updated_at AS updatedAt,
p.deleted_at AS deletedAt,
p.is_delete AS isDelete,
u.display_name AS authorDisplayName,
c.name AS categoryName,
c.slug AS categorySlug
FROM posts p
JOIN users u ON u.id = p.author_user_id
JOIN post_categories c ON c.id = p.category_id
WHERE p.id = #{id}
AND p.status = 'PUBLISHED'
AND p.is_delete = false
""")
PostData getPublished(@Param("id") long id);
@Insert("""
INSERT INTO posts (
category_id,
author_user_id,
title,
body_markdown,
body_sanitized_html,
link_url,
og_title,
og_description,
og_image_url,
og_site_name,
og_fetched_at,
status
) VALUES (
#{categoryId},
#{authorUserId},
#{title},
#{bodyMarkdown},
#{bodySanitizedHtml},
#{linkUrl},
#{ogTitle},
#{ogDescription},
#{ogImageUrl},
#{ogSiteName},
#{ogFetchedAt},
#{status}
)
""")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
int insert(PostData post);
@Update("""
UPDATE posts
SET title = #{title},
body_markdown = #{bodyMarkdown},
body_sanitized_html = #{bodySanitizedHtml},
link_url = #{linkUrl},
og_title = #{ogTitle},
og_description = #{ogDescription},
og_image_url = #{ogImageUrl},
og_site_name = #{ogSiteName},
og_fetched_at = #{ogFetchedAt},
status = #{status},
updated_at = now()
WHERE id = #{id}
""")
int update(PostData post);
@Update("""
UPDATE posts
SET is_delete = true,
deleted_at = now()
WHERE id = #{id}
""")
int softDelete(@Param("id") long id);
}

View File

@ -0,0 +1,75 @@
package com.pandoli365.bibimbap.mapper;
import com.pandoli365.bibimbap.data.UnityFeedItemData;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface UnityFeedItemsMapper {
@Insert("""
INSERT INTO unity_feed_items (
source_id,
guid,
title,
link_url,
published_at
) VALUES (
#{sourceId},
#{guid},
#{title},
#{linkUrl},
#{publishedAt}
)
ON CONFLICT (source_id, guid) DO NOTHING
""")
int insertIgnoreDup(UnityFeedItemData item);
@Select("""
SELECT
i.id,
i.source_id AS sourceId,
i.guid,
i.title,
i.link_url AS linkUrl,
i.published_at AS publishedAt,
i.is_acknowledged AS isAcknowledged,
i.detected_at AS detectedAt,
s.name AS sourceName
FROM unity_feed_items i
JOIN unity_feed_sources s ON s.id = i.source_id
WHERE i.is_acknowledged = false
ORDER BY i.detected_at DESC
LIMIT #{limit}
""")
List<UnityFeedItemData> listUnacknowledged(@Param("limit") int limit);
@Select("""
SELECT count(*)
FROM unity_feed_items
WHERE is_acknowledged = false
""")
int countUnacknowledged();
@Update("""
UPDATE unity_feed_items
SET is_acknowledged = true
WHERE id = #{itemId}
""")
int acknowledge(@Param("itemId") long itemId);
@Select("""
SELECT EXISTS(
SELECT 1
FROM unity_feed_items
WHERE source_id = #{sourceId}
AND guid = #{guid}
)
""")
boolean existsByGuid(@Param("sourceId") long sourceId, @Param("guid") String guid);
}

View File

@ -0,0 +1,106 @@
package com.pandoli365.bibimbap.mapper;
import com.pandoli365.bibimbap.data.UnityFeedSourceData;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface UnityFeedSourcesMapper {
@Select("""
SELECT
id,
name,
feed_url AS feedUrl,
is_active AS isActive,
last_polled_at AS lastPolledAt,
last_seen_guid AS lastSeenGuid,
last_error AS lastError,
created_at AS createdAt
FROM unity_feed_sources
WHERE is_active = true
ORDER BY id ASC
""")
List<UnityFeedSourceData> listActive();
@Select("""
SELECT
id,
name,
feed_url AS feedUrl,
is_active AS isActive,
last_polled_at AS lastPolledAt,
last_seen_guid AS lastSeenGuid,
last_error AS lastError,
created_at AS createdAt
FROM unity_feed_sources
ORDER BY id ASC
""")
List<UnityFeedSourceData> listAll();
@Select("""
SELECT
id,
name,
feed_url AS feedUrl,
is_active AS isActive,
last_polled_at AS lastPolledAt,
last_seen_guid AS lastSeenGuid,
last_error AS lastError,
created_at AS createdAt
FROM unity_feed_sources
WHERE id = #{id}
""")
UnityFeedSourceData getById(@Param("id") long id);
@Insert("""
INSERT INTO unity_feed_sources (
name,
feed_url,
is_active
) VALUES (
#{name},
#{feedUrl},
#{isActive}
)
""")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
int insert(UnityFeedSourceData s);
@Update("""
UPDATE unity_feed_sources
SET last_seen_guid = #{lastSeenGuid},
last_polled_at = now(),
last_error = NULL
WHERE id = #{id}
""")
int updateCursor(@Param("id") long id, @Param("lastSeenGuid") String lastSeenGuid);
@Update("""
UPDATE unity_feed_sources
SET last_error = #{lastError},
last_polled_at = now()
WHERE id = #{id}
""")
int updateError(@Param("id") long id, @Param("lastError") String lastError);
@Update("""
UPDATE unity_feed_sources
SET is_active = NOT is_active
WHERE id = #{id}
""")
int toggle(@Param("id") long id);
@Delete("""
DELETE FROM unity_feed_sources
WHERE id = #{id}
""")
int delete(@Param("id") long id);
}

View File

@ -0,0 +1,444 @@
package com.pandoli365.bibimbap.security;
import java.io.IOException;
import java.io.InputStream;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.net.http.HttpClient;
import java.net.http.HttpClient.Redirect;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.time.Duration;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import org.springframework.stereotype.Component;
/**
* SSRF 방어를 적용한 외부 URL fetcher.
*
* <p>외부에서 입력된 URL(피드 링크, OG 프리뷰 대상 ) 가져올 내부망/메타데이터
* 엔드포인트로의 요청을 차단한다. JDK {@link HttpClient} followRedirects=NEVER 두고
* 홉마다 host 직접 resolve IP 검증 연결하는 방식으로 DNS rebinding 방어한다.
*
* <h2>연결 메커니즘 (W3-3 fix1, 결함1)</h2>
* <p>이전 구현은 검증된 IP literal authority 핀닝하고 {@code Host} 헤더를 원본 host
* 덮어썼다. 그러나 (1) JDK {@link HttpClient} {@code Host} restricted header 취급해
* {@code HttpRequest.build()} 에서 {@code IllegalArgumentException} 던지므로 모든 fetch
* 실패했고, (2) IP literal connect 하면 HTTPS SNI/인증서 hostname 검증이 IP 기준이 되어
* 실제 HTTPS 대상(유니티 피드/OG 다수) TLS 검증이 깨진다.
*
* <p>이를 해소하기 위해 <b>option (b): 원본 hostname 으로 connect</b> 하되, connect 전에
* resolve <b>모든 IP 검증</b>(전부 공인이어야 통과)하고, -loopback host 대해서는
* connect 직전에 <b>실제 사용될 peer IP 재검증</b>(rebinding 방어 유지)한다.
* hostname 으로 connect 하므로 HTTPS SNI/인증서 검증이 정상 동작하고({@code Host} 헤더 override
* 불필요 restricted header 예외 제거), 사설/예약 IP rebinding 되는 입력은 pre-connect
* re-validate 단계에서 차단된다.
*
* <p>잔여 TOCTOU(검증 시점 IP HttpClient 실제 connect 하는 IP 불일치 가능성) JVM
* positive DNS 캐시({@code networkaddress.cache.ttl}) 동일 lookup 재사용되도록 두어 창을
* 최소화한다. 추가로 connect 직전 재검증을 수행한다.
*/
@Component
public class SsrfSafeFetcher {
static final int MAX_REDIRECTS = 3;
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5);
private static final String METADATA_IPV4 = "169.254.169.254";
/**
* 결함3: 포트 allowlist. OG/피드 대상은 웹서버이므로 표준 포트만 허용한다.
* 비명시 포트(scheme 기본포트) scheme 따라 80/443 으로 간주해 통과시킨다.
*/
private static final Set<Integer> ALLOWED_PORTS = Set.of(80, 443, 8080, 8443);
/**
* 검증·핀닝을 거친 단일 요청 결과. 응답 본문은 maxBytes 제한된 byte[] 이다.
*/
public record FetchResult(int status, String contentType, byte[] body, URI finalUrl) {
}
private final HttpClient httpClient = HttpClient.newBuilder()
.followRedirects(Redirect.NEVER)
.connectTimeout(CONNECT_TIMEOUT)
.build();
/**
* 외부 URL SSRF 방어 하에 가져온다.
*
* <p>scheme/port/IP/redirect/size/timeout/Content-Type 검증 하나라도 실패하거나 예외가
* 발생하면 {@link Optional#empty()} 반환한다(예외를 호출자에 전파하지 않는다).
*
* @param url 가져올 URL
* @param maxBytes 응답 본문 누적 허용 바이트 (초과 거부)
* @param allowedContentTypes 허용할 media type 집합 (파라미터 제외, 소문자 비교)
*/
public Optional<FetchResult> fetch(URI url, long maxBytes, Set<String> allowedContentTypes) {
try {
URI current = url;
for (int hop = 0; hop <= MAX_REDIRECTS; hop++) {
// #1 scheme, #1b port allowlist, #2 resolve, #3 IP 검증.
ValidatedTarget target = validate(current);
if (target == null) {
return Optional.empty();
}
// #4 connect 직전 peer IP 재검증 (rebinding 방어 유지). loopback 테스트 host
// allowLoopbackForTest 경로에서 이미 통과 판정됨.
if (!revalidatePeer(target.host())) {
return Optional.empty();
}
// 원본 hostname 으로 connect HTTPS SNI/인증서 검증 정상. Host 헤더 override 없음.
HttpRequest request = HttpRequest.newBuilder()
.uri(target.requestUri())
.timeout(REQUEST_TIMEOUT)
.GET()
.build();
HttpResponse<InputStream> response = httpClient.send(request, BodyHandlers.ofInputStream());
int status = response.statusCode();
// #5 redirect 재검증: Location URI 만들고 루프 상단에서 재검증.
if (isRedirect(status)) {
Optional<String> location = response.headers().firstValue("Location");
drain(response.body());
if (location.isEmpty()) {
return Optional.empty();
}
current = current.resolve(location.get());
continue;
}
// #8 Content-Type 검증 (media type , 소문자).
String contentType = response.headers().firstValue("Content-Type").orElse("");
String mediaType = mediaType(contentType);
if (!allowedContentTypes.contains(mediaType)) {
drain(response.body());
return Optional.empty();
}
// #6 size cap: Content-Length 신뢰 금지, 스트림에서 누적 카운트.
byte[] body = readCapped(response.body(), maxBytes);
if (body == null) {
return Optional.empty();
}
return Optional.of(new FetchResult(status, mediaType, body, current));
}
// redirect 초과.
return Optional.empty();
} catch (Exception e) {
// #9 graceful: timeout/IOException/InterruptedException 경로 차단.
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
return Optional.empty();
}
}
/**
* fetch 없이 정적으로 #1(scheme) + #1b(port) + #2(resolve) + #3( IP 공인) 검사한다.
* 피드 등록 빠른 거부 판정에 사용한다. 예외는 false.
*/
public boolean isFetchableUrl(URI url) {
try {
return validate(url) != null;
} catch (Exception e) {
return false;
}
}
/**
* #1~#3 검증 connect 대상(원본 hostname authority + 검증 통과한 host) 만든다.
* 검증 실패 null 반환한다.
*/
private ValidatedTarget validate(URI url) {
if (url == null) {
return null;
}
// #1 scheme allowlist.
String scheme = url.getScheme();
if (scheme == null) {
return null;
}
scheme = scheme.toLowerCase(Locale.ROOT);
if (!scheme.equals("http") && !scheme.equals("https")) {
return null;
}
String host = url.getHost();
if (host == null || host.isBlank()) {
return null;
}
// #1b 포트 allowlist (결함3). 명시 경우 scheme 기본포트(http=80/https=443) 허용.
int port = url.getPort();
if (port >= 0 && !isAllowedPort(port)) {
return null;
}
InetAddress[] addrs;
try {
// #2 host resolve IP 해석.
addrs = resolve(host);
} catch (UnknownHostException e) {
return null;
}
if (addrs.length == 0) {
return null;
}
// #3 하나라도 사설/예약이면 거부 (전부 공인이어야 통과).
for (InetAddress addr : addrs) {
if (isBlockedAddress(addr)) {
return null;
}
}
// 원본 URL 그대로 connect 사용 (hostname 기반 HTTPS SNI/인증서 검증 정상).
return new ValidatedTarget(url, host);
}
/**
* #1b 포트 allowlist 판정. OG/피드 대상=웹서버이므로 표준 포트({@code 80,443,8080,8443})
* 허용한다. 로컬 HttpServer ephemeral 포트를 쓰는 테스트는 {@code allowLoopbackForTest}
* 훅과 함께 메서드를 override 임의 포트를 허용한다 <b>운영 기본은 strict allowlist.</b>
*/
protected boolean isAllowedPort(int port) {
return ALLOWED_PORTS.contains(port);
}
/**
* #4 connect 직전 재검증 (DNS rebinding 방어 유지). host 다시 resolve IP
* 여전히 공인인지 확인한다. 하나라도 사설/예약으로 바뀌었으면 거부.
*/
private boolean revalidatePeer(String host) {
InetAddress[] addrs;
try {
addrs = resolve(host);
} catch (UnknownHostException e) {
return false;
}
if (addrs.length == 0) {
return false;
}
for (InetAddress addr : addrs) {
if (isBlockedAddress(addr)) {
return false;
}
}
return true;
}
/**
* #3 차단 대역 판정.
*
* <p>차단: loopback/any-local/link-local/site-local/multicast, 메타데이터 IP,
* IPv4-mapped IPv6, unique-local IPv6(fc00::/7).
*
* <p>fix1 결함2 추가: CGNAT 100.64.0.0/10, class-E 240.0.0.0/4, benchmarking
* 198.18.0.0/15·192.0.0.0/24, broadcast 255.255.255.255, NAT64 64:ff9b::/96(임베드 v4 재검증),
* 6to4 2002::/16(임베드 v4 재검증).
*
* <p>테스트에서 size/timeout/Content-Type 검증 경로를 로컬 서버로 실측하기 위해
* {@link #allowLoopbackForTest} 훅으로만 loopback 통과를 허용한다 기본 false(운영 차단).
*/
protected boolean isBlockedAddress(InetAddress addr) {
if (addr == null) {
return true;
}
// IPv6 별도 처리: IPv4-mapped, NAT64, 6to4 내장 IPv4 추출 재검사.
if (addr instanceof Inet6Address ipv6) {
byte[] raw = ipv6.getAddress();
// IPv4-mapped IPv6(::ffff:a.b.c.d): 내장 IPv4 추출해 재검사.
if (isIpv4Mapped(raw)) {
return checkEmbeddedV4(raw, 12);
}
// NAT64 64:ff9b::/96 0064:ff9b:0:0:0:0:: prefix, 내장 IPv4 마지막 4바이트.
if (isNat64(raw)) {
return checkEmbeddedV4(raw, 12);
}
// 6to4 2002::/16 다음 4바이트(2..5) 캡슐화 IPv4.
if ((raw[0] & 0xFF) == 0x20 && (raw[1] & 0xFF) == 0x02) {
return checkEmbeddedV4(raw, 2);
}
// unique-local IPv6 fc00::/7 ( 바이트 0xFC 또는 0xFD).
int first = raw[0] & 0xFF;
if (first == 0xFC || first == 0xFD) {
return true;
}
}
if (addr.isLoopbackAddress()) {
return !allowLoopbackForTest;
}
if (addr.isAnyLocalAddress()
|| addr.isLinkLocalAddress()
|| addr.isSiteLocalAddress()
|| addr.isMulticastAddress()) {
return true;
}
// 메타데이터 엔드포인트 명시 차단(link-local 이미 잡히나 이중 안전장치).
if (METADATA_IPV4.equals(addr.getHostAddress())) {
return true;
}
// fix1 결함2: IPv4 추가 예약 대역 (isSiteLocalAddress 등으로 잡히는 ).
if (addr instanceof Inet4Address ipv4) {
if (isBlockedV4Range(ipv4.getAddress())) {
return true;
}
}
return false;
}
/**
* IPv6 내부 임베드 IPv4(offset 시작 4바이트) 추출해 IPv4 차단 판정으로 위임한다.
*/
private boolean checkEmbeddedV4(byte[] raw, int offset) {
byte[] v4 = new byte[] { raw[offset], raw[offset + 1], raw[offset + 2], raw[offset + 3] };
try {
return isBlockedAddress(InetAddress.getByAddress(v4));
} catch (UnknownHostException e) {
return true;
}
}
/**
* fix1 결함2: JDK 표준 판정(loopback/site-local/link-local/multicast)으로 잡히는
* IPv4 예약 대역을 추가 차단한다.
*/
private static boolean isBlockedV4Range(byte[] b) {
int o0 = b[0] & 0xFF;
int o1 = b[1] & 0xFF;
int o2 = b[2] & 0xFF;
int o3 = b[3] & 0xFF;
// CGNAT 100.64.0.0/10 (MED) isSiteLocalAddress=false 누락됨.
if (o0 == 100 && o1 >= 64 && o1 <= 127) {
return true;
}
// class-E 240.0.0.0/4 (LOW).
if (o0 >= 240) {
return true;
}
// benchmarking 198.18.0.0/15 (LOW).
if (o0 == 198 && (o1 == 18 || o1 == 19)) {
return true;
}
// IETF protocol assignments 192.0.0.0/24 (LOW).
if (o0 == 192 && o1 == 0 && o2 == 0) {
return true;
}
// limited broadcast 255.255.255.255 (LOW).
if (o0 == 255 && o1 == 255 && o2 == 255 && o3 == 255) {
return true;
}
return false;
}
private static boolean isIpv4Mapped(byte[] raw) {
if (raw.length != 16) {
return false;
}
for (int i = 0; i < 10; i++) {
if (raw[i] != 0) {
return false;
}
}
return (raw[10] & 0xFF) == 0xFF && (raw[11] & 0xFF) == 0xFF;
}
/**
* NAT64 well-known prefix 64:ff9b::/96 판정. 12바이트가 00 64 ff 9b 00..00.
*/
private static boolean isNat64(byte[] raw) {
if (raw.length != 16) {
return false;
}
if ((raw[0] & 0xFF) != 0x00 || (raw[1] & 0xFF) != 0x64
|| (raw[2] & 0xFF) != 0xFF || (raw[3] & 0xFF) != 0x9B) {
return false;
}
for (int i = 4; i < 12; i++) {
if (raw[i] != 0) {
return false;
}
}
return true;
}
/**
* 테스트가 hostIP 매핑을 주입할 있도록 분리한 resolve 지점.
* 운영에서는 시스템 DNS 그대로 사용한다.
*/
protected InetAddress[] resolve(String host) throws UnknownHostException {
return InetAddress.getAllByName(host);
}
/**
* 보안 우회 테스트 : loopback 주소 차단을 무력화한다. <b>테스트 전용, 기본 false.</b>
* 운영 코드에서는 절대 true 설정하지 않는다.
*/
protected boolean allowLoopbackForTest = false;
private static boolean isRedirect(int status) {
return status == 301 || status == 302 || status == 303 || status == 307 || status == 308;
}
/**
* "text/html; charset=utf-8" "text/html". 파라미터 제거 소문자/trim.
*/
private static String mediaType(String contentType) {
if (contentType == null) {
return "";
}
int semi = contentType.indexOf(';');
String base = semi >= 0 ? contentType.substring(0, semi) : contentType;
return base.trim().toLowerCase(Locale.ROOT);
}
/**
* #6 InputStream 에서 maxBytes 까지만 읽는다. 초과 null(거부). Content-Length 무시.
*/
private static byte[] readCapped(InputStream in, long maxBytes) throws IOException {
try (in) {
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[8192];
long total = 0;
int n;
while ((n = in.read(buf)) != -1) {
total += n;
if (total > maxBytes) {
return null;
}
out.write(buf, 0, n);
}
return out.toByteArray();
}
}
private static void drain(InputStream in) {
try (in) {
in.readAllBytes();
} catch (IOException ignored) {
// best-effort close; 차단/리다이렉트 경로에서 본문은 버린다.
}
}
/** 검증을 통과한 connect 대상. requestUri 는 원본 host 기반(HTTPS SNI/인증서 정상). */
private record ValidatedTarget(URI requestUri, String host) {
}
}

View File

@ -0,0 +1,193 @@
package com.pandoli365.bibimbap.service;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.ByteArrayInputStream;
import java.time.OffsetDateTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
@Component
public class FeedParser {
public record FeedItem(String guid, String title, String link, OffsetDateTime publishedAt) {
}
private static final int GUID_CAP = 512;
private static final int TITLE_CAP = 500;
private static final int LINK_CAP = 2048;
public List<FeedItem> parse(byte[] body) {
if (body == null || body.length == 0) {
return List.of();
}
Document doc = parseSafely(body);
if (doc == null) {
return List.of();
}
Element root = doc.getDocumentElement();
if (root == null) {
return List.of();
}
String rootName = localName(root);
if ("feed".equalsIgnoreCase(rootName)) {
return parseAtom(doc);
}
// RSS: root <rss> wraps <channel>, or some feeds expose <channel> at root.
return parseRss(doc);
}
private Document parseSafely(byte[] body) {
try {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
// XXE 방어 (보안 필수)
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
dbf.setNamespaceAware(true);
DocumentBuilder builder = dbf.newDocumentBuilder();
return builder.parse(new ByteArrayInputStream(body));
} catch (Exception e) {
return null;
}
}
private List<FeedItem> parseRss(Document doc) {
List<FeedItem> result = new ArrayList<>();
NodeList items = doc.getElementsByTagNameNS("*", "item");
for (int i = 0; i < items.getLength(); i++) {
Node node = items.item(i);
if (node.getNodeType() != Node.ELEMENT_NODE) {
continue;
}
Element item = (Element) node;
String guid = firstChildText(item, "guid");
String link = firstChildText(item, "link");
if (guid == null || guid.isEmpty()) {
guid = link;
}
if (guid == null || guid.isEmpty()) {
continue;
}
String title = firstChildText(item, "title");
OffsetDateTime published = parseRfc1123(firstChildText(item, "pubDate"));
result.add(new FeedItem(cap(guid, GUID_CAP), cap(title, TITLE_CAP), cap(link, LINK_CAP), published));
}
return result;
}
private List<FeedItem> parseAtom(Document doc) {
List<FeedItem> result = new ArrayList<>();
NodeList entries = doc.getElementsByTagNameNS("*", "entry");
for (int i = 0; i < entries.getLength(); i++) {
Node node = entries.item(i);
if (node.getNodeType() != Node.ELEMENT_NODE) {
continue;
}
Element entry = (Element) node;
String guid = firstChildText(entry, "id");
if (guid == null || guid.isEmpty()) {
continue;
}
String title = firstChildText(entry, "title");
String link = atomLink(entry);
String dateText = firstChildText(entry, "updated");
if (dateText == null || dateText.isEmpty()) {
dateText = firstChildText(entry, "published");
}
OffsetDateTime published = parseIso(dateText);
result.add(new FeedItem(cap(guid, GUID_CAP), cap(title, TITLE_CAP), cap(link, LINK_CAP), published));
}
return result;
}
private String atomLink(Element entry) {
NodeList links = entry.getElementsByTagNameNS("*", "link");
String firstHref = null;
for (int i = 0; i < links.getLength(); i++) {
Node node = links.item(i);
if (node.getNodeType() != Node.ELEMENT_NODE) {
continue;
}
Element link = (Element) node;
String href = link.getAttribute("href");
if (href == null || href.isEmpty()) {
continue;
}
if (firstHref == null) {
firstHref = href;
}
String rel = link.getAttribute("rel");
if (rel == null || rel.isEmpty() || "alternate".equalsIgnoreCase(rel)) {
return href;
}
}
return firstHref;
}
private String firstChildText(Element parent, String childLocalName) {
NodeList children = parent.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node node = children.item(i);
if (node.getNodeType() != Node.ELEMENT_NODE) {
continue;
}
if (childLocalName.equalsIgnoreCase(localName((Element) node))) {
String text = node.getTextContent();
return text == null ? null : text.trim();
}
}
return null;
}
private String localName(Element element) {
String local = element.getLocalName();
return local != null ? local : element.getTagName();
}
private OffsetDateTime parseRfc1123(String value) {
if (value == null || value.isEmpty()) {
return null;
}
// 외부 피드의 pubDate 선행 요일이 실제 날짜와 불일치하는 경우가 흔하다
// (: "Tue, 24 Jun 2026" 이지만 24일은 실제 Wed). RFC_1123_DATE_TIME
// day-of-week 검증하여 이런 입력을 DateTimeException 으로 거부한다.
// 외부 신뢰 불가 입력이므로 선행 요일 토큰을 제거한 파싱한다(요일은 RFC_1123 에서 optional).
String normalized = value.replaceFirst("^\\s*[A-Za-z]{3,},\\s*", "");
try {
// RFC_1123_DATE_TIME "GMT" zone name 으로 해석되어 ZonedDateTime 성격을 가진다.
// OffsetDateTime.parse 직접 받으면 offset 미해석으로 DateTimeException ZonedDateTime 경유.
return ZonedDateTime.parse(normalized, DateTimeFormatter.RFC_1123_DATE_TIME).toOffsetDateTime();
} catch (Exception e) {
return null;
}
}
private OffsetDateTime parseIso(String value) {
if (value == null || value.isEmpty()) {
return null;
}
try {
return OffsetDateTime.parse(value);
} catch (Exception e) {
return null;
}
}
private String cap(String value, int max) {
if (value == null) {
return null;
}
return value.length() <= max ? value : value.substring(0, max);
}
}

View File

@ -0,0 +1,88 @@
package com.pandoli365.bibimbap.service;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import java.util.Set;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.springframework.stereotype.Component;
import com.pandoli365.bibimbap.security.SsrfSafeFetcher;
import com.pandoli365.bibimbap.security.SsrfSafeFetcher.FetchResult;
/**
* 외부 링크의 Open Graph 메타데이터를 SSRF 방어 하에 가져와 프리뷰로 변환한다.
*/
@Component
public class OgPreviewService {
private static final long OG_MAX_BYTES = 512L * 1024L;
private static final Set<String> ALLOWED_CONTENT_TYPES =
Set.of("text/html", "application/xhtml+xml");
// DB 컬럼 길이 한계.
private static final int TITLE_MAX = 300;
private static final int DESCRIPTION_MAX = 600;
private static final int IMAGE_URL_MAX = 2048;
private static final int SITE_NAME_MAX = 200;
private final SsrfSafeFetcher ssrfSafeFetcher;
public OgPreviewService(SsrfSafeFetcher ssrfSafeFetcher) {
this.ssrfSafeFetcher = ssrfSafeFetcher;
}
public record OgPreview(String title, String description, String imageUrl, String siteName) {
}
public Optional<OgPreview> fetch(String linkUrl) {
try {
URI uri = URI.create(linkUrl);
Optional<FetchResult> result =
ssrfSafeFetcher.fetch(uri, OG_MAX_BYTES, ALLOWED_CONTENT_TYPES);
if (result.isEmpty()) {
return Optional.empty();
}
FetchResult fr = result.get();
String html = new String(fr.body(), StandardCharsets.UTF_8);
Document doc = Jsoup.parse(html, fr.finalUrl().toString());
String title = cap(firstNonBlank(ogContent(doc, "og:title"), doc.title()), TITLE_MAX);
String description = cap(ogContent(doc, "og:description"), DESCRIPTION_MAX);
String imageUrl = cap(ogContent(doc, "og:image"), IMAGE_URL_MAX);
String siteName = cap(ogContent(doc, "og:site_name"), SITE_NAME_MAX);
return Optional.of(new OgPreview(title, description, imageUrl, siteName));
} catch (Exception e) {
// graceful: URI 파싱 실패/파싱 예외 경로 차단.
return Optional.empty();
}
}
private static String ogContent(Document doc, String property) {
Element meta = doc.selectFirst("meta[property=" + property + "]");
return meta != null ? meta.attr("content") : null;
}
private static String firstNonBlank(String a, String b) {
if (a != null && !a.isBlank()) {
return a;
}
return b;
}
private static String cap(String value, int max) {
if (value == null) {
return null;
}
String trimmed = value.trim();
if (trimmed.isEmpty()) {
return null;
}
return trimmed.length() > max ? trimmed.substring(0, max) : trimmed;
}
}

View File

@ -0,0 +1,33 @@
package com.pandoli365.bibimbap.service;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;
import org.jsoup.Jsoup;
import org.jsoup.safety.Safelist;
import org.springframework.stereotype.Component;
@Component
public class PostMarkdownService {
// Parser/HtmlRenderer are thread-safe and immutable once built.
private final Parser parser = Parser.builder().build();
private final HtmlRenderer renderer = HtmlRenderer.builder().build();
public String render(String markdown) {
if (markdown == null || markdown.isBlank()) {
return "";
}
String rawHtml = renderer.render(parser.parse(markdown));
// Empty baseUri so relative URLs are not resolved against a host.
return Jsoup.clean(rawHtml, "", buildSafelist());
}
private Safelist buildSafelist() {
return Safelist.basicWithImages()
.addTags("h1", "h2", "h3", "hr")
.addProtocols("a", "href", "http", "https", "mailto")
.addEnforcedAttribute("a", "rel", "nofollow noopener")
.addEnforcedAttribute("a", "target", "_blank")
.addProtocols("img", "src", "http", "https");
}
}

View File

@ -0,0 +1,114 @@
package com.pandoli365.bibimbap.service;
import com.pandoli365.bibimbap.data.UnityFeedItemData;
import com.pandoli365.bibimbap.data.UnityFeedSourceData;
import com.pandoli365.bibimbap.mapper.UnityFeedItemsMapper;
import com.pandoli365.bibimbap.mapper.UnityFeedSourcesMapper;
import com.pandoli365.bibimbap.security.SsrfSafeFetcher;
import com.pandoli365.bibimbap.security.SsrfSafeFetcher.FetchResult;
import com.pandoli365.bibimbap.service.FeedParser.FeedItem;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@Component
public class UnityFeedPoller {
private static final Logger log = LoggerFactory.getLogger(UnityFeedPoller.class);
private static final long FEED_MAX_BYTES = 2L * 1024 * 1024;
private static final Set<String> FEED_CONTENT_TYPES = Set.of(
"application/rss+xml",
"application/atom+xml",
"application/xml",
"text/xml",
"text/html");
private final SsrfSafeFetcher ssrfSafeFetcher;
private final FeedParser feedParser;
private final UnityFeedSourcesMapper sourcesMapper;
private final UnityFeedItemsMapper itemsMapper;
public UnityFeedPoller(SsrfSafeFetcher ssrfSafeFetcher,
FeedParser feedParser,
UnityFeedSourcesMapper sourcesMapper,
UnityFeedItemsMapper itemsMapper) {
this.ssrfSafeFetcher = ssrfSafeFetcher;
this.feedParser = feedParser;
this.sourcesMapper = sourcesMapper;
this.itemsMapper = itemsMapper;
}
@Scheduled(fixedDelayString = "${unity.feed.poll-delay-ms:1800000}")
public void poll() {
for (UnityFeedSourceData source : sourcesMapper.listActive()) {
try {
pollOnce(source.getId());
} catch (Exception e) {
// 소스 실패가 전체 폴링을 멈추지 않게 격리
log.warn("unity feed poll 실패 sourceId={}", source.getId(), e);
}
}
}
public int pollOnce(long sourceId) {
UnityFeedSourceData source = sourcesMapper.getById(sourceId);
if (source == null) {
return 0;
}
try {
URI uri;
try {
uri = URI.create(source.getFeedUrl());
} catch (Exception e) {
sourcesMapper.updateError(sourceId, "feed URL 파싱 실패");
return 0;
}
Optional<FetchResult> fetched = ssrfSafeFetcher.fetch(uri, FEED_MAX_BYTES, FEED_CONTENT_TYPES);
if (fetched.isEmpty()) {
sourcesMapper.updateError(sourceId, "fetch 실패 또는 SSRF 차단");
return 0;
}
List<FeedItem> items = feedParser.parse(fetched.get().body());
if (items.isEmpty()) {
sourcesMapper.updateCursor(sourceId, source.getLastSeenGuid());
return 0;
}
int newCount = 0;
for (FeedItem item : items) {
if (itemsMapper.existsByGuid(sourceId, item.guid())) {
continue;
}
UnityFeedItemData data = new UnityFeedItemData();
data.setSourceId(sourceId);
data.setGuid(item.guid());
data.setTitle(item.title());
data.setLinkUrl(item.link());
data.setPublishedAt(item.publishedAt());
if (itemsMapper.insertIgnoreDup(data) == 1) {
newCount++;
}
}
sourcesMapper.updateCursor(sourceId, items.get(0).guid());
return newCount;
} catch (Exception e) {
log.warn("unity feed pollOnce 실패 sourceId={}", sourceId, e);
try {
sourcesMapper.updateError(sourceId, "폴링 처리 중 오류");
} catch (Exception ignore) {
// updateError 자체 실패도 graceful 전파 금지
}
return 0;
}
}
}

View File

@ -0,0 +1,355 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
<%@ page import="org.springframework.web.util.HtmlUtils" %>
<%@ page import="com.pandoli365.bibimbap.data.PostCategoryData" %>
<%@ page import="java.util.Collections" %>
<%@ page import="java.util.List" %>
<%
String ctx = request.getContextPath();
List<PostCategoryData> categories = Collections.emptyList();
Object categoriesAttr = request.getAttribute("categories");
if (categoriesAttr instanceof List<?>) { categories = (List<PostCategoryData>) categoriesAttr; }
String csrfToken = (String) request.getAttribute("csrfToken");
String csrfTokenHtml = HtmlUtils.htmlEscape(csrfToken == null ? "" : csrfToken);
%>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="_csrf" content="<%= csrfTokenHtml %>">
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
<title>포스트 카테고리 관리 | bibimbap</title>
<style>
html {
color-scheme: light;
--surface: #faf8f5;
--card-bg: #fff;
--text: #1a1a1a;
--text-muted: #5c5c5c;
--accent: #e8a54b;
--accent-soft: rgba(232, 165, 75, 0.16);
--border: rgba(0, 0, 0, 0.08);
--shadow: rgba(0, 0, 0, 0.06);
--field-bg: #fff;
--button-text: #1a1a1a;
}
html[data-theme="dark"] {
color-scheme: dark;
--surface: #121212;
--card-bg: #1e1e1e;
--text: #ece8e1;
--text-muted: #a39e96;
--border: rgba(255, 255, 255, 0.1);
--shadow: rgba(0, 0, 0, 0.35);
--field-bg: #181818;
--button-text: #1a1a1a;
}
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
background: var(--surface);
color: var(--text);
}
.admin-page {
max-width: 78rem;
margin: 0 auto;
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
}
.admin-hero { margin-bottom: 1.5rem; }
.admin-hero__eyebrow {
margin: 0 0 0.35rem;
color: var(--accent);
font-size: 0.75rem;
font-weight: 900;
}
.admin-hero h1 { margin: 0; font-size: 1.9rem; line-height: 1.2; }
.admin-hero p { margin: 0.45rem 0 0; color: var(--text-muted); line-height: 1.6; }
.admin-section {
margin-bottom: 2rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card-bg);
box-shadow: 0 2px 8px var(--shadow);
}
.admin-section h2 { margin: 0 0 1rem; font-size: 1.15rem; }
.admin-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: 0.9rem;
}
.admin-field { display: grid; gap: 0.375rem; }
.admin-field label { font-size: 0.8125rem; font-weight: 700; color: var(--text); }
.admin-field input {
box-sizing: border-box;
height: 3rem;
padding: 0.7rem 0.875rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--field-bg);
color: var(--text);
font: inherit;
font-size: 1rem;
}
.admin-form-actions { margin-top: 1rem; }
.admin-table-wrap { overflow-x: auto; }
.admin-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
.admin-table th, .admin-table td {
padding: 0.65rem 0.5rem;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
.admin-table th {
color: var(--text-muted);
font-size: 0.8125rem;
font-weight: 800;
white-space: nowrap;
}
.admin-actions { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.admin-btn {
min-height: 2.25rem;
padding: 0 0.85rem;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--card-bg);
color: var(--text);
font: inherit;
font-size: 0.8125rem;
font-weight: 800;
cursor: pointer;
}
.admin-btn:hover { border-color: rgba(232, 165, 75, 0.45); }
.admin-btn--primary { border-color: transparent; background: var(--accent); color: var(--button-text); }
.admin-btn--danger { border-color: rgba(200, 60, 60, 0.45); color: #c83c3c; }
.admin-muted { color: var(--text-muted); font-size: 0.8125rem; }
.admin-status {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.75rem;
font-weight: 800;
}
.admin-input-inline {
box-sizing: border-box;
min-height: 2.25rem;
max-width: 9rem;
padding: 0 0.5rem;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--field-bg);
color: var(--text);
font: inherit;
font-size: 0.8125rem;
}
</style>
</head>
<body>
<jsp:include page="/WEB-INF/views/header.jsp"/>
<main class="admin-page">
<section class="admin-hero">
<p class="admin-hero__eyebrow">POST CATEGORY ADMIN</p>
<h1>포스트 카테고리 관리</h1>
<p>포스트 카테고리를 등록·수정·비활성화합니다. 소속 포스트가 있는 카테고리는 삭제할 수 없으며 비활성화를 권장합니다.</p>
</section>
<section class="admin-section">
<h2>신규 카테고리 등록</h2>
<form id="category-create-form" autocomplete="off">
<input type="hidden" name="_csrf" value="<%= csrfTokenHtml %>" />
<div class="admin-form-grid">
<div class="admin-field">
<label for="cat-name">이름 (필수)</label>
<input type="text" id="cat-name" name="name" maxlength="80" placeholder="카테고리 이름" required />
</div>
<div class="admin-field">
<label for="cat-slug">슬러그 (필수)</label>
<input type="text" id="cat-slug" name="slug" maxlength="80" placeholder="slug" required />
</div>
<div class="admin-field">
<label for="cat-sort">정렬 순서</label>
<input type="number" id="cat-sort" name="sortOrder" step="1" value="0" />
</div>
</div>
<div class="admin-form-actions">
<button class="admin-btn admin-btn--primary" type="submit" data-action="create">카테고리 등록</button>
</div>
</form>
</section>
<section class="admin-section">
<h2>카테고리 목록</h2>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th scope="col">이름</th>
<th scope="col">슬러그</th>
<th scope="col">정렬</th>
<th scope="col">활성</th>
<th scope="col">액션</th>
</tr>
</thead>
<tbody>
<% if (categories.isEmpty()) { %>
<tr>
<td colspan="5"><span class="admin-muted">등록된 카테고리가 없습니다.</span></td>
</tr>
<% } else {
for (PostCategoryData cat : categories) {
boolean catActive = Boolean.TRUE.equals(cat.getIsActive());
String catSortOrder = cat.getSortOrder() == null ? "" : String.valueOf(cat.getSortOrder());
%>
<tr>
<td><%= HtmlUtils.htmlEscape(cat.getName() == null ? "" : cat.getName()) %></td>
<td><%= HtmlUtils.htmlEscape(cat.getSlug() == null ? "" : cat.getSlug()) %></td>
<td><%= HtmlUtils.htmlEscape(catSortOrder) %></td>
<td>
<% if (catActive) { %><span class="admin-status">활성</span><% } else { %><span class="admin-muted">비활성</span><% } %>
</td>
<td>
<div class="admin-actions">
<input type="number" class="admin-input-inline"
data-edit-sort="<%= cat.getId() %>"
value="<%= HtmlUtils.htmlEscape(catSortOrder) %>"
step="1" aria-label="정렬 순서 수정" />
<button class="admin-btn" type="button"
data-action="save-sort"
data-cat-id="<%= cat.getId() %>">정렬 저장</button>
<button class="admin-btn" type="button"
data-action="toggle"
data-cat-id="<%= cat.getId() %>"
data-active="<%= catActive %>">
<%= catActive ? "비활성화" : "활성화" %>
</button>
<button class="admin-btn admin-btn--danger" type="button"
data-action="delete"
data-cat-id="<%= cat.getId() %>">삭제</button>
</div>
</td>
</tr>
<% }
} %>
</tbody>
</table>
</div>
</section>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>
<script>
(function () {
var ctx = document.querySelector('base') ? '' : '';
var meta = document.querySelector('meta[name="_csrf"]');
var CSRF_TOKEN = meta ? meta.getAttribute('content') : '';
var BASE = '/admin/post-categories';
function notify(message) {
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
window.BibimbapModal.alert({ title: '포스트 카테고리 관리', message: message });
return;
}
alert(message);
}
function post(url, params) {
var options = {
method: 'POST',
headers: {
'X-CSRF-Token': CSRF_TOKEN,
'Accept': 'application/json'
}
};
if (!params) {
params = new URLSearchParams();
}
params.set('_csrf', CSRF_TOKEN);
options.headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
options.body = params.toString();
return fetch(url, options);
}
function handleResult(res) {
if (res.ok) {
window.location.reload();
return;
}
res.json().then(function (data) {
notify((data && data.message) ? data.message : ('요청을 처리하지 못했습니다. (상태 ' + res.status + ')'));
}).catch(function () {
notify('요청을 처리하지 못했습니다. (상태 ' + res.status + ')');
});
}
function handleError() {
notify('요청 중 오류가 발생했습니다.');
}
function createCategory() {
var name = (document.getElementById('cat-name').value || '').trim();
var slug = (document.getElementById('cat-slug').value || '').trim();
var sort = (document.getElementById('cat-sort').value || '').trim();
if (!name || !slug) {
notify('이름과 슬러그를 입력해 주세요.');
return;
}
var params = new URLSearchParams();
params.set('name', name);
params.set('slug', slug);
if (sort) {
params.set('sortOrder', sort);
}
post(BASE, params).then(handleResult).catch(handleError);
}
function saveSort(catId) {
var input = document.querySelector('[data-edit-sort="' + catId + '"]');
var sort = input ? input.value.trim() : '';
var params = new URLSearchParams();
params.set('sortOrder', sort === '' ? '0' : sort);
post(BASE + '/' + encodeURIComponent(catId), params).then(handleResult).catch(handleError);
}
function toggleActive(catId, currentActive) {
var params = new URLSearchParams();
params.set('isActive', currentActive === 'true' ? 'false' : 'true');
post(BASE + '/' + encodeURIComponent(catId), params).then(handleResult).catch(handleError);
}
function removeCategory(catId) {
if (!window.confirm('이 카테고리를 삭제하시겠습니까? 소속 포스트가 있으면 삭제할 수 없습니다.')) {
return;
}
post(BASE + '/' + encodeURIComponent(catId) + '/delete').then(handleResult).catch(handleError);
}
var form = document.getElementById('category-create-form');
if (form) {
form.addEventListener('submit', function (ev) {
ev.preventDefault();
createCategory();
});
}
document.addEventListener('click', function (ev) {
var btn = ev.target.closest('[data-action]');
if (!btn) {
return;
}
var action = btn.getAttribute('data-action');
var catId = btn.getAttribute('data-cat-id');
if (action === 'save-sort' && catId) {
saveSort(catId);
} else if (action === 'toggle' && catId) {
toggleActive(catId, btn.getAttribute('data-active'));
} else if (action === 'delete' && catId) {
removeCategory(catId);
}
});
})();
</script>
</body>
</html>

View File

@ -0,0 +1,443 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
<%@ page import="org.springframework.web.util.HtmlUtils" %>
<%@ page import="com.pandoli365.bibimbap.data.UnityFeedSourceData" %>
<%@ page import="com.pandoli365.bibimbap.data.UnityFeedItemData" %>
<%@ page import="java.util.Collections" %>
<%@ page import="java.util.List" %>
<%
String ctx = request.getContextPath();
List<UnityFeedSourceData> sources = Collections.emptyList();
Object sourcesAttr = request.getAttribute("sources");
if (sourcesAttr instanceof List<?>) { sources = (List<UnityFeedSourceData>) sourcesAttr; }
List<UnityFeedItemData> unackItems = Collections.emptyList();
Object unackItemsAttr = request.getAttribute("unackItems");
if (unackItemsAttr instanceof List<?>) { unackItems = (List<UnityFeedItemData>) unackItemsAttr; }
Object unackCountAttr = request.getAttribute("unackCount");
String unackCount = unackCountAttr == null ? "0" : String.valueOf(unackCountAttr);
String csrfToken = (String) request.getAttribute("csrfToken");
String csrfTokenHtml = HtmlUtils.htmlEscape(csrfToken == null ? "" : csrfToken);
%>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="_csrf" content="<%= csrfTokenHtml %>">
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
<title>Unity 피드 관리 | bibimbap</title>
<style>
html {
color-scheme: light;
--surface: #faf8f5;
--card-bg: #fff;
--text: #1a1a1a;
--text-muted: #5c5c5c;
--accent: #e8a54b;
--accent-soft: rgba(232, 165, 75, 0.16);
--border: rgba(0, 0, 0, 0.08);
--shadow: rgba(0, 0, 0, 0.06);
--field-bg: #fff;
--button-text: #1a1a1a;
--danger: #c83c3c;
}
html[data-theme="dark"] {
color-scheme: dark;
--surface: #121212;
--card-bg: #1e1e1e;
--text: #ece8e1;
--text-muted: #a39e96;
--border: rgba(255, 255, 255, 0.1);
--shadow: rgba(0, 0, 0, 0.35);
--field-bg: #181818;
--button-text: #1a1a1a;
--danger: #ef7878;
}
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
background: var(--surface);
color: var(--text);
}
.admin-page {
max-width: 78rem;
margin: 0 auto;
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
}
.admin-hero { margin-bottom: 1.5rem; }
.admin-hero__eyebrow {
margin: 0 0 0.35rem;
color: var(--accent);
font-size: 0.75rem;
font-weight: 900;
}
.admin-hero h1 { margin: 0; font-size: 1.9rem; line-height: 1.2; }
.admin-hero p { margin: 0.45rem 0 0; color: var(--text-muted); line-height: 1.6; }
.admin-section {
margin-bottom: 2rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card-bg);
box-shadow: 0 2px 8px var(--shadow);
}
.admin-section h2 { margin: 0 0 1rem; font-size: 1.15rem; }
.admin-section__head {
display: flex;
align-items: center;
gap: 0.6rem;
margin: 0 0 1rem;
}
.admin-section__head h2 { margin: 0; }
.admin-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: 0.9rem;
}
.admin-field { display: grid; gap: 0.375rem; }
.admin-field--full { grid-column: 1 / -1; }
.admin-field label { font-size: 0.8125rem; font-weight: 700; color: var(--text); }
.admin-field input {
box-sizing: border-box;
height: 3rem;
padding: 0.7rem 0.875rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--field-bg);
color: var(--text);
font: inherit;
font-size: 1rem;
}
.admin-form-actions { margin-top: 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap; }
.admin-table-wrap { overflow-x: auto; }
.admin-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
.admin-table th, .admin-table td {
padding: 0.65rem 0.5rem;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
.admin-table th {
color: var(--text-muted);
font-size: 0.8125rem;
font-weight: 800;
white-space: nowrap;
}
.admin-table td a { color: var(--accent); word-break: break-all; }
.admin-actions { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.admin-btn {
min-height: 2.25rem;
padding: 0 0.85rem;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--card-bg);
color: var(--text);
font: inherit;
font-size: 0.8125rem;
font-weight: 800;
cursor: pointer;
}
.admin-btn:hover { border-color: rgba(232, 165, 75, 0.45); }
.admin-btn--primary { border-color: transparent; background: var(--accent); color: var(--button-text); }
.admin-btn--danger { border-color: rgba(200, 60, 60, 0.45); color: var(--danger); }
.admin-muted { color: var(--text-muted); font-size: 0.8125rem; }
.admin-error { color: var(--danger); font-size: 0.8125rem; }
.admin-status {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.75rem;
font-weight: 800;
}
.admin-badge {
display: inline-flex;
align-items: center;
min-width: 1.5rem;
justify-content: center;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: var(--danger);
color: #fff;
font-size: 0.75rem;
font-weight: 900;
}
</style>
</head>
<body>
<jsp:include page="/WEB-INF/views/header.jsp"/>
<main class="admin-page">
<section class="admin-hero">
<p class="admin-hero__eyebrow">UNITY FEED ADMIN</p>
<h1>Unity 피드 관리</h1>
<p>Unity 관련 외부 피드 소스를 등록하고 폴링하여 미확인 항목을 검토합니다. 피드 URL 은 등록 시 SSRF 안전성을 검증합니다.</p>
</section>
<section class="admin-section">
<h2>신규 피드 소스 등록</h2>
<form id="source-create-form" autocomplete="off">
<input type="hidden" name="_csrf" value="<%= csrfTokenHtml %>" />
<div class="admin-form-grid">
<div class="admin-field">
<label for="src-name">이름 (필수)</label>
<input type="text" id="src-name" name="name" maxlength="120" placeholder="피드 이름" required />
</div>
<div class="admin-field admin-field--full">
<label for="src-url">피드 URL (필수)</label>
<input type="url" id="src-url" name="feedUrl" maxlength="2048" placeholder="https://example.com/feed.xml" required />
</div>
</div>
<div class="admin-form-actions">
<button class="admin-btn admin-btn--primary" type="submit" data-action="create">피드 등록</button>
<button class="admin-btn" type="button" data-action="poll">지금 폴링</button>
</div>
</form>
</section>
<section class="admin-section">
<h2>피드 소스 목록</h2>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th scope="col">이름</th>
<th scope="col">피드 URL</th>
<th scope="col">활성</th>
<th scope="col">최근 오류</th>
<th scope="col">액션</th>
</tr>
</thead>
<tbody>
<% if (sources.isEmpty()) { %>
<tr>
<td colspan="5"><span class="admin-muted">등록된 피드 소스가 없습니다.</span></td>
</tr>
<% } else {
for (UnityFeedSourceData src : sources) {
boolean srcActive = Boolean.TRUE.equals(src.getIsActive());
boolean hasError = src.getLastError() != null && !src.getLastError().isBlank();
%>
<tr>
<td><%= HtmlUtils.htmlEscape(src.getName() == null ? "" : src.getName()) %></td>
<td><%= HtmlUtils.htmlEscape(src.getFeedUrl() == null ? "" : src.getFeedUrl()) %></td>
<td>
<% if (srcActive) { %><span class="admin-status">활성</span><% } else { %><span class="admin-muted">비활성</span><% } %>
</td>
<td>
<% if (hasError) { %>
<span class="admin-error"><%= HtmlUtils.htmlEscape(src.getLastError()) %></span>
<% } else { %><span class="admin-muted">-</span><% } %>
</td>
<td>
<div class="admin-actions">
<button class="admin-btn" type="button"
data-action="toggle"
data-src-id="<%= src.getId() %>">
<%= srcActive ? "비활성화" : "활성화" %>
</button>
<button class="admin-btn admin-btn--danger" type="button"
data-action="delete"
data-src-id="<%= src.getId() %>">삭제</button>
</div>
</td>
</tr>
<% }
} %>
</tbody>
</table>
</div>
</section>
<section class="admin-section">
<div class="admin-section__head">
<h2>미확인 항목</h2>
<span class="admin-badge"><%= HtmlUtils.htmlEscape(unackCount) %></span>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th scope="col">제목</th>
<th scope="col">소스</th>
<th scope="col">감지 시각</th>
<th scope="col">액션</th>
</tr>
</thead>
<tbody>
<% if (unackItems.isEmpty()) { %>
<tr>
<td colspan="4"><span class="admin-muted">미확인 항목이 없습니다.</span></td>
</tr>
<% } else {
for (UnityFeedItemData item : unackItems) {
String itemDetectedAt = item.getDetectedAt() == null ? "" : String.valueOf(item.getDetectedAt());
%>
<tr>
<td>
<a href="<%= HtmlUtils.htmlEscape(item.getLinkUrl() == null ? "" : item.getLinkUrl()) %>"
target="_blank" rel="nofollow noopener"><%= HtmlUtils.htmlEscape(item.getTitle() == null ? "" : item.getTitle()) %></a>
</td>
<td><%= HtmlUtils.htmlEscape(item.getSourceName() == null ? "" : item.getSourceName()) %></td>
<td><%= HtmlUtils.htmlEscape(itemDetectedAt) %></td>
<td>
<button class="admin-btn" type="button"
data-action="ack"
data-item-id="<%= item.getId() %>">확인</button>
</td>
</tr>
<% }
} %>
</tbody>
</table>
</div>
</section>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>
<script>
(function () {
var meta = document.querySelector('meta[name="_csrf"]');
var CSRF_TOKEN = meta ? meta.getAttribute('content') : '';
var BASE = '/admin/unity-feeds';
function notify(message) {
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
window.BibimbapModal.alert({ title: 'Unity 피드 관리', message: message });
return;
}
alert(message);
}
function post(url, params) {
var options = {
method: 'POST',
headers: {
'X-CSRF-Token': CSRF_TOKEN,
'Accept': 'application/json'
}
};
if (!params) {
params = new URLSearchParams();
}
params.set('_csrf', CSRF_TOKEN);
options.headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
options.body = params.toString();
return fetch(url, options);
}
function handleResultWithMessage(res, okMessage) {
res.json().then(function (data) {
var message = (data && data.message) ? data.message : null;
if (res.ok) {
notify(okMessage || message || '처리되었습니다.');
window.location.reload();
return;
}
notify(message || ('요청을 처리하지 못했습니다. (상태 ' + res.status + ')'));
}).catch(function () {
if (res.ok) {
window.location.reload();
return;
}
notify('요청을 처리하지 못했습니다. (상태 ' + res.status + ')');
});
}
function reloadOnOk(res) {
if (res.ok) {
window.location.reload();
return;
}
res.json().then(function (data) {
notify((data && data.message) ? data.message : ('요청을 처리하지 못했습니다. (상태 ' + res.status + ')'));
}).catch(function () {
notify('요청을 처리하지 못했습니다. (상태 ' + res.status + ')');
});
}
function handleError() {
notify('요청 중 오류가 발생했습니다.');
}
function createSource() {
var name = (document.getElementById('src-name').value || '').trim();
var url = (document.getElementById('src-url').value || '').trim();
if (!name || !url) {
notify('이름과 피드 URL 을 입력해 주세요.');
return;
}
var params = new URLSearchParams();
params.set('name', name);
params.set('feedUrl', url);
post(BASE, params).then(reloadOnOk).catch(handleError);
}
function pollNow() {
post(BASE + '/poll').then(function (res) {
res.json().then(function (data) {
if (res.ok) {
var count = (data && typeof data.newCount === 'number') ? data.newCount : 0;
notify('폴링 완료: 신규 ' + count + '건');
window.location.reload();
return;
}
notify((data && data.message) ? data.message : ('폴링에 실패했습니다. (상태 ' + res.status + ')'));
}).catch(function () {
if (res.ok) {
window.location.reload();
return;
}
notify('폴링에 실패했습니다. (상태 ' + res.status + ')');
});
}).catch(handleError);
}
function toggleSource(id) {
post(BASE + '/' + encodeURIComponent(id) + '/toggle').then(reloadOnOk).catch(handleError);
}
function removeSource(id) {
if (!window.confirm('이 피드 소스를 삭제하시겠습니까?')) {
return;
}
post(BASE + '/' + encodeURIComponent(id) + '/delete').then(reloadOnOk).catch(handleError);
}
function ackItem(itemId) {
post(BASE + '/items/' + encodeURIComponent(itemId) + '/ack').then(reloadOnOk).catch(handleError);
}
var form = document.getElementById('source-create-form');
if (form) {
form.addEventListener('submit', function (ev) {
ev.preventDefault();
createSource();
});
}
document.addEventListener('click', function (ev) {
var btn = ev.target.closest('[data-action]');
if (!btn) {
return;
}
var action = btn.getAttribute('data-action');
if (action === 'poll') {
ev.preventDefault();
pollNow();
return;
}
var srcId = btn.getAttribute('data-src-id');
var itemId = btn.getAttribute('data-item-id');
if (action === 'toggle' && srcId) {
toggleSource(srcId);
} else if (action === 'delete' && srcId) {
removeSource(srcId);
} else if (action === 'ack' && itemId) {
ackItem(itemId);
}
});
})();
</script>
</body>
</html>

View File

@ -200,6 +200,7 @@
</a>
<nav class="site-header__nav" aria-label="주요 메뉴">
<a class="site-header__nav-link" href="${pageContext.request.contextPath}/recruit">팀원 모집</a>
<a class="site-header__nav-link" href="${pageContext.request.contextPath}/posts">포스팅</a>
</nav>
<div class="site-header__actions">
<button type="button" class="site-header__icon-btn" id="theme-toggle" aria-label="다크 모드로 전환" title="테마 전환">

View File

@ -0,0 +1,187 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
<%@ page import="org.springframework.web.util.HtmlUtils" %>
<%@ page import="com.pandoli365.bibimbap.data.PostData" %>
<%
String ctx = request.getContextPath();
PostData post = (PostData) request.getAttribute("post");
%>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
<title><%= HtmlUtils.htmlEscape(post.getTitle() == null ? "" : post.getTitle()) %> | bibimbap</title>
<style>
html {
color-scheme: light;
--surface: #faf8f5;
--card-bg: #fff;
--text: #1a1a1a;
--text-muted: #5c5c5c;
--accent: #e8a54b;
--accent-soft: rgba(232, 165, 75, 0.16);
--border: rgba(0, 0, 0, 0.08);
--shadow: rgba(0, 0, 0, 0.06);
}
html[data-theme="dark"] {
color-scheme: dark;
--surface: #121212;
--card-bg: #1e1e1e;
--text: #ece8e1;
--text-muted: #a39e96;
--border: rgba(255, 255, 255, 0.1);
--shadow: rgba(0, 0, 0, 0.35);
}
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
background: var(--surface);
color: var(--text);
}
.post-page {
max-width: 48rem;
margin: 0 auto;
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
}
.post-back {
display: inline-flex;
align-items: center;
margin-bottom: 1rem;
color: var(--text-muted);
font-size: 0.875rem;
font-weight: 800;
text-decoration: none;
}
.post-back:hover {
color: var(--accent);
}
.post-category {
display: inline-flex;
align-items: center;
min-height: 1.7rem;
padding: 0 0.6rem;
border-radius: 8px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.75rem;
font-weight: 900;
}
.post-title {
margin: 0.7rem 0 0.45rem;
font-size: 1.85rem;
line-height: 1.25;
letter-spacing: 0;
word-break: break-word;
}
.post-author {
color: var(--text-muted);
font-size: 0.875rem;
}
.post-body {
margin-top: 1.5rem;
line-height: 1.75;
word-break: break-word;
}
.post-body img {
max-width: 100%;
height: auto;
}
.post-body pre {
overflow-x: auto;
padding: 0.9rem;
border-radius: 10px;
background: var(--card-bg);
border: 1px solid var(--border);
}
.og-card {
margin-top: 1.75rem;
display: flex;
gap: 0.9rem;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
background: var(--card-bg);
color: inherit;
text-decoration: none;
box-shadow: 0 2px 8px var(--shadow);
}
.og-card:hover {
border-color: rgba(232, 165, 75, 0.45);
}
.og-card__img {
width: 11rem;
flex-shrink: 0;
object-fit: cover;
background: var(--surface);
}
.og-card__body {
padding: 0.95rem 1rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.og-card__site {
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 800;
}
.og-card__title {
font-size: 1rem;
font-weight: 900;
line-height: 1.4;
word-break: break-word;
}
.og-card__desc {
color: var(--text-muted);
font-size: 0.8125rem;
line-height: 1.5;
word-break: break-word;
}
@media (max-width: 560px) {
.og-card {
flex-direction: column;
}
.og-card__img {
width: 100%;
aspect-ratio: 16 / 9;
}
}
</style>
</head>
<body>
<jsp:include page="/WEB-INF/views/header.jsp"/>
<main class="post-page">
<a class="post-back" href="<%= ctx %>/posts">목록으로</a>
<% if (post.getCategoryName() != null && !post.getCategoryName().isBlank()) { %>
<span class="post-category"><%= HtmlUtils.htmlEscape(post.getCategoryName()) %></span>
<% } %>
<h1 class="post-title"><%= HtmlUtils.htmlEscape(post.getTitle() == null ? "" : post.getTitle()) %></h1>
<p class="post-author"><%= HtmlUtils.htmlEscape(post.getAuthorDisplayName() == null ? "" : post.getAuthorDisplayName()) %></p>
<%-- 본문은 PostMarkdownService 가 sanitize 한 신뢰 HTML 을 직접 출력한다 (유일한 신뢰 원천). --%>
<article class="post-body"><%= post.getBodySanitizedHtml() == null ? "" : post.getBodySanitizedHtml() %></article>
<% if (post.getOgTitle() != null && !post.getOgTitle().isBlank()) { %>
<a class="og-card" href="<%= HtmlUtils.htmlEscape(post.getLinkUrl() == null ? "" : post.getLinkUrl()) %>" rel="nofollow noopener" target="_blank">
<% if (post.getOgImageUrl() != null && !post.getOgImageUrl().isBlank()) { %>
<img class="og-card__img" src="<%= HtmlUtils.htmlEscape(post.getOgImageUrl()) %>" alt="">
<% } %>
<span class="og-card__body">
<% if (post.getOgSiteName() != null && !post.getOgSiteName().isBlank()) { %>
<span class="og-card__site"><%= HtmlUtils.htmlEscape(post.getOgSiteName()) %></span>
<% } %>
<span class="og-card__title"><%= HtmlUtils.htmlEscape(post.getOgTitle()) %></span>
<% if (post.getOgDescription() != null && !post.getOgDescription().isBlank()) { %>
<span class="og-card__desc"><%= HtmlUtils.htmlEscape(post.getOgDescription()) %></span>
<% } %>
</span>
</a>
<% } %>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>
</body>
</html>

View File

@ -0,0 +1,285 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
<%@ page import="org.springframework.web.util.HtmlUtils" %>
<%@ page import="com.pandoli365.bibimbap.data.PostData" %>
<%@ page import="com.pandoli365.bibimbap.data.PostCategoryData" %>
<%@ page import="java.util.Collections" %>
<%@ page import="java.util.List" %>
<%
String ctx = request.getContextPath();
String mode = (String) request.getAttribute("mode");
boolean isEdit = "edit".equals(mode);
PostData post = (PostData) request.getAttribute("post");
List<PostCategoryData> categories = Collections.emptyList();
Object categoriesAttr = request.getAttribute("categories");
if (categoriesAttr instanceof List<?>) { categories = (List<PostCategoryData>) categoriesAttr; }
String csrfToken = (String) request.getAttribute("csrfToken");
String csrfTokenHtml = HtmlUtils.htmlEscape(csrfToken == null ? "" : csrfToken);
Long postId = post == null ? null : post.getId();
Long postCategoryId = post == null ? null : post.getCategoryId();
String postTitle = post == null || post.getTitle() == null ? "" : post.getTitle();
String postBodyMarkdown = post == null || post.getBodyMarkdown() == null ? "" : post.getBodyMarkdown();
String postLinkUrl = post == null || post.getLinkUrl() == null ? "" : post.getLinkUrl();
String postStatus = post == null ? null : post.getStatus();
boolean isDraft = "DRAFT".equals(postStatus);
%>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
<title><%= isEdit ? "포스트 수정" : "포스트 작성" %> | bibimbap</title>
<style>
html {
color-scheme: light;
--surface: #faf8f5;
--card-bg: #fff;
--text: #1a1a1a;
--text-muted: #5c5c5c;
--accent: #e8a54b;
--accent-soft: rgba(232, 165, 75, 0.16);
--border: rgba(0, 0, 0, 0.08);
--shadow: rgba(0, 0, 0, 0.06);
--field-bg: #fff;
}
html[data-theme="dark"] {
color-scheme: dark;
--surface: #121212;
--card-bg: #1e1e1e;
--text: #ece8e1;
--text-muted: #a39e96;
--border: rgba(255, 255, 255, 0.1);
--shadow: rgba(0, 0, 0, 0.35);
--field-bg: #181818;
}
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
background: var(--surface);
color: var(--text);
}
.form-page {
max-width: 48rem;
margin: 0 auto;
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
}
.form-heading {
margin-bottom: 1rem;
}
.form-heading__eyebrow {
margin: 0 0 0.35rem;
color: var(--accent);
font-size: 0.75rem;
font-weight: 900;
}
.form-heading h1 {
margin: 0;
font-size: 1.85rem;
line-height: 1.2;
letter-spacing: 0;
}
.post-form {
padding: 1.35rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card-bg);
box-shadow: 0 2px 8px var(--shadow);
}
.field {
display: grid;
gap: 0.4rem;
margin-bottom: 0.9rem;
}
.field label {
color: var(--text);
font-size: 0.8125rem;
font-weight: 900;
}
.field input,
.field select,
.field textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--field-bg);
color: var(--text);
font: inherit;
font-size: 0.9375rem;
}
.field input,
.field select {
height: 2.85rem;
padding: 0 0.8rem;
}
.field textarea {
min-height: 16rem;
padding: 0.8rem;
line-height: 1.6;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.22);
}
.form-actions {
margin-top: 1rem;
display: flex;
justify-content: flex-end;
gap: 0.6rem;
}
.form-button {
min-height: 2.75rem;
padding: 0 1rem;
border: 1px solid var(--border);
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--card-bg);
color: var(--text);
font-size: 0.9375rem;
font-weight: 900;
text-decoration: none;
cursor: pointer;
}
.form-button--primary {
border-color: transparent;
background: var(--accent);
color: #1a1a1a;
}
</style>
</head>
<body>
<jsp:include page="/WEB-INF/views/header.jsp"/>
<main class="form-page">
<section class="form-heading" aria-labelledby="form-title">
<p class="form-heading__eyebrow">POSTING</p>
<h1 id="form-title"><%= isEdit ? "포스트 수정" : "포스트 작성" %></h1>
</section>
<section class="post-form" aria-label="포스트 입력">
<form id="post-form" novalidate>
<input type="hidden" name="_csrf" value="<%= csrfTokenHtml %>">
<div class="field">
<label for="categoryId">카테고리</label>
<select id="categoryId" name="categoryId" required>
<option value="">카테고리 선택</option>
<% for (PostCategoryData cat : categories) { %>
<option value="<%= cat.getId() %>" <%= postCategoryId != null && postCategoryId.equals(cat.getId()) ? "selected" : "" %>><%= HtmlUtils.htmlEscape(cat.getName() == null ? "" : cat.getName()) %></option>
<% } %>
</select>
</div>
<div class="field">
<label for="title">제목</label>
<input id="title" name="title" type="text" maxlength="200" value="<%= HtmlUtils.htmlEscape(postTitle) %>" placeholder="제목을 입력해 주세요." required>
</div>
<div class="field">
<label for="bodyMarkdown">본문 (Markdown)</label>
<textarea id="bodyMarkdown" name="bodyMarkdown" maxlength="20000" placeholder="마크다운으로 작성해 주세요."><%= HtmlUtils.htmlEscape(postBodyMarkdown) %></textarea>
</div>
<div class="field">
<label for="linkUrl">링크 URL</label>
<input id="linkUrl" name="linkUrl" type="url" maxlength="2048" value="<%= HtmlUtils.htmlEscape(postLinkUrl) %>" placeholder="https://example.com (선택)">
</div>
<div class="field">
<label for="status">공개 상태</label>
<select id="status" name="status">
<option value="PUBLISHED" <%= isDraft ? "" : "selected" %>>공개</option>
<option value="DRAFT" <%= isDraft ? "selected" : "" %>>비공개(임시저장)</option>
</select>
</div>
<div class="form-actions">
<a class="form-button" href="<%= ctx %>/posts">취소</a>
<button class="form-button form-button--primary" type="submit"><%= isEdit ? "수정" : "등록" %></button>
</div>
</form>
</section>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>
<script>
(function () {
var ctx = '<%= HtmlUtils.htmlEscape(ctx) %>';
var mode = '<%= HtmlUtils.htmlEscape(mode == null ? "" : mode) %>';
var postId = '<%= postId == null ? "" : postId %>';
var csrfToken = '<%= csrfTokenHtml %>';
var form = document.getElementById('post-form');
if (!form) return;
var action = mode === 'edit' ? ctx + '/posts/' + postId : ctx + '/posts';
form.addEventListener('submit', function (ev) {
ev.preventDefault();
if (!form.checkValidity()) {
form.reportValidity();
return;
}
var baseHeaders = {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken
};
var headers = window.BibimbapCsrf ? window.BibimbapCsrf.headers(baseHeaders) : baseHeaders;
var body = new URLSearchParams(new FormData(form));
fetch(action, {
method: 'POST',
headers: headers,
body: body
}).then(function (res) {
return res.json().catch(function () {
return { message: '포스트를 저장하지 못했습니다.' };
}).then(function (data) {
if (!res.ok) {
var error = new Error(data && data.message ? data.message : '포스트를 저장하지 못했습니다.');
error.status = res.status;
throw error;
}
return data;
});
}).then(function (data) {
var go = function () {
window.location.href = ctx + (data.location || '/posts');
};
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
window.BibimbapModal.alert({
title: mode === 'edit' ? '포스트 수정 완료' : '포스트 등록 완료',
message: mode === 'edit' ? '포스트가 수정되었습니다.' : '포스트가 등록되었습니다.',
confirmText: '확인',
onConfirm: go
});
} else {
go();
}
}).catch(function (err) {
var message = err.message || '포스트를 저장하지 못했습니다.';
var redirectLogin = function () {
if (err.status === 401) {
window.location.href = ctx + '/login';
}
};
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
window.BibimbapModal.alert({
title: '저장 실패',
message: message,
confirmText: '확인',
onConfirm: redirectLogin
});
} else {
alert(message);
redirectLogin();
}
});
});
})();
</script>
</body>
</html>

View File

@ -0,0 +1,276 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
<%@ page import="org.springframework.web.util.HtmlUtils" %>
<%@ page import="com.pandoli365.bibimbap.data.PostData" %>
<%@ page import="com.pandoli365.bibimbap.data.PostCategoryData" %>
<%@ page import="java.net.URLEncoder" %>
<%@ page import="java.time.OffsetDateTime" %>
<%@ page import="java.util.Collections" %>
<%@ page import="java.util.List" %>
<%
String ctx = request.getContextPath();
List<PostData> posts = Collections.emptyList();
Object postsAttr = request.getAttribute("posts");
if (postsAttr instanceof List<?>) { posts = (List<PostData>) postsAttr; }
List<PostCategoryData> categories = Collections.emptyList();
Object categoriesAttr = request.getAttribute("categories");
if (categoriesAttr instanceof List<?>) { categories = (List<PostCategoryData>) categoriesAttr; }
Long categoryId = (Long) request.getAttribute("categoryId");
boolean hasNext = Boolean.TRUE.equals(request.getAttribute("hasNext"));
OffsetDateTime nextCursorCreatedAt = (OffsetDateTime) request.getAttribute("nextCursorCreatedAt");
Long nextCursorId = (Long) request.getAttribute("nextCursorId");
%>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
<title>포스팅 | bibimbap</title>
<style>
html {
color-scheme: light;
--surface: #faf8f5;
--card-bg: #fff;
--text: #1a1a1a;
--text-muted: #5c5c5c;
--accent: #e8a54b;
--accent-soft: rgba(232, 165, 75, 0.16);
--border: rgba(0, 0, 0, 0.08);
--shadow: rgba(0, 0, 0, 0.06);
}
html[data-theme="dark"] {
color-scheme: dark;
--surface: #121212;
--card-bg: #1e1e1e;
--text: #ece8e1;
--text-muted: #a39e96;
--border: rgba(255, 255, 255, 0.1);
--shadow: rgba(0, 0, 0, 0.35);
}
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
background: var(--surface);
color: var(--text);
}
.posts-page {
max-width: 72rem;
margin: 0 auto;
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
}
.posts-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.posts-hero__eyebrow {
margin: 0 0 0.35rem;
color: var(--accent);
font-size: 0.75rem;
font-weight: 900;
}
.posts-hero h1 {
margin: 0;
font-size: 1.9rem;
line-height: 1.2;
letter-spacing: 0;
}
.posts-write {
min-height: 2.75rem;
padding: 0 1rem;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--accent);
color: #1a1a1a;
font-size: 0.9375rem;
font-weight: 900;
text-decoration: none;
white-space: nowrap;
}
.posts-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.posts-tab {
min-height: 2.25rem;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0 0.95rem;
display: inline-flex;
align-items: center;
background: var(--card-bg);
color: var(--text);
font-size: 0.875rem;
font-weight: 800;
text-decoration: none;
}
.posts-tab.is-active {
border-color: rgba(232, 165, 75, 0.65);
background: var(--accent-soft);
color: var(--accent);
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.post-card {
border: 1px solid var(--border);
border-radius: 12px;
display: flex;
flex-direction: column;
background: var(--card-bg);
color: inherit;
text-decoration: none;
overflow: hidden;
box-shadow: 0 2px 8px var(--shadow);
}
.post-card:hover {
border-color: rgba(232, 165, 75, 0.45);
box-shadow: 0 8px 20px var(--shadow);
transform: translateY(-2px);
}
.post-card__thumb {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
display: block;
background: var(--surface);
}
.post-card__body {
padding: 1.05rem;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.post-card__category {
align-self: flex-start;
min-height: 1.6rem;
padding: 0 0.55rem;
border-radius: 8px;
display: inline-flex;
align-items: center;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.7rem;
font-weight: 900;
}
.post-card h2 {
margin: 0;
font-size: 1.1rem;
line-height: 1.4;
letter-spacing: 0;
word-break: break-word;
}
.post-card__author {
color: var(--text-muted);
font-size: 0.8125rem;
}
.posts-empty {
min-height: 10rem;
border: 1px dashed var(--border);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: var(--card-bg);
color: var(--text-muted);
text-align: center;
}
.posts-more {
margin: 1.5rem auto 0;
max-width: 16rem;
min-height: 2.85rem;
border: 1px solid var(--border);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: var(--card-bg);
color: var(--text);
font-size: 0.9375rem;
font-weight: 900;
text-decoration: none;
}
@media (max-width: 900px) {
.posts-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.posts-hero {
align-items: stretch;
flex-direction: column;
}
.posts-write {
width: 100%;
box-sizing: border-box;
}
.posts-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<jsp:include page="/WEB-INF/views/header.jsp"/>
<main class="posts-page">
<section class="posts-hero" aria-labelledby="posts-title">
<div>
<p class="posts-hero__eyebrow">POSTING</p>
<h1 id="posts-title">포스팅</h1>
</div>
<a class="posts-write" href="<%= ctx %>/posts/new">글쓰기</a>
</section>
<nav class="posts-tabs" aria-label="카테고리">
<a class="posts-tab <%= categoryId == null ? "is-active" : "" %>"
href="<%= ctx %>/posts">전체</a>
<% for (PostCategoryData cat : categories) { %>
<a class="posts-tab <%= categoryId != null && categoryId.equals(cat.getId()) ? "is-active" : "" %>"
href="<%= ctx %>/posts?categoryId=<%= cat.getId() %>"><%= HtmlUtils.htmlEscape(cat.getName() == null ? "" : cat.getName()) %></a>
<% } %>
</nav>
<% if (posts.isEmpty()) { %>
<div class="posts-empty">아직 등록된 포스트가 없습니다.</div>
<% } else { %>
<section class="posts-grid" aria-label="포스트 목록">
<% for (PostData post : posts) { %>
<a class="post-card" href="<%= ctx %>/posts/<%= post.getId() %>">
<% if (post.getOgImageUrl() != null && !post.getOgImageUrl().isBlank()) { %>
<img class="post-card__thumb" src="<%= HtmlUtils.htmlEscape(post.getOgImageUrl()) %>" alt="" loading="lazy">
<% } %>
<div class="post-card__body">
<% if (post.getCategoryName() != null && !post.getCategoryName().isBlank()) { %>
<span class="post-card__category"><%= HtmlUtils.htmlEscape(post.getCategoryName()) %></span>
<% } %>
<h2><%= HtmlUtils.htmlEscape(post.getTitle() == null ? "" : post.getTitle()) %></h2>
<span class="post-card__author"><%= HtmlUtils.htmlEscape(post.getAuthorDisplayName() == null ? "" : post.getAuthorDisplayName()) %></span>
</div>
</a>
<% } %>
</section>
<% if (hasNext) {
StringBuilder moreUrl = new StringBuilder(ctx).append("/posts?");
moreUrl.append("cursorCreatedAt=").append(URLEncoder.encode(String.valueOf(nextCursorCreatedAt), "UTF-8"));
moreUrl.append("&cursorId=").append(nextCursorId);
if (categoryId != null) {
moreUrl.append("&categoryId=").append(categoryId);
}
%>
<a class="posts-more" href="<%= HtmlUtils.htmlEscape(moreUrl.toString()) %>">더보기</a>
<% } %>
<% } %>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>
</body>
</html>

View File

@ -21,14 +21,22 @@ import com.pandoli365.bibimbap.mapper.JamTeamsMapper;
import com.pandoli365.bibimbap.mapper.JamVotesMapper;
import com.pandoli365.bibimbap.mapper.JamsMapper;
import com.pandoli365.bibimbap.mapper.PermissionsMapper;
import com.pandoli365.bibimbap.mapper.PostCategoriesMapper;
import com.pandoli365.bibimbap.mapper.PostsMapper;
import com.pandoli365.bibimbap.mapper.RbacAuditMapper;
import com.pandoli365.bibimbap.mapper.RecruitPostsMapper;
import com.pandoli365.bibimbap.mapper.TagsMapper;
import com.pandoli365.bibimbap.mapper.UnityFeedItemsMapper;
import com.pandoli365.bibimbap.mapper.UnityFeedSourcesMapper;
import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper;
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
import com.pandoli365.bibimbap.mapper.UsersMapper;
import com.pandoli365.bibimbap.security.JamRoleGate;
import com.pandoli365.bibimbap.security.PermissionGate;
import com.pandoli365.bibimbap.security.SsrfSafeFetcher;
import com.pandoli365.bibimbap.service.OgPreviewService;
import com.pandoli365.bibimbap.service.PostMarkdownService;
import com.pandoli365.bibimbap.service.UnityFeedPoller;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
@ -128,6 +136,32 @@ class BibimbapApplicationTests {
@MockBean
private JamRoleGate jamRoleGate;
// W3-3 포스팅 보드 신규 매퍼 6종(MyBatis autoconfigure excluded @MockBean 필수)
@MockBean
private PostsMapper postsMapper;
@MockBean
private PostCategoriesMapper postCategoriesMapper;
@MockBean
private UnityFeedSourcesMapper unityFeedSourcesMapper;
@MockBean
private UnityFeedItemsMapper unityFeedItemsMapper;
// W3-3 신규 컴포넌트/서비스(컨트롤러·서비스 주입 contextLoads 안정화)
@MockBean
private SsrfSafeFetcher ssrfSafeFetcher;
@MockBean
private PostMarkdownService postMarkdownService;
@MockBean
private OgPreviewService ogPreviewService;
@MockBean
private UnityFeedPoller unityFeedPoller;
@Test
void contextLoads() {
}

View File

@ -0,0 +1,288 @@
package com.pandoli365.bibimbap.controller;
import com.pandoli365.bibimbap.data.PostCategoryData;
import com.pandoli365.bibimbap.data.PostData;
import com.pandoli365.bibimbap.mapper.PostCategoriesMapper;
import com.pandoli365.bibimbap.mapper.PostsMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.security.PermissionGate;
import com.pandoli365.bibimbap.security.PermissionKeys;
import com.pandoli365.bibimbap.service.OgPreviewService;
import com.pandoli365.bibimbap.service.PostMarkdownService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.ui.ExtendedModelMap;
import org.springframework.ui.Model;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* PostController 단위 테스트 (plain Mockito, MockMvc 미사용 동일 패키지 컨벤션).
*
* <p>create/update/delete 게이트 순서(CSRF인증POST_WRITE 인가검증)
* keyset 페이징 hasNext 처리를 검증한다. 차단 경로마다 mapper 쓰기(insert/softDelete)
* 호출되지 않음을 verify(never) 확인해 "쓰기 차단"(VP-5) grounding 한다.
*
* <p>설계 매핑: VP-1(POST_WRITE 게이트), VP-5(상태변경 CSRF + 미인가 쓰기 차단),
* VP-6(keyset 페이징 hasNext).
*/
@ExtendWith(MockitoExtension.class)
class PostControllerTest {
private static final long CATEGORY_ID = 3L;
private static final long USER_ID = 5L;
private static final long POST_ID = 1L;
private static final int PAGE_SIZE = 20;
private static final String POST_WRITE = PermissionKeys.POST_WRITE.name();
@Mock
private PostsMapper postsMapper;
@Mock
private PostCategoriesMapper postCategoriesMapper;
@Mock
private PermissionGate gate;
@Mock
private PostMarkdownService postMarkdownService;
@Mock
private OgPreviewService ogPreviewService;
// ==== create (POST /posts): 게이트 + 검증 ====
/** VP-5: CSRF 누락 → 403, 인가 게이트/쓰기 매퍼 진입 전 차단. */
@Test
void create_withoutCsrf_returns403() {
PostController controller = controller();
MockHttpSession session = userSession(USER_ID);
MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response = controller.create(
CATEGORY_ID, "제목", "본문", null, null, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(response.getBody()).containsEntry("status", 403);
verifyNoInteractions(gate);
verify(postsMapper, never()).insert(any());
}
/** 미인증(userId 미설정) → 401, 인가 게이트/쓰기 매퍼 미진입. */
@Test
void create_unauthenticated_returns401() {
PostController controller = controller();
MockHttpSession session = anonymousSession();
MockHttpServletRequest request = csrfPost(session);
ResponseEntity<Map<String, Object>> response = controller.create(
CATEGORY_ID, "제목", "본문", null, null, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
verifyNoInteractions(gate);
verify(postsMapper, never()).insert(any());
}
/** VP-1/VP-5: POST_WRITE 미보유 → 403, 검증/쓰기 매퍼 미진입. */
@Test
void create_withoutPostWrite_returns403() {
PostController controller = controller();
MockHttpSession session = userSession(USER_ID);
MockHttpServletRequest request = csrfPost(session);
when(gate.has(any(), eq(POST_WRITE))).thenReturn(false);
ResponseEntity<Map<String, Object>> response = controller.create(
CATEGORY_ID, "제목", "본문", null, null, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verify(postsMapper, never()).insert(any());
verifyNoInteractions(postCategoriesMapper);
}
/** VP-1: 권한 보유 + 유효 입력 → 200 {postId, location}, insert 1회. */
@Test
void create_withPostWrite_returns200() {
PostController controller = controller();
MockHttpSession session = userSession(USER_ID);
MockHttpServletRequest request = csrfPost(session);
when(gate.has(any(), eq(POST_WRITE))).thenReturn(true);
when(postCategoriesMapper.getActive(CATEGORY_ID)).thenReturn(activeCategory());
when(postMarkdownService.render(any())).thenReturn("<p>본문</p>");
when(postsMapper.insert(any())).thenAnswer(inv -> {
((PostData) inv.getArgument(0)).setId(POST_ID);
return 1;
});
ResponseEntity<Map<String, Object>> response = controller.create(
CATEGORY_ID, "제목", "본문", null, "PUBLISHED", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("postId", POST_ID);
assertThat(response.getBody()).containsEntry("location", "/posts/" + POST_ID);
verify(postsMapper).insert(any());
}
/** 검증 실패(title 201자) → 400, 쓰기 매퍼/카테고리 조회 미진입. */
@Test
void create_titleTooLong_returns4xx() {
PostController controller = controller();
MockHttpSession session = userSession(USER_ID);
MockHttpServletRequest request = csrfPost(session);
when(gate.has(any(), eq(POST_WRITE))).thenReturn(true);
String longTitle = "x".repeat(201);
ResponseEntity<Map<String, Object>> response = controller.create(
CATEGORY_ID, longTitle, "본문", null, null, request, session);
assertThat(response.getStatusCode().is4xxClientError()).isTrue();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verify(postsMapper, never()).insert(any());
// title 검증이 categoryId 조회보다 먼저라 getActive 미호출.
verify(postCategoriesMapper, never()).getActive(anyLong());
}
/** 카테고리 없음(getActive→null) → 404, 쓰기 매퍼 미진입. */
@Test
void create_unknownCategory_returns404() {
PostController controller = controller();
MockHttpSession session = userSession(USER_ID);
MockHttpServletRequest request = csrfPost(session);
when(gate.has(any(), eq(POST_WRITE))).thenReturn(true);
when(postCategoriesMapper.getActive(CATEGORY_ID)).thenReturn(null);
ResponseEntity<Map<String, Object>> response = controller.create(
CATEGORY_ID, "제목", "본문", null, null, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
verify(postsMapper, never()).insert(any());
}
// ==== delete (POST /posts/{id}/delete): 게이트 ====
/** VP-1/VP-5: delete 도 POST_WRITE 게이트 — 미보유 → 403, softDelete 미수행. */
@Test
void delete_withoutPostWrite_returns403() {
PostController controller = controller();
MockHttpSession session = userSession(USER_ID);
MockHttpServletRequest request = csrfPost(session);
when(gate.has(any(), eq(POST_WRITE))).thenReturn(false);
ResponseEntity<Map<String, Object>> response =
controller.delete(POST_ID, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verify(postsMapper, never()).softDelete(anyLong());
}
// ==== list (GET /posts): keyset 페이징 ====
/** VP-6: listPublishedKeyset 가 PAGE_SIZE+1(21)건 → hasNext=true, 표시 20건. */
@Test
void list_keysetPaging() {
PostController controller = controller();
when(postsMapper.listPublishedKeyset(any(), any(), any(), eq(PAGE_SIZE + 1)))
.thenReturn(rows(PAGE_SIZE + 1));
lenient().when(postCategoriesMapper.listActive()).thenReturn(List.of());
Model model = new ExtendedModelMap();
String view = controller.list(null, null, null, model);
assertThat(view).isEqualTo("posts-list");
assertThat(model.getAttribute("hasNext")).isEqualTo(true);
@SuppressWarnings("unchecked")
List<PostData> shown = (List<PostData>) model.getAttribute("posts");
assertThat(shown).hasSize(PAGE_SIZE);
assertThat(model.getAttribute("nextCursorId")).isNotNull();
}
/** VP-6: PAGE_SIZE 이하(20)면 hasNext=false, 커서 미노출. */
@Test
void list_lastPage_hasNextFalse() {
PostController controller = controller();
when(postsMapper.listPublishedKeyset(any(), any(), any(), eq(PAGE_SIZE + 1)))
.thenReturn(rows(PAGE_SIZE));
lenient().when(postCategoriesMapper.listActive()).thenReturn(List.of());
Model model = new ExtendedModelMap();
controller.list(null, null, null, model);
assertThat(model.getAttribute("hasNext")).isEqualTo(false);
@SuppressWarnings("unchecked")
List<PostData> shown = (List<PostData>) model.getAttribute("posts");
assertThat(shown).hasSize(PAGE_SIZE);
assertThat(model.getAttribute("nextCursorId")).isNull();
}
// ==== helpers ====
private PostController controller() {
return new PostController(postsMapper, postCategoriesMapper, gate,
postMarkdownService, ogPreviewService);
}
private PostCategoryData activeCategory() {
PostCategoryData category = new PostCategoryData();
category.setId(CATEGORY_ID);
category.setName("공지");
category.setSlug("notice");
category.setIsActive(true);
return category;
}
private List<PostData> rows(int count) {
List<PostData> rows = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
PostData post = new PostData();
post.setId((long) (i + 1));
rows.add(post);
}
return rows;
}
private MockHttpSession userSession(long userId) {
MockHttpSession session = new MockHttpSession();
session.setAttribute("userId", userId);
CsrfTokens.getOrCreate(session);
return session;
}
/** userId 미설정 — CSRF 통과를 위해 토큰만 보유. */
private MockHttpSession anonymousSession() {
MockHttpSession session = new MockHttpSession();
CsrfTokens.getOrCreate(session);
return session;
}
private MockHttpServletRequest csrfPost(MockHttpSession session) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setSession(session);
request.addHeader(CsrfTokens.HEADER_NAME,
(String) session.getAttribute(CsrfTokens.SESSION_ATTRIBUTE));
return request;
}
private MockHttpServletRequest noCsrfPost(MockHttpSession session) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setSession(session);
return request;
}
}

View File

@ -0,0 +1,351 @@
package com.pandoli365.bibimbap.security;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import com.pandoli365.bibimbap.security.SsrfSafeFetcher.FetchResult;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
class SsrfSafeFetcherTest {
private static final Set<String> HTML = Set.of("text/html");
private static final long MAX = 64 * 1024;
private HttpServer server;
@AfterEach
void stopServer() {
if (server != null) {
server.stop(0);
server = null;
}
}
/**
* resolve() stub 으로 주입하고, 로컬 HttpServer 실측을 위해 loopback 차단을
* 선택적으로 무력화할 있는 테스트 전용 서브클래스.
*/
static class TestableFetcher extends SsrfSafeFetcher {
final Map<String, InetAddress[]> stub = new HashMap<>();
private boolean allowAnyPort = false;
TestableFetcher allowLoopback() {
this.allowLoopbackForTest = true;
// 로컬 HttpServer ephemeral 포트를 쓰므로 포트 allowlist 완화한다(테스트 전용).
this.allowAnyPort = true;
return this;
}
@Override
protected boolean isAllowedPort(int port) {
return allowAnyPort || super.isAllowedPort(port);
}
@Override
protected InetAddress[] resolve(String host) throws UnknownHostException {
InetAddress[] stubbed = stub.get(host);
if (stubbed != null) {
return stubbed;
}
return super.resolve(host);
}
}
private static InetAddress addr(String literal) throws UnknownHostException {
return InetAddress.getByName(literal);
}
// #1 scheme allowlist
@Test
void rejectsNonHttpSchemes() {
SsrfSafeFetcher fetcher = new SsrfSafeFetcher();
assertTrue(fetcher.fetch(URI.create("file:///etc/passwd"), MAX, HTML).isEmpty());
assertTrue(fetcher.fetch(URI.create("gopher://example.com/"), MAX, HTML).isEmpty());
assertTrue(fetcher.fetch(URI.create("ftp://example.com/"), MAX, HTML).isEmpty());
// 유효한 data URI (불법 <> 문자 없이) scheme 거부 의도만 입증.
assertTrue(fetcher.fetch(URI.create("data:text/plain;base64,aGk="), MAX, HTML).isEmpty());
}
// #2 resolve IP 공인 (사설 IP 직접)
@Test
void rejectsPrivateIpLiteral() {
SsrfSafeFetcher fetcher = new SsrfSafeFetcher();
assertTrue(fetcher.fetch(URI.create("http://10.0.0.1/"), MAX, HTML).isEmpty());
assertTrue(fetcher.fetch(URI.create("http://192.168.1.1/"), MAX, HTML).isEmpty());
assertTrue(fetcher.fetch(URI.create("http://172.16.0.1/"), MAX, HTML).isEmpty());
assertFalse(fetcher.isFetchableUrl(URI.create("http://10.0.0.1/")));
}
// #3 loopback
@Test
void rejectsLoopback() {
SsrfSafeFetcher fetcher = new SsrfSafeFetcher();
assertTrue(fetcher.fetch(URI.create("http://127.0.0.1/"), MAX, HTML).isEmpty());
assertTrue(fetcher.fetch(URI.create("http://[::1]/"), MAX, HTML).isEmpty());
assertFalse(fetcher.isFetchableUrl(URI.create("http://127.0.0.1/")));
}
// #4 (3항목 메타데이터) 메타데이터 엔드포인트
@Test
void rejectsMetadataEndpoint() {
SsrfSafeFetcher fetcher = new SsrfSafeFetcher();
assertTrue(fetcher.fetch(
URI.create("http://169.254.169.254/latest/meta-data"), MAX, HTML).isEmpty());
assertFalse(fetcher.isFetchableUrl(URI.create("http://169.254.169.254/")));
}
// #5 (3항목 link-local) 링크로컬
@Test
void rejectsLinkLocal() {
SsrfSafeFetcher fetcher = new SsrfSafeFetcher();
assertTrue(fetcher.fetch(URI.create("http://169.254.1.1/"), MAX, HTML).isEmpty());
assertFalse(fetcher.isFetchableUrl(URI.create("http://169.254.1.1/")));
}
// #6 (3항목 IPv4-mapped IPv6)
@Test
void rejectsIpv4MappedIpv6() throws Exception {
TestableFetcher fetcher = new TestableFetcher();
// host ::ffff:127.0.0.1 (loopback) / ::ffff:10.0.0.1 (사설) 해석되는 시나리오.
fetcher.stub.put("mapped-loopback.test", new InetAddress[] { addr("::ffff:7f00:1") });
fetcher.stub.put("mapped-private.test", new InetAddress[] { addr("::ffff:0a00:1") });
assertTrue(fetcher.fetch(URI.create("http://mapped-loopback.test/"), MAX, HTML).isEmpty());
assertTrue(fetcher.fetch(URI.create("http://mapped-private.test/"), MAX, HTML).isEmpty());
assertFalse(fetcher.isFetchableUrl(URI.create("http://mapped-private.test/")));
}
// #7 DNS rebinding mock (resolve사설)
@Test
void rejectsRebindingToPrivateIp() throws Exception {
TestableFetcher fetcher = new TestableFetcher();
// 정상 외부처럼 보이는 host 실제로는 사설 IP resolve 되는 rebinding 시나리오.
// IP 핀닝 구현은 resolve 결과 IP 자체를 검증하므로 사설 반환 차단된다.
fetcher.stub.put("evil-rebind.test", new InetAddress[] { addr("10.0.0.5") });
fetcher.stub.put("evil-loopback.test", new InetAddress[] { addr("127.0.0.1") });
assertTrue(fetcher.fetch(URI.create("http://evil-rebind.test/"), MAX, HTML).isEmpty());
assertTrue(fetcher.fetch(URI.create("http://evil-loopback.test/"), MAX, HTML).isEmpty());
assertFalse(fetcher.isFetchableUrl(URI.create("http://evil-rebind.test/")));
}
@Test
void rejectsMixedPublicAndPrivateIps() throws Exception {
TestableFetcher fetcher = new TestableFetcher();
// 하나라도 사설이면 거부 (전부 공인이어야 통과).
fetcher.stub.put("mixed.test",
new InetAddress[] { addr("93.184.216.34"), addr("10.0.0.9") });
assertFalse(fetcher.isFetchableUrl(URI.create("http://mixed.test/")));
assertTrue(fetcher.fetch(URI.create("http://mixed.test/"), MAX, HTML).isEmpty());
}
// fix1 결함2: 확장 예약대역 차단 (CGNAT )
@Test
void rejectsCgnatAddress() throws Exception {
// CGNAT 100.64.0.0/10 isSiteLocalAddress=false 기존 . 클라우드 내부 라우팅 차단.
TestableFetcher fetcher = new TestableFetcher();
fetcher.stub.put("cgnat.test", new InetAddress[] { addr("100.64.1.1") });
fetcher.stub.put("cgnat-edge.test", new InetAddress[] { addr("100.127.255.254") });
assertFalse(fetcher.isFetchableUrl(URI.create("http://cgnat.test/")));
assertFalse(fetcher.isFetchableUrl(URI.create("http://cgnat-edge.test/")));
assertTrue(fetcher.fetch(URI.create("http://cgnat.test/"), MAX, HTML).isEmpty());
// 인접 공인 대역(100.63.x.x, 100.128.x.x) CGNAT 아니므로 차단 대상 아님 (경계 입증).
fetcher.stub.put("below-cgnat.test", new InetAddress[] { addr("100.63.255.255") });
assertTrue(fetcher.isFetchableUrl(URI.create("http://below-cgnat.test/")),
"100.63.x 는 CGNAT 미만이므로 차단되지 않아야 한다");
}
@Test
void rejectsOtherReservedV4Ranges() throws Exception {
TestableFetcher fetcher = new TestableFetcher();
fetcher.stub.put("class-e.test", new InetAddress[] { addr("240.0.0.1") });
fetcher.stub.put("bench.test", new InetAddress[] { addr("198.18.0.1") });
fetcher.stub.put("ietf.test", new InetAddress[] { addr("192.0.0.1") });
fetcher.stub.put("bcast.test", new InetAddress[] { addr("255.255.255.255") });
assertFalse(fetcher.isFetchableUrl(URI.create("http://class-e.test/")));
assertFalse(fetcher.isFetchableUrl(URI.create("http://bench.test/")));
assertFalse(fetcher.isFetchableUrl(URI.create("http://ietf.test/")));
assertFalse(fetcher.isFetchableUrl(URI.create("http://bcast.test/")));
}
@Test
void rejectsNat64AndSixToFourEmbeddingPrivateV4() throws Exception {
TestableFetcher fetcher = new TestableFetcher();
// NAT64 64:ff9b::/96 임베드 v4 = 10.0.0.1 (사설).
fetcher.stub.put("nat64.test", new InetAddress[] { addr("64:ff9b::a00:1") });
// 6to4 2002::/16 임베드 v4 = 192.168.0.1 (사설) 2002:c0a8:1::.
fetcher.stub.put("sixtofour.test", new InetAddress[] { addr("2002:c0a8:1::1") });
assertFalse(fetcher.isFetchableUrl(URI.create("http://nat64.test/")));
assertFalse(fetcher.isFetchableUrl(URI.create("http://sixtofour.test/")));
}
// fix1 결함3: 포트 allowlist
@Test
void rejectsNonAllowedPort() throws Exception {
TestableFetcher fetcher = new TestableFetcher();
// 공인 IP 라도 - 포트(22/6379/5432) 거부.
fetcher.stub.put("pub.test", new InetAddress[] { addr("93.184.216.34") });
assertFalse(fetcher.isFetchableUrl(URI.create("http://pub.test:22/")));
assertFalse(fetcher.isFetchableUrl(URI.create("http://pub.test:6379/")));
assertFalse(fetcher.isFetchableUrl(URI.create("https://pub.test:5432/")));
assertTrue(fetcher.fetch(URI.create("http://pub.test:22/"), MAX, HTML).isEmpty());
// 허용 포트 + scheme 기본포트(미명시) 통과.
assertTrue(fetcher.isFetchableUrl(URI.create("http://pub.test:8080/")));
assertTrue(fetcher.isFetchableUrl(URI.create("https://pub.test:8443/")));
assertTrue(fetcher.isFetchableUrl(URI.create("https://pub.test/")));
}
// #8 (항목5) redirect 사설 재검증 차단
@Test
void rejectsRedirectTargetToPrivateHost() throws Exception {
// redirect 대상 host 사설 IP resolve 되면 재검증에서 차단됨을 정적 입증.
TestableFetcher fetcher = new TestableFetcher();
fetcher.stub.put("internal-redirect.test", new InetAddress[] { addr("10.1.2.3") });
assertFalse(fetcher.isFetchableUrl(URI.create("http://internal-redirect.test/admin")));
}
@Test
void redirectToPrivateHostIsBlockedEndToEnd() throws Exception {
// 로컬 서버(공인처럼 통과) 사설 host 302 redirect 다음 재검증 차단.
TestableFetcher fetcher = new TestableFetcher().allowLoopback();
fetcher.stub.put("internal-redirect.test", new InetAddress[] { addr("10.1.2.3") });
startServer(exchange -> {
exchange.getResponseHeaders().add("Location", "http://internal-redirect.test/secret");
exchange.sendResponseHeaders(302, -1);
exchange.close();
});
Optional<FetchResult> result = fetcher.fetch(localUri("/"), MAX, HTML);
assertTrue(result.isEmpty(), "redirect 대상이 사설이면 empty 여야 한다");
}
@Test
void exceedingMaxRedirectsIsBlocked() throws Exception {
// 무한/과다 redirect MAX_REDIRECTS 초과 abort.
TestableFetcher fetcher = new TestableFetcher().allowLoopback();
startServer(exchange -> {
exchange.getResponseHeaders().add("Location", "/loop");
exchange.sendResponseHeaders(302, -1);
exchange.close();
});
Optional<FetchResult> result = fetcher.fetch(localUri("/loop"), MAX, HTML);
assertTrue(result.isEmpty(), "redirect 홉 초과 시 empty 여야 한다");
}
// #9 size / timeout / Content-Type + 정상 (로컬 서버 실측, graceful)
@Test
void rejectsOversizedBody() throws Exception {
TestableFetcher fetcher = new TestableFetcher().allowLoopback();
byte[] big = new byte[(int) MAX + 1024];
startServer(exchange -> {
exchange.getResponseHeaders().add("Content-Type", "text/html");
exchange.sendResponseHeaders(200, big.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(big);
}
});
Optional<FetchResult> result = fetcher.fetch(localUri("/"), MAX, HTML);
assertTrue(result.isEmpty(), "maxBytes 초과 본문은 empty 여야 한다");
}
@Test
void rejectsOnTimeout() throws Exception {
TestableFetcher fetcher = new TestableFetcher().allowLoopback();
startServer(exchange -> {
try {
// REQUEST_TIMEOUT(5s) 보다 길게 지연 request timeout 발동.
Thread.sleep(7000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
try {
exchange.getResponseHeaders().add("Content-Type", "text/html");
exchange.sendResponseHeaders(200, -1);
exchange.close();
} catch (IOException ignored) {
// 클라이언트 timeout 응답 시도는 무시.
}
});
Optional<FetchResult> result = fetcher.fetch(localUri("/"), MAX, HTML);
assertTrue(result.isEmpty(), "응답 지연 시 timeout 으로 empty 여야 한다");
}
@Test
void rejectsDisallowedContentType() throws Exception {
TestableFetcher fetcher = new TestableFetcher().allowLoopback();
startServer(exchange -> writeBody(exchange, "text/plain", "hello"));
Optional<FetchResult> result = fetcher.fetch(localUri("/"), MAX, HTML);
assertTrue(result.isEmpty(), "허용되지 않은 Content-Type 은 empty 여야 한다");
}
@Test
void fetchesAllowedHtmlSuccessfully() throws Exception {
TestableFetcher fetcher = new TestableFetcher().allowLoopback();
String html = "<html><head><title>ok</title></head><body>hi</body></html>";
startServer(exchange -> writeBody(exchange, "text/html; charset=utf-8", html));
Optional<FetchResult> result = fetcher.fetch(localUri("/"), MAX, HTML);
assertTrue(result.isPresent(), "정상 text/html 은 present 여야 한다");
assertEquals(200, result.get().status());
assertEquals("text/html", result.get().contentType());
assertEquals(html, new String(result.get().body(), StandardCharsets.UTF_8));
}
// helpers
private void startServer(HttpHandler handler) throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", handler);
server.start();
}
private URI localUri(String path) {
return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + path);
}
private static void writeBody(HttpExchange exchange, String contentType, String body) {
try {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", contentType);
exchange.sendResponseHeaders(200, bytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(bytes);
}
} catch (IOException ignored) {
// 테스트 핸들러 best-effort.
}
}
}

View File

@ -0,0 +1,176 @@
package com.pandoli365.bibimbap.service;
import com.pandoli365.bibimbap.service.FeedParser.FeedItem;
import org.junit.jupiter.api.Test;
import java.util.List;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FeedParserTest {
private final FeedParser parser = new FeedParser();
@Test
void parsesRss20() {
String xml = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Unity Blog</title>
<item>
<guid>urn:unity:1</guid>
<title>First Post</title>
<link>https://unity.com/blog/first</link>
<pubDate>Tue, 24 Jun 2026 10:00:00 GMT</pubDate>
</item>
<item>
<guid>urn:unity:2</guid>
<title>Second Post</title>
<link>https://unity.com/blog/second</link>
<pubDate>Wed, 25 Jun 2026 12:30:00 GMT</pubDate>
</item>
</channel>
</rss>
""";
List<FeedItem> items = parser.parse(xml.getBytes(UTF_8));
assertEquals(2, items.size());
FeedItem first = items.get(0);
assertEquals("urn:unity:1", first.guid());
assertEquals("First Post", first.title());
assertEquals("https://unity.com/blog/first", first.link());
assertNotNull(first.publishedAt());
FeedItem second = items.get(1);
assertEquals("urn:unity:2", second.guid());
assertEquals("Second Post", second.title());
assertEquals("https://unity.com/blog/second", second.link());
}
@Test
void parsesRssGuidFallsBackToLink() {
String xml = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<item>
<title>No Guid</title>
<link>https://unity.com/blog/no-guid</link>
</item>
</channel>
</rss>
""";
List<FeedItem> items = parser.parse(xml.getBytes(UTF_8));
assertEquals(1, items.size());
assertEquals("https://unity.com/blog/no-guid", items.get(0).guid());
}
@Test
void parsesAtom10() {
String xml = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Unity News</title>
<entry>
<id>tag:unity,2026:a</id>
<title>Atom First</title>
<link rel="alternate" href="https://unity.com/news/a"/>
<updated>2026-06-24T10:00:00Z</updated>
</entry>
<entry>
<id>tag:unity,2026:b</id>
<title>Atom Second</title>
<link rel="alternate" href="https://unity.com/news/b"/>
<published>2026-06-25T12:30:00Z</published>
</entry>
</feed>
""";
List<FeedItem> items = parser.parse(xml.getBytes(UTF_8));
assertEquals(2, items.size());
FeedItem first = items.get(0);
assertEquals("tag:unity,2026:a", first.guid());
assertEquals("Atom First", first.title());
assertEquals("https://unity.com/news/a", first.link());
assertNotNull(first.publishedAt());
FeedItem second = items.get(1);
assertEquals("tag:unity,2026:b", second.guid());
assertEquals("Atom Second", second.title());
assertEquals("https://unity.com/news/b", second.link());
assertNotNull(second.publishedAt());
}
@Test
void atomPrefersAlternateLink() {
String xml = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<id>tag:unity,2026:c</id>
<title>Multi Link</title>
<link rel="self" href="https://unity.com/news/self"/>
<link rel="alternate" href="https://unity.com/news/alternate"/>
</entry>
</feed>
""";
List<FeedItem> items = parser.parse(xml.getBytes(UTF_8));
assertEquals(1, items.size());
assertEquals("https://unity.com/news/alternate", items.get(0).link());
}
@Test
void blocksXxeExternalEntity() {
// DOCTYPE + external entity 포함 악성 XML disallow-doctype-decl 파싱 거부 기대
String malicious = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE rss [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<rss version="2.0">
<channel>
<item>
<guid>x</guid>
<title>&xxe;</title>
<link>https://example.com/x</link>
</item>
</channel>
</rss>
""";
List<FeedItem> items = assertDoesNotThrow(() -> parser.parse(malicious.getBytes(UTF_8)));
// DOCTYPE 거부로 리스트. 만약 파싱됐더라도 entity 확장되어 시스템 파일 내용이
// 노출되어선 된다.
if (!items.isEmpty()) {
String title = items.get(0).title();
assertFalse(title != null && title.contains("root:"),
"external entity 가 확장되어선 안 된다");
} else {
assertTrue(items.isEmpty());
}
}
@Test
void returnsEmptyForEmptyInput() {
assertTrue(parser.parse(new byte[0]).isEmpty());
assertTrue(parser.parse(null).isEmpty());
}
@Test
void returnsEmptyForBrokenInput() {
byte[] notXml = "this is not xml at all <<< >>>".getBytes(UTF_8);
List<FeedItem> items = assertDoesNotThrow(() -> parser.parse(notXml));
assertTrue(items.isEmpty());
}
}

View File

@ -0,0 +1,53 @@
package com.pandoli365.bibimbap.service;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class PostMarkdownServiceTest {
private final PostMarkdownService service = new PostMarkdownService();
@Test
void stripsScriptTag() {
String html = service.render("<script>alert(1)</script>");
assertFalse(html.contains("<script"), "script tag must be removed: " + html);
}
@Test
void stripsOnErrorAttribute() {
String html = service.render("<img src=x onerror=alert(1)>");
assertFalse(html.contains("onerror"), "onerror attribute must be removed: " + html);
}
@Test
void stripsJavascriptHref() {
String html = service.render("[클릭](javascript:alert(1))");
assertFalse(html.contains("javascript:"), "javascript: protocol must be removed: " + html);
}
@Test
void stripsIframeTag() {
String html = service.render("<iframe src=\"https://evil.example.com\"></iframe>");
assertFalse(html.contains("<iframe"), "iframe tag must be removed: " + html);
}
@Test
void preservesSafeContent() {
String bold = service.render("**굵게**");
assertTrue(bold.contains("<strong>"), "bold should render <strong>: " + bold);
String link = service.render("[링크](https://example.com)");
assertTrue(link.contains("<a"), "link should render <a>: " + link);
assertTrue(link.contains("href=\"https://example.com\""), "link href preserved: " + link);
assertTrue(link.contains("rel=\"nofollow noopener\""), "link rel enforced: " + link);
String list = service.render("- 항목1\n- 항목2");
assertTrue(list.contains("<ul>"), "list should render <ul>: " + list);
assertTrue(list.contains("<li>"), "list should render <li>: " + list);
String heading = service.render("# 제목");
assertTrue(heading.contains("<h1>"), "heading should render <h1>: " + heading);
}
}

View File

@ -0,0 +1,129 @@
package com.pandoli365.bibimbap.service;
import com.pandoli365.bibimbap.data.UnityFeedItemData;
import com.pandoli365.bibimbap.data.UnityFeedSourceData;
import com.pandoli365.bibimbap.mapper.UnityFeedItemsMapper;
import com.pandoli365.bibimbap.mapper.UnityFeedSourcesMapper;
import com.pandoli365.bibimbap.security.SsrfSafeFetcher;
import com.pandoli365.bibimbap.security.SsrfSafeFetcher.FetchResult;
import com.pandoli365.bibimbap.service.FeedParser.FeedItem;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.net.URI;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class UnityFeedPollerTest {
private static final long SOURCE_ID = 7L;
private static final String FEED_URL = "https://unity.com/feed.xml";
@Mock
private SsrfSafeFetcher ssrfSafeFetcher;
@Mock
private FeedParser feedParser;
@Mock
private UnityFeedSourcesMapper sourcesMapper;
@Mock
private UnityFeedItemsMapper itemsMapper;
private UnityFeedPoller poller;
@BeforeEach
void setUp() {
poller = new UnityFeedPoller(ssrfSafeFetcher, feedParser, sourcesMapper, itemsMapper);
}
private UnityFeedSourceData source() {
UnityFeedSourceData source = new UnityFeedSourceData();
source.setId(SOURCE_ID);
source.setName("Unity");
source.setFeedUrl(FEED_URL);
source.setIsActive(true);
return source;
}
private Optional<FetchResult> okFetch() {
return Optional.of(new FetchResult(
200,
"application/rss+xml",
new byte[]{1, 2, 3},
URI.create(FEED_URL)));
}
private FeedItem item(String guid) {
return new FeedItem(guid, "title-" + guid, "https://unity.com/" + guid, null);
}
@Test
void insertsNewGuids() {
when(sourcesMapper.getById(SOURCE_ID)).thenReturn(source());
when(ssrfSafeFetcher.fetch(any(URI.class), anyLong(), any())).thenReturn(okFetch());
when(feedParser.parse(any())).thenReturn(List.of(item("A"), item("B")));
when(itemsMapper.existsByGuid(eq(SOURCE_ID), anyString())).thenReturn(false);
when(itemsMapper.insertIgnoreDup(any(UnityFeedItemData.class))).thenReturn(1);
int newCount = poller.pollOnce(SOURCE_ID);
assertEquals(2, newCount);
verify(itemsMapper, times(2)).insertIgnoreDup(any(UnityFeedItemData.class));
verify(sourcesMapper).updateCursor(SOURCE_ID, "A");
}
@Test
void dedupesAllExistingGuids() {
when(sourcesMapper.getById(SOURCE_ID)).thenReturn(source());
when(ssrfSafeFetcher.fetch(any(URI.class), anyLong(), any())).thenReturn(okFetch());
when(feedParser.parse(any())).thenReturn(List.of(item("A"), item("B")));
when(itemsMapper.existsByGuid(eq(SOURCE_ID), anyString())).thenReturn(true);
int newCount = poller.pollOnce(SOURCE_ID);
assertEquals(0, newCount);
verify(itemsMapper, never()).insertIgnoreDup(any(UnityFeedItemData.class));
verify(sourcesMapper).updateCursor(SOURCE_ID, "A");
}
@Test
void insertsOnlyNewGuidInMixedBatch() {
when(sourcesMapper.getById(SOURCE_ID)).thenReturn(source());
when(ssrfSafeFetcher.fetch(any(URI.class), anyLong(), any())).thenReturn(okFetch());
when(feedParser.parse(any())).thenReturn(List.of(item("A"), item("B")));
when(itemsMapper.existsByGuid(SOURCE_ID, "A")).thenReturn(true);
when(itemsMapper.existsByGuid(SOURCE_ID, "B")).thenReturn(false);
when(itemsMapper.insertIgnoreDup(any(UnityFeedItemData.class))).thenReturn(1);
int newCount = poller.pollOnce(SOURCE_ID);
assertEquals(1, newCount);
verify(itemsMapper, times(1)).insertIgnoreDup(any(UnityFeedItemData.class));
verify(sourcesMapper).updateCursor(SOURCE_ID, "A");
}
@Test
void recordsErrorWhenFetchFails() {
when(sourcesMapper.getById(SOURCE_ID)).thenReturn(source());
when(ssrfSafeFetcher.fetch(any(URI.class), anyLong(), any())).thenReturn(Optional.empty());
int newCount = poller.pollOnce(SOURCE_ID);
assertEquals(0, newCount);
verify(sourcesMapper).updateError(eq(SOURCE_ID), anyString());
verify(itemsMapper, never()).insertIgnoreDup(any(UnityFeedItemData.class));
}
}