diff --git a/db/schema.sql b/db/schema.sql index de881bd..031c3fa 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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; diff --git a/docs/board-ddl.sql b/docs/board-ddl.sql new file mode 100644 index 0000000..2815f5d --- /dev/null +++ b/docs/board-ddl.sql @@ -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; diff --git a/pom.xml b/pom.xml index 05112c9..c135592 100644 --- a/pom.xml +++ b/pom.xml @@ -84,6 +84,16 @@ spring-boot-starter-test test + + org.commonmark + commonmark + 0.22.0 + + + org.jsoup + jsoup + 1.17.2 + diff --git a/src/main/java/com/pandoli365/bibimbap/config/SchedulingConfig.java b/src/main/java/com/pandoli365/bibimbap/config/SchedulingConfig.java new file mode 100644 index 0000000..86b2388 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/config/SchedulingConfig.java @@ -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 { +} diff --git a/src/main/java/com/pandoli365/bibimbap/controller/PostAdminController.java b/src/main/java/com/pandoli365/bibimbap/controller/PostAdminController.java new file mode 100644 index 0000000..5aa359a --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/PostAdminController.java @@ -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> 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 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> 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> 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> response(HttpStatus status, String message) { + Map body = new LinkedHashMap<>(); + body.put("status", status.value()); + body.put("message", message); + return ResponseEntity.status(status).body(body); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/controller/PostController.java b/src/main/java/com/pandoli365/bibimbap/controller/PostController.java new file mode 100644 index 0000000..841df37 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/PostController.java @@ -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 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 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> 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 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> 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 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> 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 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 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> response(HttpStatus status, String message) { + Map 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> error; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/controller/UnityFeedAdminController.java b/src/main/java/com/pandoli365/bibimbap/controller/UnityFeedAdminController.java new file mode 100644 index 0000000..189fa90 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/UnityFeedAdminController.java @@ -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> 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 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> 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 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> 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> 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> pollNow(HttpServletRequest request) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } + + int newCount = 0; + List 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 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> response(HttpStatus status, String message) { + Map body = new LinkedHashMap<>(); + body.put("status", status.value()); + body.put("message", message); + return ResponseEntity.status(status).body(body); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/PostCategoryData.java b/src/main/java/com/pandoli365/bibimbap/data/PostCategoryData.java new file mode 100644 index 0000000..4890330 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/PostCategoryData.java @@ -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; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/PostData.java b/src/main/java/com/pandoli365/bibimbap/data/PostData.java new file mode 100644 index 0000000..fa93acf --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/PostData.java @@ -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; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/UnityFeedItemData.java b/src/main/java/com/pandoli365/bibimbap/data/UnityFeedItemData.java new file mode 100644 index 0000000..de0e3b3 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/UnityFeedItemData.java @@ -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; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/UnityFeedSourceData.java b/src/main/java/com/pandoli365/bibimbap/data/UnityFeedSourceData.java new file mode 100644 index 0000000..8e1b5a6 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/UnityFeedSourceData.java @@ -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; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/PostCategoriesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/PostCategoriesMapper.java new file mode 100644 index 0000000..0566b4f --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/PostCategoriesMapper.java @@ -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 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); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/PostsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/PostsMapper.java new file mode 100644 index 0000000..9c210e5 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/PostsMapper.java @@ -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 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); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/UnityFeedItemsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/UnityFeedItemsMapper.java new file mode 100644 index 0000000..b1b9e3d --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/UnityFeedItemsMapper.java @@ -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 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); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/UnityFeedSourcesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/UnityFeedSourcesMapper.java new file mode 100644 index 0000000..eff6486 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/UnityFeedSourcesMapper.java @@ -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 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 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); +} diff --git a/src/main/java/com/pandoli365/bibimbap/security/SsrfSafeFetcher.java b/src/main/java/com/pandoli365/bibimbap/security/SsrfSafeFetcher.java new file mode 100644 index 0000000..8602fe3 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/security/SsrfSafeFetcher.java @@ -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. + * + *

외부에서 입력된 URL(피드 링크, OG 프리뷰 대상 등)을 가져올 때 내부망/메타데이터 + * 엔드포인트로의 요청을 차단한다. JDK {@link HttpClient}를 followRedirects=NEVER 로 두고 + * 매 홉마다 host 를 직접 resolve → 전 IP 검증 후 연결하는 방식으로 DNS rebinding 을 방어한다. + * + *

연결 메커니즘 (W3-3 fix1, 결함1)

+ *

이전 구현은 검증된 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 검증이 깨진다. + * + *

이를 해소하기 위해 option (b): 원본 hostname 으로 connect 하되, connect 전에 + * resolve 한 모든 IP 를 검증(전부 공인이어야 통과)하고, 비-loopback host 에 대해서는 + * connect 직전에 실제 사용될 peer IP 를 한 번 더 재검증(rebinding 방어 유지)한다. + * hostname 으로 connect 하므로 HTTPS SNI/인증서 검증이 정상 동작하고({@code Host} 헤더 override + * 불필요 → restricted header 예외 제거), 사설/예약 IP 로 rebinding 되는 입력은 pre-connect 및 + * re-validate 양 단계에서 차단된다. + * + *

잔여 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 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 방어 하에 가져온다. + * + *

scheme/port/IP/redirect/size/timeout/Content-Type 검증 중 하나라도 실패하거나 예외가 + * 발생하면 {@link Optional#empty()} 를 반환한다(예외를 호출자에 전파하지 않는다). + * + * @param url 가져올 URL + * @param maxBytes 응답 본문 누적 허용 바이트 (초과 시 거부) + * @param allowedContentTypes 허용할 media type 집합 (파라미터 제외, 소문자 비교) + */ + public Optional fetch(URI url, long maxBytes, Set 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 response = httpClient.send(request, BodyHandlers.ofInputStream()); + int status = response.statusCode(); + + // #5 redirect 매 홉 재검증: Location 을 새 URI 로 만들고 루프 상단에서 재검증. + if (isRedirect(status)) { + Optional 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 해 임의 포트를 허용한다 — 운영 기본은 strict allowlist. + */ + 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 차단 대역 판정. + * + *

차단: loopback/any-local/link-local/site-local/multicast, 메타데이터 IP, + * IPv4-mapped IPv6, unique-local IPv6(fc00::/7). + * + *

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 재검증). + * + *

테스트에서 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; + } + + /** + * 테스트가 host→IP 매핑을 주입할 수 있도록 분리한 resolve 지점. + * 운영에서는 시스템 DNS 를 그대로 사용한다. + */ + protected InetAddress[] resolve(String host) throws UnknownHostException { + return InetAddress.getAllByName(host); + } + + /** + * 보안 우회 테스트 훅: loopback 주소 차단을 무력화한다. 테스트 전용, 기본 false. + * 운영 코드에서는 절대 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) { + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/service/FeedParser.java b/src/main/java/com/pandoli365/bibimbap/service/FeedParser.java new file mode 100644 index 0000000..1c5f4a9 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/service/FeedParser.java @@ -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 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 wraps , or some feeds expose 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 parseRss(Document doc) { + List 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 parseAtom(Document doc) { + List 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); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/service/OgPreviewService.java b/src/main/java/com/pandoli365/bibimbap/service/OgPreviewService.java new file mode 100644 index 0000000..79edb90 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/service/OgPreviewService.java @@ -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 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 fetch(String linkUrl) { + try { + URI uri = URI.create(linkUrl); + Optional 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; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/service/PostMarkdownService.java b/src/main/java/com/pandoli365/bibimbap/service/PostMarkdownService.java new file mode 100644 index 0000000..137b001 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/service/PostMarkdownService.java @@ -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"); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/service/UnityFeedPoller.java b/src/main/java/com/pandoli365/bibimbap/service/UnityFeedPoller.java new file mode 100644 index 0000000..bbf4e80 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/service/UnityFeedPoller.java @@ -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 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 fetched = ssrfSafeFetcher.fetch(uri, FEED_MAX_BYTES, FEED_CONTENT_TYPES); + if (fetched.isEmpty()) { + sourcesMapper.updateError(sourceId, "fetch 실패 또는 SSRF 차단"); + return 0; + } + + List 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; + } + } +} diff --git a/src/main/webapp/WEB-INF/views/admin-post-categories.jsp b/src/main/webapp/WEB-INF/views/admin-post-categories.jsp new file mode 100644 index 0000000..2b21c2f --- /dev/null +++ b/src/main/webapp/WEB-INF/views/admin-post-categories.jsp @@ -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 categories = Collections.emptyList(); + Object categoriesAttr = request.getAttribute("categories"); + if (categoriesAttr instanceof List) { categories = (List) categoriesAttr; } + String csrfToken = (String) request.getAttribute("csrfToken"); + String csrfTokenHtml = HtmlUtils.htmlEscape(csrfToken == null ? "" : csrfToken); +%> + + + + + + + + 포스트 카테고리 관리 | bibimbap + + + + +

+
+

POST CATEGORY ADMIN

+

포스트 카테고리 관리

+

포스트 카테고리를 등록·수정·비활성화합니다. 소속 포스트가 있는 카테고리는 삭제할 수 없으며 비활성화를 권장합니다.

+
+ +
+

신규 카테고리 등록

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ +
+

카테고리 목록

+
+ + + + + + + + + + + + <% if (categories.isEmpty()) { %> + + + + <% } else { + for (PostCategoryData cat : categories) { + boolean catActive = Boolean.TRUE.equals(cat.getIsActive()); + String catSortOrder = cat.getSortOrder() == null ? "" : String.valueOf(cat.getSortOrder()); + %> + + + + + + + + <% } + } %> + +
이름슬러그정렬활성액션
등록된 카테고리가 없습니다.
<%= HtmlUtils.htmlEscape(cat.getName() == null ? "" : cat.getName()) %><%= HtmlUtils.htmlEscape(cat.getSlug() == null ? "" : cat.getSlug()) %><%= HtmlUtils.htmlEscape(catSortOrder) %> + <% if (catActive) { %>활성<% } else { %>비활성<% } %> + +
+ + + + +
+
+
+
+
+ + + + diff --git a/src/main/webapp/WEB-INF/views/admin-unity-feeds.jsp b/src/main/webapp/WEB-INF/views/admin-unity-feeds.jsp new file mode 100644 index 0000000..eacac8c --- /dev/null +++ b/src/main/webapp/WEB-INF/views/admin-unity-feeds.jsp @@ -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 sources = Collections.emptyList(); + Object sourcesAttr = request.getAttribute("sources"); + if (sourcesAttr instanceof List) { sources = (List) sourcesAttr; } + List unackItems = Collections.emptyList(); + Object unackItemsAttr = request.getAttribute("unackItems"); + if (unackItemsAttr instanceof List) { unackItems = (List) 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); +%> + + + + + + + + Unity 피드 관리 | bibimbap + + + + +
+
+

UNITY FEED ADMIN

+

Unity 피드 관리

+

Unity 관련 외부 피드 소스를 등록하고 폴링하여 미확인 항목을 검토합니다. 피드 URL 은 등록 시 SSRF 안전성을 검증합니다.

+
+ +
+

신규 피드 소스 등록

+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+

피드 소스 목록

+
+ + + + + + + + + + + + <% if (sources.isEmpty()) { %> + + + + <% } else { + for (UnityFeedSourceData src : sources) { + boolean srcActive = Boolean.TRUE.equals(src.getIsActive()); + boolean hasError = src.getLastError() != null && !src.getLastError().isBlank(); + %> + + + + + + + + <% } + } %> + +
이름피드 URL활성최근 오류액션
등록된 피드 소스가 없습니다.
<%= HtmlUtils.htmlEscape(src.getName() == null ? "" : src.getName()) %><%= HtmlUtils.htmlEscape(src.getFeedUrl() == null ? "" : src.getFeedUrl()) %> + <% if (srcActive) { %>활성<% } else { %>비활성<% } %> + + <% if (hasError) { %> + <%= HtmlUtils.htmlEscape(src.getLastError()) %> + <% } else { %>-<% } %> + +
+ + +
+
+
+
+ +
+
+

미확인 항목

+ <%= HtmlUtils.htmlEscape(unackCount) %> +
+
+ + + + + + + + + + + <% if (unackItems.isEmpty()) { %> + + + + <% } else { + for (UnityFeedItemData item : unackItems) { + String itemDetectedAt = item.getDetectedAt() == null ? "" : String.valueOf(item.getDetectedAt()); + %> + + + + + + + <% } + } %> + +
제목소스감지 시각액션
미확인 항목이 없습니다.
+ " + target="_blank" rel="nofollow noopener"><%= HtmlUtils.htmlEscape(item.getTitle() == null ? "" : item.getTitle()) %> + <%= HtmlUtils.htmlEscape(item.getSourceName() == null ? "" : item.getSourceName()) %><%= HtmlUtils.htmlEscape(itemDetectedAt) %> + +
+
+
+
+ + + + diff --git a/src/main/webapp/WEB-INF/views/header.jsp b/src/main/webapp/WEB-INF/views/header.jsp index f147987..e3f770c 100644 --- a/src/main/webapp/WEB-INF/views/header.jsp +++ b/src/main/webapp/WEB-INF/views/header.jsp @@ -200,6 +200,7 @@
+
+ + + + + + + diff --git a/src/main/webapp/WEB-INF/views/posts-list.jsp b/src/main/webapp/WEB-INF/views/posts-list.jsp new file mode 100644 index 0000000..f4c7ced --- /dev/null +++ b/src/main/webapp/WEB-INF/views/posts-list.jsp @@ -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 posts = Collections.emptyList(); + Object postsAttr = request.getAttribute("posts"); + if (postsAttr instanceof List) { posts = (List) postsAttr; } + List categories = Collections.emptyList(); + Object categoriesAttr = request.getAttribute("categories"); + if (categoriesAttr instanceof List) { categories = (List) 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"); +%> + + + + + + + 포스팅 | bibimbap + + + + +
+
+
+

POSTING

+

포스팅

+
+ 글쓰기 +
+ + + + <% if (posts.isEmpty()) { %> +
아직 등록된 포스트가 없습니다.
+ <% } else { %> +
+ <% for (PostData post : posts) { %> + + <% if (post.getOgImageUrl() != null && !post.getOgImageUrl().isBlank()) { %> + + <% } %> +
+ <% if (post.getCategoryName() != null && !post.getCategoryName().isBlank()) { %> + <%= HtmlUtils.htmlEscape(post.getCategoryName()) %> + <% } %> +

<%= HtmlUtils.htmlEscape(post.getTitle() == null ? "" : post.getTitle()) %>

+ <%= HtmlUtils.htmlEscape(post.getAuthorDisplayName() == null ? "" : post.getAuthorDisplayName()) %> +
+
+ <% } %> +
+ + <% 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); + } + %> + 더보기 + <% } %> + <% } %> +
+ + + diff --git a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java index a70f010..cf47146 100644 --- a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java +++ b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java @@ -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() { } diff --git a/src/test/java/com/pandoli365/bibimbap/controller/PostControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/PostControllerTest.java new file mode 100644 index 0000000..04a689f --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/controller/PostControllerTest.java @@ -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 미사용 — 동일 패키지 컨벤션). + * + *

create/update/delete 의 게이트 순서(CSRF→인증→POST_WRITE 인가→검증)와 + * keyset 페이징 hasNext 처리를 검증한다. 차단 경로마다 mapper 쓰기(insert/softDelete) + * 가 호출되지 않음을 verify(never) 로 확인해 "쓰기 차단"(VP-5) 을 grounding 한다. + * + *

설계 매핑: 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> 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> 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> 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("

본문

"); + when(postsMapper.insert(any())).thenAnswer(inv -> { + ((PostData) inv.getArgument(0)).setId(POST_ID); + return 1; + }); + + ResponseEntity> 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> 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> 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> 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 shown = (List) 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 shown = (List) 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 rows(int count) { + List 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; + } +} diff --git a/src/test/java/com/pandoli365/bibimbap/security/SsrfSafeFetcherTest.java b/src/test/java/com/pandoli365/bibimbap/security/SsrfSafeFetcherTest.java new file mode 100644 index 0000000..290ccde --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/security/SsrfSafeFetcherTest.java @@ -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 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 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 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 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 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 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 result = fetcher.fetch(localUri("/"), MAX, HTML); + assertTrue(result.isEmpty(), "허용되지 않은 Content-Type 은 empty 여야 한다"); + } + + @Test + void fetchesAllowedHtmlSuccessfully() throws Exception { + TestableFetcher fetcher = new TestableFetcher().allowLoopback(); + String html = "okhi"; + startServer(exchange -> writeBody(exchange, "text/html; charset=utf-8", html)); + + Optional 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. + } + } +} diff --git a/src/test/java/com/pandoli365/bibimbap/service/FeedParserTest.java b/src/test/java/com/pandoli365/bibimbap/service/FeedParserTest.java new file mode 100644 index 0000000..95a9cda --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/service/FeedParserTest.java @@ -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 = """ + + + + Unity Blog + + urn:unity:1 + First Post + https://unity.com/blog/first + Tue, 24 Jun 2026 10:00:00 GMT + + + urn:unity:2 + Second Post + https://unity.com/blog/second + Wed, 25 Jun 2026 12:30:00 GMT + + + + """; + + List 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 = """ + + + + + No Guid + https://unity.com/blog/no-guid + + + + """; + + List 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 = """ + + + Unity News + + tag:unity,2026:a + Atom First + + 2026-06-24T10:00:00Z + + + tag:unity,2026:b + Atom Second + + 2026-06-25T12:30:00Z + + + """; + + List 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 = """ + + + + tag:unity,2026:c + Multi Link + + + + + """; + + List 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 = """ + + + ]> + + + + x + &xxe; + https://example.com/x + + + + """; + + List 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 items = assertDoesNotThrow(() -> parser.parse(notXml)); + assertTrue(items.isEmpty()); + } +} diff --git a/src/test/java/com/pandoli365/bibimbap/service/PostMarkdownServiceTest.java b/src/test/java/com/pandoli365/bibimbap/service/PostMarkdownServiceTest.java new file mode 100644 index 0000000..dda9529 --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/service/PostMarkdownServiceTest.java @@ -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(""); + assertFalse(html.contains(""); + 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(""); + assertFalse(html.contains(""), "bold should render : " + bold); + + String link = service.render("[링크](https://example.com)"); + assertTrue(link.contains(": " + 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("
    "), "list should render
      : " + list); + assertTrue(list.contains("
    • "), "list should render
    • : " + list); + + String heading = service.render("# 제목"); + assertTrue(heading.contains("

      "), "heading should render

      : " + heading); + } +} diff --git a/src/test/java/com/pandoli365/bibimbap/service/UnityFeedPollerTest.java b/src/test/java/com/pandoli365/bibimbap/service/UnityFeedPollerTest.java new file mode 100644 index 0000000..e85744b --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/service/UnityFeedPollerTest.java @@ -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 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)); + } +}