feat(board): W3-3 포스팅 보드 — 공지/블로그 + OG 미리보기(SSRF 방어) + 유니티 피드 감시
- posts/post_categories/unity_feed_sources/unity_feed_items 4테이블: docs/board-ddl.sql 권위 + db/schema.sql 동기, 멱등(IF NOT EXISTS/DO $$). FK·CHECK·부분 UNIQUE(unity_feed_items source_id,guid)
- 포스팅 CRUD: PostController 공개 목록(keyset 페이징 (created_at,id)) + 상세 + 작성/수정/삭제. POST_WRITE enforcement 연결(W1 PermissionGate, 임시 role 체크 0). 작성자무관 POST_WRITE 보유자 편집
- 마크다운 본문: PostMarkdownService commonmark 0.22.0 → jsoup 1.17.2 Safelist allowlist sanitize, 저장 시 1회 캐시(body_sanitized_html). script/on*/javascript:/iframe/object 제거(adversarial 감사 SOUND)
- ★SsrfSafeFetcher 공용 외부 fetch 관문: scheme allowlist + 전 resolved IP 공인검증(사설/루프백/링크로컬/메타데이터169.254.169.254 + CGNAT 100.64/10 + class-E/benchmarking/NAT64/6to4 차단) + 포트 allowlist{80/443/8080/8443} + hostname-connect(HTTPS SNI 정합) + 매홉 재검증(redirect NEVER, MAX 3) + peer 재검증(rebinding) + size cap + timeout + Content-Type + graceful
- OG 미리보기(OgPreviewService) + 유니티 피드 폴링(UnityFeedPoller @Scheduled, SchedulingConfig @EnableScheduling, FeedParser RSS/Atom XXE 방어 disallow-doctype) + guid dedupe(ON CONFLICT DO NOTHING)
- 카테고리/피드 운영: PostAdminController/UnityFeedAdminController /admin/** ADMIN 인터셉터 + CSRF 전수. 카테고리 삭제 FK 보호
- 신규 6매퍼 #{} only(${} 0). @MockBean 8. JSP 5(scriptlet+HtmlUtils.htmlEscape, JSTL 의존 부재 정합)
검증: 컨테이너 ./mvnw -o test 287/287 GREEN(신규 44), 회귀 0. L2 격리 throwaway DB contract PASS(keyset row-comparison·alias·insertIgnoreDup ON CONFLICT·FK 거부). adversarial SSRF 감사: rebinding/redirect/scheme/IP인코딩/IPv6 차단 실증, XSS sanitize SOUND. 실 dev DB 무접촉.
backward 보정(동일 커밋 통합): (1) Host restricted-header→fetch 전건 empty+테스트 vacuous → hostname-connect 전환(HTTPS SNI 정합, JVM 플래그 불요) (2) CGNAT 등 차단범위 확장 (3) FeedParser RFC1123 요일-날짜 불일치 robust 파싱.
알려진 잔여: option-b hostname-connect 는 connect 시 재resolve 로 rebinding TOCTOU 창 존재(DNS 캐시로 최소화). 위협모델상 LOW(fetch URL 제출자=ADMIN 피드등록/POST_WRITE OG). HTTPS 실 TLS = L3 스모크 권고.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD
This commit is contained in:
parent
e28fa60ca6
commit
6047a39ca5
115
db/schema.sql
115
db/schema.sql
|
|
@ -906,3 +906,118 @@ CREATE INDEX IF NOT EXISTS "idx_game_views_dedupe" ON "game_views" ("game_id", "
|
||||||
-- 5) games 비정규화 방문수 카운터
|
-- 5) games 비정규화 방문수 카운터
|
||||||
ALTER TABLE "games" ADD COLUMN IF NOT EXISTS "view_count" integer DEFAULT 0 NOT NULL;
|
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");
|
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;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
-- W3-3 포스팅 보드. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
|
||||||
|
-- posts / post_categories / unity_feed_sources / unity_feed_items. 추가만, 파괴 없음.
|
||||||
|
|
||||||
|
-- 1) post_categories (운영자 CRUD 카테고리 — D1/G2)
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "post_categories_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "post_categories" (
|
||||||
|
"id" bigint DEFAULT nextval('post_categories_id_seq'::regclass) NOT NULL,
|
||||||
|
"name" character varying(80) NOT NULL,
|
||||||
|
"slug" character varying(80) NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "post_categories_id_seq" OWNED BY "post_categories"."id";
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_post_categories_slug"
|
||||||
|
ON "post_categories" ("slug");
|
||||||
|
|
||||||
|
-- 2) posts (D1) — body_markdown 원본 + body_sanitized_html 캐시(D4), og_* 캐시(D5)
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "posts_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "posts" (
|
||||||
|
"id" bigint DEFAULT nextval('posts_id_seq'::regclass) NOT NULL,
|
||||||
|
"category_id" bigint NOT NULL,
|
||||||
|
"author_user_id" bigint NOT NULL,
|
||||||
|
"title" character varying(200) NOT NULL,
|
||||||
|
"body_markdown" text NOT NULL,
|
||||||
|
"body_sanitized_html" text NOT NULL,
|
||||||
|
"link_url" character varying(2048),
|
||||||
|
"og_title" character varying(300),
|
||||||
|
"og_description" character varying(600),
|
||||||
|
"og_image_url" character varying(2048),
|
||||||
|
"og_site_name" character varying(200),
|
||||||
|
"og_fetched_at" timestamp with time zone,
|
||||||
|
"status" character varying(20) DEFAULT 'PUBLISHED' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone,
|
||||||
|
"is_delete" boolean DEFAULT false NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "posts_id_seq" OWNED BY "posts"."id";
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_category_id_fkey') THEN
|
||||||
|
ALTER TABLE "posts"
|
||||||
|
ADD CONSTRAINT "posts_category_id_fkey"
|
||||||
|
FOREIGN KEY ("category_id") REFERENCES "post_categories" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_author_user_id_fkey') THEN
|
||||||
|
ALTER TABLE "posts"
|
||||||
|
ADD CONSTRAINT "posts_author_user_id_fkey"
|
||||||
|
FOREIGN KEY ("author_user_id") REFERENCES "users" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_status_check') THEN
|
||||||
|
ALTER TABLE "posts"
|
||||||
|
ADD CONSTRAINT "posts_status_check"
|
||||||
|
CHECK ("status" IN ('DRAFT', 'PUBLISHED'));
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_posts_published_keyset"
|
||||||
|
ON "posts" ("status", "is_delete", "created_at" DESC, "id" DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_posts_category_keyset"
|
||||||
|
ON "posts" ("category_id", "created_at" DESC, "id" DESC)
|
||||||
|
WHERE "is_delete" = false AND "status" = 'PUBLISHED';
|
||||||
|
|
||||||
|
-- 3) unity_feed_sources (외부 피드 감시 소스 — D6/G5)
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "unity_feed_sources_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "unity_feed_sources" (
|
||||||
|
"id" bigint DEFAULT nextval('unity_feed_sources_id_seq'::regclass) NOT NULL,
|
||||||
|
"name" character varying(120) NOT NULL,
|
||||||
|
"feed_url" character varying(2048) NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"last_polled_at" timestamp with time zone,
|
||||||
|
"last_seen_guid" character varying(512),
|
||||||
|
"last_error" character varying(500),
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "unity_feed_sources_id_seq" OWNED BY "unity_feed_sources"."id";
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_sources_url"
|
||||||
|
ON "unity_feed_sources" ("feed_url");
|
||||||
|
|
||||||
|
-- 4) unity_feed_items (감지된 새 글 — 운영자 알림 표면 + dedupe 영속화)
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "unity_feed_items_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "unity_feed_items" (
|
||||||
|
"id" bigint DEFAULT nextval('unity_feed_items_id_seq'::regclass) NOT NULL,
|
||||||
|
"source_id" bigint NOT NULL,
|
||||||
|
"guid" character varying(512) NOT NULL,
|
||||||
|
"title" character varying(500) NOT NULL,
|
||||||
|
"link_url" character varying(2048) NOT NULL,
|
||||||
|
"published_at" timestamp with time zone,
|
||||||
|
"is_acknowledged" boolean DEFAULT false NOT NULL,
|
||||||
|
"detected_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "unity_feed_items_id_seq" OWNED BY "unity_feed_items"."id";
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'unity_feed_items_source_id_fkey') THEN
|
||||||
|
ALTER TABLE "unity_feed_items"
|
||||||
|
ADD CONSTRAINT "unity_feed_items_source_id_fkey"
|
||||||
|
FOREIGN KEY ("source_id") REFERENCES "unity_feed_sources" ("id");
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_items_source_guid"
|
||||||
|
ON "unity_feed_items" ("source_id", "guid");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_unity_feed_items_unack"
|
||||||
|
ON "unity_feed_items" ("is_acknowledged", "detected_at" DESC)
|
||||||
|
WHERE "is_acknowledged" = false;
|
||||||
10
pom.xml
10
pom.xml
|
|
@ -84,6 +84,16 @@
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.commonmark</groupId>
|
||||||
|
<artifactId>commonmark</artifactId>
|
||||||
|
<version>0.22.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jsoup</groupId>
|
||||||
|
<artifactId>jsoup</artifactId>
|
||||||
|
<version>1.17.2</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
package com.pandoli365.bibimbap.controller;
|
||||||
|
|
||||||
|
import com.pandoli365.bibimbap.data.PostCategoryData;
|
||||||
|
import com.pandoli365.bibimbap.mapper.PostCategoriesMapper;
|
||||||
|
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class PostAdminController {
|
||||||
|
|
||||||
|
private final PostCategoriesMapper postCategoriesMapper;
|
||||||
|
|
||||||
|
public PostAdminController(PostCategoriesMapper postCategoriesMapper) {
|
||||||
|
this.postCategoriesMapper = postCategoriesMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/admin/post-categories")
|
||||||
|
public String postCategoriesPage(HttpServletRequest request, Model model) {
|
||||||
|
model.addAttribute("categories", postCategoriesMapper.listActive());
|
||||||
|
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession()));
|
||||||
|
return "admin-post-categories";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/admin/post-categories")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Map<String, Object>> createCategory(
|
||||||
|
@RequestParam(name = "name", required = false) String name,
|
||||||
|
@RequestParam(name = "slug", required = false) String slug,
|
||||||
|
@RequestParam(name = "sortOrder", required = false) Integer sortOrder,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
if (!CsrfTokens.isValid(request)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedName = trimToNull(name);
|
||||||
|
String normalizedSlug = trimToNull(slug);
|
||||||
|
if (normalizedName == null || normalizedName.length() > 80) {
|
||||||
|
return response(HttpStatus.BAD_REQUEST, "카테고리 이름을 80자 이내로 입력해 주세요.");
|
||||||
|
}
|
||||||
|
if (normalizedSlug == null || normalizedSlug.length() > 80) {
|
||||||
|
return response(HttpStatus.BAD_REQUEST, "슬러그를 80자 이내로 입력해 주세요.");
|
||||||
|
}
|
||||||
|
int order = sortOrder == null ? 0 : sortOrder;
|
||||||
|
|
||||||
|
PostCategoryData category = new PostCategoryData();
|
||||||
|
category.setName(normalizedName);
|
||||||
|
category.setSlug(normalizedSlug);
|
||||||
|
category.setSortOrder(order);
|
||||||
|
category.setIsActive(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
postCategoriesMapper.insert(category);
|
||||||
|
} catch (DuplicateKeyException e) {
|
||||||
|
return response(HttpStatus.CONFLICT, "이미 사용 중인 슬러그입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("status", 200);
|
||||||
|
body.put("message", "카테고리를 등록했습니다.");
|
||||||
|
body.put("categoryId", category.getId());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/admin/post-categories/{id}")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Map<String, Object>> updateCategory(
|
||||||
|
@PathVariable("id") long id,
|
||||||
|
@RequestParam(name = "name", required = false) String name,
|
||||||
|
@RequestParam(name = "slug", required = false) String slug,
|
||||||
|
@RequestParam(name = "sortOrder", required = false) Integer sortOrder,
|
||||||
|
@RequestParam(name = "isActive", required = false) Boolean isActive,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
if (!CsrfTokens.isValid(request)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
PostCategoryData existing = postCategoriesMapper.getById(id);
|
||||||
|
if (existing == null) {
|
||||||
|
return response(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedName = trimToNull(name);
|
||||||
|
if (normalizedName != null) {
|
||||||
|
if (normalizedName.length() > 80) {
|
||||||
|
return response(HttpStatus.BAD_REQUEST, "카테고리 이름을 80자 이내로 입력해 주세요.");
|
||||||
|
}
|
||||||
|
existing.setName(normalizedName);
|
||||||
|
}
|
||||||
|
String normalizedSlug = trimToNull(slug);
|
||||||
|
if (normalizedSlug != null) {
|
||||||
|
if (normalizedSlug.length() > 80) {
|
||||||
|
return response(HttpStatus.BAD_REQUEST, "슬러그를 80자 이내로 입력해 주세요.");
|
||||||
|
}
|
||||||
|
existing.setSlug(normalizedSlug);
|
||||||
|
}
|
||||||
|
if (sortOrder != null) {
|
||||||
|
existing.setSortOrder(sortOrder);
|
||||||
|
}
|
||||||
|
if (isActive != null) {
|
||||||
|
existing.setIsActive(isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
postCategoriesMapper.update(existing);
|
||||||
|
} catch (DuplicateKeyException e) {
|
||||||
|
return response(HttpStatus.CONFLICT, "이미 사용 중인 슬러그입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(HttpStatus.OK, "카테고리를 수정했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/admin/post-categories/{id}/delete")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Map<String, Object>> deleteCategory(
|
||||||
|
@PathVariable("id") long id,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
if (!CsrfTokens.isValid(request)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
PostCategoryData existing = postCategoriesMapper.getById(id);
|
||||||
|
if (existing == null) {
|
||||||
|
return response(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
if (postCategoriesMapper.countPostsByCategory(id) > 0) {
|
||||||
|
return response(HttpStatus.CONFLICT,
|
||||||
|
"소속 PUBLISHED 포스트가 있어 삭제할 수 없습니다. 비활성화(is_active=false)를 권장합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
postCategoriesMapper.delete(id);
|
||||||
|
return response(HttpStatus.OK, "카테고리를 삭제했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimToNull(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String text = value.trim();
|
||||||
|
return text.isBlank() ? null : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("status", status.value());
|
||||||
|
body.put("message", message);
|
||||||
|
return ResponseEntity.status(status).body(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,414 @@
|
||||||
|
package com.pandoli365.bibimbap.controller;
|
||||||
|
|
||||||
|
import com.pandoli365.bibimbap.data.PostCategoryData;
|
||||||
|
import com.pandoli365.bibimbap.data.PostData;
|
||||||
|
import com.pandoli365.bibimbap.mapper.PostCategoriesMapper;
|
||||||
|
import com.pandoli365.bibimbap.mapper.PostsMapper;
|
||||||
|
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||||
|
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||||
|
import com.pandoli365.bibimbap.security.PermissionKeys;
|
||||||
|
import com.pandoli365.bibimbap.service.OgPreviewService;
|
||||||
|
import com.pandoli365.bibimbap.service.OgPreviewService.OgPreview;
|
||||||
|
import com.pandoli365.bibimbap.service.PostMarkdownService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpSession;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class PostController {
|
||||||
|
|
||||||
|
private static final int PAGE_SIZE = 20;
|
||||||
|
private static final int MAX_TITLE = 200;
|
||||||
|
private static final int MAX_BODY = 20000;
|
||||||
|
private static final int MAX_LINK_URL = 2048;
|
||||||
|
private static final Set<String> STATUSES = Set.of("DRAFT", "PUBLISHED");
|
||||||
|
private static final String DEFAULT_STATUS = "PUBLISHED";
|
||||||
|
|
||||||
|
private final PostsMapper postsMapper;
|
||||||
|
private final PostCategoriesMapper postCategoriesMapper;
|
||||||
|
private final PermissionGate gate;
|
||||||
|
private final PostMarkdownService postMarkdownService;
|
||||||
|
private final OgPreviewService ogPreviewService;
|
||||||
|
|
||||||
|
public PostController(PostsMapper postsMapper,
|
||||||
|
PostCategoriesMapper postCategoriesMapper,
|
||||||
|
PermissionGate gate,
|
||||||
|
PostMarkdownService postMarkdownService,
|
||||||
|
OgPreviewService ogPreviewService) {
|
||||||
|
this.postsMapper = postsMapper;
|
||||||
|
this.postCategoriesMapper = postCategoriesMapper;
|
||||||
|
this.gate = gate;
|
||||||
|
this.postMarkdownService = postMarkdownService;
|
||||||
|
this.ogPreviewService = ogPreviewService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/posts")
|
||||||
|
public String list(
|
||||||
|
@RequestParam(name = "categoryId", required = false) Long categoryId,
|
||||||
|
@RequestParam(name = "cursorCreatedAt", required = false) String cursorCreatedAt,
|
||||||
|
@RequestParam(name = "cursorId", required = false) Long cursorId,
|
||||||
|
Model model
|
||||||
|
) {
|
||||||
|
OffsetDateTime cursor = parseOffsetDateTime(cursorCreatedAt);
|
||||||
|
List<PostData> rows = postsMapper.listPublishedKeyset(cursor, cursorId, categoryId, PAGE_SIZE + 1);
|
||||||
|
|
||||||
|
boolean hasNext = rows.size() > PAGE_SIZE;
|
||||||
|
if (hasNext) {
|
||||||
|
rows = rows.subList(0, PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
model.addAttribute("posts", rows);
|
||||||
|
model.addAttribute("categories", postCategoriesMapper.listActive());
|
||||||
|
model.addAttribute("categoryId", categoryId);
|
||||||
|
model.addAttribute("hasNext", hasNext);
|
||||||
|
if (hasNext && !rows.isEmpty()) {
|
||||||
|
PostData last = rows.get(rows.size() - 1);
|
||||||
|
model.addAttribute("nextCursorCreatedAt", last.getCreatedAt());
|
||||||
|
model.addAttribute("nextCursorId", last.getId());
|
||||||
|
}
|
||||||
|
return "posts-list";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/posts/{id}")
|
||||||
|
public String detail(@PathVariable("id") long id, Model model) {
|
||||||
|
PostData post = postsMapper.getPublished(id);
|
||||||
|
if (post == null) {
|
||||||
|
return "redirect:/posts";
|
||||||
|
}
|
||||||
|
model.addAttribute("post", post);
|
||||||
|
return "posts-detail";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/posts/new")
|
||||||
|
public String createForm(HttpSession session, Model model) {
|
||||||
|
if (sessionUserId(session) == null) {
|
||||||
|
return "redirect:/login";
|
||||||
|
}
|
||||||
|
requireWritePermission(session);
|
||||||
|
model.addAttribute("categories", postCategoriesMapper.listActive());
|
||||||
|
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(session));
|
||||||
|
model.addAttribute("mode", "new");
|
||||||
|
return "posts-form";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/posts")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Map<String, Object>> create(
|
||||||
|
@RequestParam(name = "categoryId", required = false) Long categoryId,
|
||||||
|
@RequestParam(name = "title", required = false) String title,
|
||||||
|
@RequestParam(name = "bodyMarkdown", required = false) String bodyMarkdown,
|
||||||
|
@RequestParam(name = "linkUrl", required = false) String linkUrl,
|
||||||
|
@RequestParam(name = "status", required = false) String status,
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpSession session
|
||||||
|
) {
|
||||||
|
if (!CsrfTokens.isValid(request)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||||
|
}
|
||||||
|
Long userId = sessionUserId(session);
|
||||||
|
if (userId == null) {
|
||||||
|
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||||
|
}
|
||||||
|
if (!gate.has(session, PermissionKeys.POST_WRITE.name())) {
|
||||||
|
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Validated validated = validate(title, bodyMarkdown, linkUrl, status, categoryId);
|
||||||
|
if (validated.error != null) {
|
||||||
|
return validated.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
PostData post = new PostData();
|
||||||
|
post.setCategoryId(categoryId);
|
||||||
|
post.setAuthorUserId(userId);
|
||||||
|
post.setTitle(validated.title);
|
||||||
|
post.setBodyMarkdown(validated.bodyMarkdown);
|
||||||
|
post.setBodySanitizedHtml(postMarkdownService.render(validated.bodyMarkdown));
|
||||||
|
post.setLinkUrl(validated.linkUrl);
|
||||||
|
post.setStatus(validated.status);
|
||||||
|
applyOgPreview(post, validated.linkUrl);
|
||||||
|
|
||||||
|
postsMapper.insert(post);
|
||||||
|
if (post.getId() == null) {
|
||||||
|
return response(HttpStatus.INTERNAL_SERVER_ERROR, "포스트 등록 결과를 확인하지 못했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("status", 200);
|
||||||
|
body.put("message", "포스트가 등록되었습니다.");
|
||||||
|
body.put("postId", post.getId());
|
||||||
|
body.put("location", "/posts/" + post.getId());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/posts/{id}/edit")
|
||||||
|
public String editForm(@PathVariable("id") long id, HttpSession session, Model model) {
|
||||||
|
if (sessionUserId(session) == null) {
|
||||||
|
return "redirect:/login";
|
||||||
|
}
|
||||||
|
requireWritePermission(session);
|
||||||
|
PostData post = postsMapper.getPublished(id);
|
||||||
|
if (post == null) {
|
||||||
|
return "redirect:/posts";
|
||||||
|
}
|
||||||
|
model.addAttribute("post", post);
|
||||||
|
model.addAttribute("categories", postCategoriesMapper.listActive());
|
||||||
|
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(session));
|
||||||
|
model.addAttribute("mode", "edit");
|
||||||
|
return "posts-form";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/posts/{id}")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Map<String, Object>> update(
|
||||||
|
@PathVariable("id") long id,
|
||||||
|
@RequestParam(name = "categoryId", required = false) Long categoryId,
|
||||||
|
@RequestParam(name = "title", required = false) String title,
|
||||||
|
@RequestParam(name = "bodyMarkdown", required = false) String bodyMarkdown,
|
||||||
|
@RequestParam(name = "linkUrl", required = false) String linkUrl,
|
||||||
|
@RequestParam(name = "status", required = false) String status,
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpSession session
|
||||||
|
) {
|
||||||
|
if (!CsrfTokens.isValid(request)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||||
|
}
|
||||||
|
Long userId = sessionUserId(session);
|
||||||
|
if (userId == null) {
|
||||||
|
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||||
|
}
|
||||||
|
if (!gate.has(session, PermissionKeys.POST_WRITE.name())) {
|
||||||
|
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Validated validated = validate(title, bodyMarkdown, linkUrl, status, categoryId);
|
||||||
|
if (validated.error != null) {
|
||||||
|
return validated.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
PostData existing = postsMapper.getPublished(id);
|
||||||
|
if (existing == null) {
|
||||||
|
return response(HttpStatus.NOT_FOUND, "포스트를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.setCategoryId(categoryId);
|
||||||
|
existing.setTitle(validated.title);
|
||||||
|
existing.setBodyMarkdown(validated.bodyMarkdown);
|
||||||
|
existing.setBodySanitizedHtml(postMarkdownService.render(validated.bodyMarkdown));
|
||||||
|
existing.setStatus(validated.status);
|
||||||
|
|
||||||
|
boolean linkChanged = !equalsLink(existing.getLinkUrl(), validated.linkUrl);
|
||||||
|
existing.setLinkUrl(validated.linkUrl);
|
||||||
|
if (linkChanged) {
|
||||||
|
clearOgPreview(existing);
|
||||||
|
applyOgPreview(existing, validated.linkUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
postsMapper.update(existing);
|
||||||
|
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("status", 200);
|
||||||
|
body.put("message", "포스트가 수정되었습니다.");
|
||||||
|
body.put("postId", existing.getId());
|
||||||
|
body.put("location", "/posts/" + existing.getId());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/posts/{id}/delete")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Map<String, Object>> delete(
|
||||||
|
@PathVariable("id") long id,
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpSession session
|
||||||
|
) {
|
||||||
|
if (!CsrfTokens.isValid(request)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||||
|
}
|
||||||
|
Long userId = sessionUserId(session);
|
||||||
|
if (userId == null) {
|
||||||
|
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||||
|
}
|
||||||
|
if (!gate.has(session, PermissionKeys.POST_WRITE.name())) {
|
||||||
|
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
PostData existing = postsMapper.getPublished(id);
|
||||||
|
if (existing == null) {
|
||||||
|
return response(HttpStatus.NOT_FOUND, "포스트를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
postsMapper.softDelete(id);
|
||||||
|
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("status", 200);
|
||||||
|
body.put("message", "포스트가 삭제되었습니다.");
|
||||||
|
body.put("postId", id);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireWritePermission(HttpSession session) {
|
||||||
|
if (!gate.has(session, PermissionKeys.POST_WRITE.name())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Validated validate(String title, String bodyMarkdown, String linkUrl, String status, Long categoryId) {
|
||||||
|
Validated v = new Validated();
|
||||||
|
|
||||||
|
String normalizedTitle = trimToNull(title);
|
||||||
|
if (normalizedTitle == null || normalizedTitle.length() > MAX_TITLE) {
|
||||||
|
v.error = response(HttpStatus.BAD_REQUEST, "제목을 " + MAX_TITLE + "자 이내로 입력해 주세요.");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
v.title = normalizedTitle;
|
||||||
|
|
||||||
|
String normalizedBody = bodyMarkdown == null ? "" : bodyMarkdown;
|
||||||
|
if (normalizedBody.length() > MAX_BODY) {
|
||||||
|
v.error = response(HttpStatus.BAD_REQUEST, "본문은 " + MAX_BODY + "자 이내로 입력해 주세요.");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
v.bodyMarkdown = normalizedBody;
|
||||||
|
|
||||||
|
v.linkUrl = normalizeLinkUrl(linkUrl);
|
||||||
|
if (v.linkUrl != null && v.linkUrl.length() > MAX_LINK_URL) {
|
||||||
|
v.error = response(HttpStatus.BAD_REQUEST, "링크 URL은 " + MAX_LINK_URL + "자 이내로 입력해 주세요.");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedStatus = trimToNull(status);
|
||||||
|
if (normalizedStatus == null) {
|
||||||
|
normalizedStatus = DEFAULT_STATUS;
|
||||||
|
}
|
||||||
|
if (!STATUSES.contains(normalizedStatus)) {
|
||||||
|
v.error = response(HttpStatus.BAD_REQUEST, "공개 상태를 확인해 주세요.");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
v.status = normalizedStatus;
|
||||||
|
|
||||||
|
if (categoryId == null) {
|
||||||
|
v.error = response(HttpStatus.BAD_REQUEST, "카테고리를 선택해 주세요.");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
PostCategoryData category = postCategoriesMapper.getActive(categoryId);
|
||||||
|
if (category == null) {
|
||||||
|
v.error = response(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다.");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OG fetch 는 graceful: 실패해도 작성/수정은 성공시킨다.
|
||||||
|
private void applyOgPreview(PostData post, String linkUrl) {
|
||||||
|
if (linkUrl == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Optional<OgPreview> preview = ogPreviewService.fetch(linkUrl);
|
||||||
|
if (preview.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
OgPreview og = preview.get();
|
||||||
|
post.setOgTitle(og.title());
|
||||||
|
post.setOgDescription(og.description());
|
||||||
|
post.setOgImageUrl(og.imageUrl());
|
||||||
|
post.setOgSiteName(og.siteName());
|
||||||
|
post.setOgFetchedAt(OffsetDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearOgPreview(PostData post) {
|
||||||
|
post.setOgTitle(null);
|
||||||
|
post.setOgDescription(null);
|
||||||
|
post.setOgImageUrl(null);
|
||||||
|
post.setOgSiteName(null);
|
||||||
|
post.setOgFetchedAt(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean equalsLink(String a, String b) {
|
||||||
|
if (a == null) {
|
||||||
|
return b == null;
|
||||||
|
}
|
||||||
|
return a.equals(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeLinkUrl(String linkUrl) {
|
||||||
|
String text = trimToNull(linkUrl);
|
||||||
|
if (text == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String lower = text.toLowerCase();
|
||||||
|
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private OffsetDateTime parseOffsetDateTime(String value) {
|
||||||
|
String text = trimToNull(value);
|
||||||
|
if (text == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return OffsetDateTime.parse(text);
|
||||||
|
} catch (DateTimeParseException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long sessionUserId(HttpSession session) {
|
||||||
|
if (session == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object userId = session.getAttribute("userId");
|
||||||
|
if (userId instanceof Number number) {
|
||||||
|
return number.longValue();
|
||||||
|
}
|
||||||
|
if (userId instanceof String text) {
|
||||||
|
try {
|
||||||
|
return Long.parseLong(text);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimToNull(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String text = value.trim();
|
||||||
|
return text.isBlank() ? null : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("status", status.value());
|
||||||
|
body.put("message", message);
|
||||||
|
return ResponseEntity.status(status).body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Validated {
|
||||||
|
private String title;
|
||||||
|
private String bodyMarkdown;
|
||||||
|
private String linkUrl;
|
||||||
|
private String status;
|
||||||
|
private ResponseEntity<Map<String, Object>> error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
package com.pandoli365.bibimbap.controller;
|
||||||
|
|
||||||
|
import com.pandoli365.bibimbap.data.UnityFeedSourceData;
|
||||||
|
import com.pandoli365.bibimbap.mapper.UnityFeedItemsMapper;
|
||||||
|
import com.pandoli365.bibimbap.mapper.UnityFeedSourcesMapper;
|
||||||
|
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||||
|
import com.pandoli365.bibimbap.security.SsrfSafeFetcher;
|
||||||
|
import com.pandoli365.bibimbap.service.UnityFeedPoller;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class UnityFeedAdminController {
|
||||||
|
|
||||||
|
private final UnityFeedSourcesMapper sourcesMapper;
|
||||||
|
private final UnityFeedItemsMapper itemsMapper;
|
||||||
|
private final SsrfSafeFetcher ssrfSafeFetcher;
|
||||||
|
private final UnityFeedPoller unityFeedPoller;
|
||||||
|
|
||||||
|
public UnityFeedAdminController(
|
||||||
|
UnityFeedSourcesMapper sourcesMapper,
|
||||||
|
UnityFeedItemsMapper itemsMapper,
|
||||||
|
SsrfSafeFetcher ssrfSafeFetcher,
|
||||||
|
UnityFeedPoller unityFeedPoller
|
||||||
|
) {
|
||||||
|
this.sourcesMapper = sourcesMapper;
|
||||||
|
this.itemsMapper = itemsMapper;
|
||||||
|
this.ssrfSafeFetcher = ssrfSafeFetcher;
|
||||||
|
this.unityFeedPoller = unityFeedPoller;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/admin/unity-feeds")
|
||||||
|
public String unityFeedsPage(HttpServletRequest request, Model model) {
|
||||||
|
model.addAttribute("sources", sourcesMapper.listAll());
|
||||||
|
model.addAttribute("unackItems", itemsMapper.listUnacknowledged(50));
|
||||||
|
model.addAttribute("unackCount", itemsMapper.countUnacknowledged());
|
||||||
|
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession()));
|
||||||
|
return "admin-unity-feeds";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/admin/unity-feeds")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Map<String, Object>> createSource(
|
||||||
|
@RequestParam(name = "name", required = false) String name,
|
||||||
|
@RequestParam(name = "feedUrl", required = false) String feedUrl,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
if (!CsrfTokens.isValid(request)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedName = trimToNull(name);
|
||||||
|
String normalizedUrl = trimToNull(feedUrl);
|
||||||
|
if (normalizedName == null || normalizedName.length() > 120) {
|
||||||
|
return response(HttpStatus.BAD_REQUEST, "피드 이름을 120자 이내로 입력해 주세요.");
|
||||||
|
}
|
||||||
|
if (normalizedUrl == null || normalizedUrl.length() > 2048) {
|
||||||
|
return response(HttpStatus.BAD_REQUEST, "피드 URL 을 2048자 이내로 입력해 주세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
URI uri;
|
||||||
|
try {
|
||||||
|
uri = new URI(normalizedUrl);
|
||||||
|
} catch (URISyntaxException e) {
|
||||||
|
return response(HttpStatus.BAD_REQUEST, "피드 URL 이 안전하지 않거나 접근 불가합니다.");
|
||||||
|
}
|
||||||
|
if (!ssrfSafeFetcher.isFetchableUrl(uri)) {
|
||||||
|
return response(HttpStatus.BAD_REQUEST, "피드 URL 이 안전하지 않거나 접근 불가합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
UnityFeedSourceData source = new UnityFeedSourceData();
|
||||||
|
source.setName(normalizedName);
|
||||||
|
source.setFeedUrl(normalizedUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
sourcesMapper.insert(source);
|
||||||
|
} catch (DuplicateKeyException e) {
|
||||||
|
return response(HttpStatus.CONFLICT, "이미 등록된 피드 URL 입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("status", 200);
|
||||||
|
body.put("message", "피드 소스를 등록했습니다.");
|
||||||
|
body.put("sourceId", source.getId());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/admin/unity-feeds/{id}/toggle")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Map<String, Object>> toggleSource(
|
||||||
|
@PathVariable("id") long id,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
if (!CsrfTokens.isValid(request)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
UnityFeedSourceData existing = sourcesMapper.getById(id);
|
||||||
|
if (existing == null) {
|
||||||
|
return response(HttpStatus.NOT_FOUND, "피드 소스를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
sourcesMapper.toggle(id);
|
||||||
|
UnityFeedSourceData updated = sourcesMapper.getById(id);
|
||||||
|
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("status", 200);
|
||||||
|
body.put("message", "피드 소스 상태를 변경했습니다.");
|
||||||
|
body.put("isActive", updated == null ? null : updated.getIsActive());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/admin/unity-feeds/{id}/delete")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Map<String, Object>> deleteSource(
|
||||||
|
@PathVariable("id") long id,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
if (!CsrfTokens.isValid(request)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
UnityFeedSourceData existing = sourcesMapper.getById(id);
|
||||||
|
if (existing == null) {
|
||||||
|
return response(HttpStatus.NOT_FOUND, "피드 소스를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
sourcesMapper.delete(id);
|
||||||
|
return response(HttpStatus.OK, "피드 소스를 삭제했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/admin/unity-feeds/items/{itemId}/ack")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Map<String, Object>> acknowledgeItem(
|
||||||
|
@PathVariable("itemId") long itemId,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
if (!CsrfTokens.isValid(request)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsMapper.acknowledge(itemId);
|
||||||
|
return response(HttpStatus.OK, "확인 처리했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/admin/unity-feeds/poll")
|
||||||
|
public ResponseEntity<Map<String, Object>> pollNow(HttpServletRequest request) {
|
||||||
|
if (!CsrfTokens.isValid(request)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
int newCount = 0;
|
||||||
|
List<UnityFeedSourceData> sources = sourcesMapper.listAll();
|
||||||
|
if (sources != null) {
|
||||||
|
for (UnityFeedSourceData source : sources) {
|
||||||
|
if (source == null || source.getId() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Boolean.TRUE.equals(source.getIsActive())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
newCount += unityFeedPoller.pollOnce(source.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("status", 200);
|
||||||
|
body.put("message", "폴링을 완료했습니다.");
|
||||||
|
body.put("newCount", newCount);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimToNull(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String text = value.trim();
|
||||||
|
return text.isBlank() ? null : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("status", status.value());
|
||||||
|
body.put("message", message);
|
||||||
|
return ResponseEntity.status(status).body(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
package com.pandoli365.bibimbap.mapper;
|
||||||
|
|
||||||
|
import com.pandoli365.bibimbap.data.PostCategoryData;
|
||||||
|
import org.apache.ibatis.annotations.Delete;
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Options;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface PostCategoriesMapper {
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
sort_order AS sortOrder,
|
||||||
|
is_active AS isActive,
|
||||||
|
created_at AS createdAt,
|
||||||
|
updated_at AS updatedAt
|
||||||
|
FROM post_categories
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY sort_order ASC, id ASC
|
||||||
|
""")
|
||||||
|
List<PostCategoryData> listActive();
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
sort_order AS sortOrder,
|
||||||
|
is_active AS isActive,
|
||||||
|
created_at AS createdAt,
|
||||||
|
updated_at AS updatedAt
|
||||||
|
FROM post_categories
|
||||||
|
WHERE id = #{id}
|
||||||
|
AND is_active = true
|
||||||
|
""")
|
||||||
|
PostCategoryData getActive(@Param("id") long id);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
sort_order AS sortOrder,
|
||||||
|
is_active AS isActive,
|
||||||
|
created_at AS createdAt,
|
||||||
|
updated_at AS updatedAt
|
||||||
|
FROM post_categories
|
||||||
|
WHERE id = #{id}
|
||||||
|
""")
|
||||||
|
PostCategoryData getById(@Param("id") long id);
|
||||||
|
|
||||||
|
@Insert("""
|
||||||
|
INSERT INTO post_categories (
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
sort_order
|
||||||
|
) VALUES (
|
||||||
|
#{name},
|
||||||
|
#{slug},
|
||||||
|
#{sortOrder}
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
|
||||||
|
int insert(PostCategoryData c);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE post_categories
|
||||||
|
SET name = #{name},
|
||||||
|
slug = #{slug},
|
||||||
|
sort_order = #{sortOrder},
|
||||||
|
is_active = #{isActive},
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = #{id}
|
||||||
|
""")
|
||||||
|
int update(PostCategoryData c);
|
||||||
|
|
||||||
|
@Delete("""
|
||||||
|
DELETE FROM post_categories
|
||||||
|
WHERE id = #{id}
|
||||||
|
""")
|
||||||
|
int delete(@Param("id") long id);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT count(*)
|
||||||
|
FROM posts
|
||||||
|
WHERE category_id = #{categoryId}
|
||||||
|
AND is_delete = false
|
||||||
|
AND status = 'PUBLISHED'
|
||||||
|
""")
|
||||||
|
int countPostsByCategory(@Param("categoryId") long categoryId);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
package com.pandoli365.bibimbap.mapper;
|
||||||
|
|
||||||
|
import com.pandoli365.bibimbap.data.PostData;
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Options;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface PostsMapper {
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.category_id AS categoryId,
|
||||||
|
p.author_user_id AS authorUserId,
|
||||||
|
p.title,
|
||||||
|
p.body_markdown AS bodyMarkdown,
|
||||||
|
p.body_sanitized_html AS bodySanitizedHtml,
|
||||||
|
p.link_url AS linkUrl,
|
||||||
|
p.og_title AS ogTitle,
|
||||||
|
p.og_description AS ogDescription,
|
||||||
|
p.og_image_url AS ogImageUrl,
|
||||||
|
p.og_site_name AS ogSiteName,
|
||||||
|
p.og_fetched_at AS ogFetchedAt,
|
||||||
|
p.status,
|
||||||
|
p.created_at AS createdAt,
|
||||||
|
p.updated_at AS updatedAt,
|
||||||
|
p.deleted_at AS deletedAt,
|
||||||
|
p.is_delete AS isDelete,
|
||||||
|
u.display_name AS authorDisplayName,
|
||||||
|
c.name AS categoryName,
|
||||||
|
c.slug AS categorySlug
|
||||||
|
FROM posts p
|
||||||
|
JOIN users u ON u.id = p.author_user_id
|
||||||
|
JOIN post_categories c ON c.id = p.category_id
|
||||||
|
WHERE p.status = 'PUBLISHED'
|
||||||
|
AND p.is_delete = false
|
||||||
|
AND (#{categoryId} IS NULL OR p.category_id = #{categoryId})
|
||||||
|
AND (
|
||||||
|
#{cursorCreatedAt} IS NULL
|
||||||
|
OR (p.created_at, p.id) < (#{cursorCreatedAt}, #{cursorId})
|
||||||
|
)
|
||||||
|
ORDER BY p.created_at DESC, p.id DESC
|
||||||
|
LIMIT #{limit}
|
||||||
|
""")
|
||||||
|
List<PostData> listPublishedKeyset(@Param("cursorCreatedAt") OffsetDateTime cursorCreatedAt,
|
||||||
|
@Param("cursorId") Long cursorId,
|
||||||
|
@Param("categoryId") Long categoryId,
|
||||||
|
@Param("limit") int limit);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.category_id AS categoryId,
|
||||||
|
p.author_user_id AS authorUserId,
|
||||||
|
p.title,
|
||||||
|
p.body_markdown AS bodyMarkdown,
|
||||||
|
p.body_sanitized_html AS bodySanitizedHtml,
|
||||||
|
p.link_url AS linkUrl,
|
||||||
|
p.og_title AS ogTitle,
|
||||||
|
p.og_description AS ogDescription,
|
||||||
|
p.og_image_url AS ogImageUrl,
|
||||||
|
p.og_site_name AS ogSiteName,
|
||||||
|
p.og_fetched_at AS ogFetchedAt,
|
||||||
|
p.status,
|
||||||
|
p.created_at AS createdAt,
|
||||||
|
p.updated_at AS updatedAt,
|
||||||
|
p.deleted_at AS deletedAt,
|
||||||
|
p.is_delete AS isDelete,
|
||||||
|
u.display_name AS authorDisplayName,
|
||||||
|
c.name AS categoryName,
|
||||||
|
c.slug AS categorySlug
|
||||||
|
FROM posts p
|
||||||
|
JOIN users u ON u.id = p.author_user_id
|
||||||
|
JOIN post_categories c ON c.id = p.category_id
|
||||||
|
WHERE p.id = #{id}
|
||||||
|
AND p.status = 'PUBLISHED'
|
||||||
|
AND p.is_delete = false
|
||||||
|
""")
|
||||||
|
PostData getPublished(@Param("id") long id);
|
||||||
|
|
||||||
|
@Insert("""
|
||||||
|
INSERT INTO posts (
|
||||||
|
category_id,
|
||||||
|
author_user_id,
|
||||||
|
title,
|
||||||
|
body_markdown,
|
||||||
|
body_sanitized_html,
|
||||||
|
link_url,
|
||||||
|
og_title,
|
||||||
|
og_description,
|
||||||
|
og_image_url,
|
||||||
|
og_site_name,
|
||||||
|
og_fetched_at,
|
||||||
|
status
|
||||||
|
) VALUES (
|
||||||
|
#{categoryId},
|
||||||
|
#{authorUserId},
|
||||||
|
#{title},
|
||||||
|
#{bodyMarkdown},
|
||||||
|
#{bodySanitizedHtml},
|
||||||
|
#{linkUrl},
|
||||||
|
#{ogTitle},
|
||||||
|
#{ogDescription},
|
||||||
|
#{ogImageUrl},
|
||||||
|
#{ogSiteName},
|
||||||
|
#{ogFetchedAt},
|
||||||
|
#{status}
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
|
||||||
|
int insert(PostData post);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE posts
|
||||||
|
SET title = #{title},
|
||||||
|
body_markdown = #{bodyMarkdown},
|
||||||
|
body_sanitized_html = #{bodySanitizedHtml},
|
||||||
|
link_url = #{linkUrl},
|
||||||
|
og_title = #{ogTitle},
|
||||||
|
og_description = #{ogDescription},
|
||||||
|
og_image_url = #{ogImageUrl},
|
||||||
|
og_site_name = #{ogSiteName},
|
||||||
|
og_fetched_at = #{ogFetchedAt},
|
||||||
|
status = #{status},
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = #{id}
|
||||||
|
""")
|
||||||
|
int update(PostData post);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE posts
|
||||||
|
SET is_delete = true,
|
||||||
|
deleted_at = now()
|
||||||
|
WHERE id = #{id}
|
||||||
|
""")
|
||||||
|
int softDelete(@Param("id") long id);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package com.pandoli365.bibimbap.mapper;
|
||||||
|
|
||||||
|
import com.pandoli365.bibimbap.data.UnityFeedItemData;
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UnityFeedItemsMapper {
|
||||||
|
|
||||||
|
@Insert("""
|
||||||
|
INSERT INTO unity_feed_items (
|
||||||
|
source_id,
|
||||||
|
guid,
|
||||||
|
title,
|
||||||
|
link_url,
|
||||||
|
published_at
|
||||||
|
) VALUES (
|
||||||
|
#{sourceId},
|
||||||
|
#{guid},
|
||||||
|
#{title},
|
||||||
|
#{linkUrl},
|
||||||
|
#{publishedAt}
|
||||||
|
)
|
||||||
|
ON CONFLICT (source_id, guid) DO NOTHING
|
||||||
|
""")
|
||||||
|
int insertIgnoreDup(UnityFeedItemData item);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
i.source_id AS sourceId,
|
||||||
|
i.guid,
|
||||||
|
i.title,
|
||||||
|
i.link_url AS linkUrl,
|
||||||
|
i.published_at AS publishedAt,
|
||||||
|
i.is_acknowledged AS isAcknowledged,
|
||||||
|
i.detected_at AS detectedAt,
|
||||||
|
s.name AS sourceName
|
||||||
|
FROM unity_feed_items i
|
||||||
|
JOIN unity_feed_sources s ON s.id = i.source_id
|
||||||
|
WHERE i.is_acknowledged = false
|
||||||
|
ORDER BY i.detected_at DESC
|
||||||
|
LIMIT #{limit}
|
||||||
|
""")
|
||||||
|
List<UnityFeedItemData> listUnacknowledged(@Param("limit") int limit);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT count(*)
|
||||||
|
FROM unity_feed_items
|
||||||
|
WHERE is_acknowledged = false
|
||||||
|
""")
|
||||||
|
int countUnacknowledged();
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE unity_feed_items
|
||||||
|
SET is_acknowledged = true
|
||||||
|
WHERE id = #{itemId}
|
||||||
|
""")
|
||||||
|
int acknowledge(@Param("itemId") long itemId);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM unity_feed_items
|
||||||
|
WHERE source_id = #{sourceId}
|
||||||
|
AND guid = #{guid}
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
boolean existsByGuid(@Param("sourceId") long sourceId, @Param("guid") String guid);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
package com.pandoli365.bibimbap.mapper;
|
||||||
|
|
||||||
|
import com.pandoli365.bibimbap.data.UnityFeedSourceData;
|
||||||
|
import org.apache.ibatis.annotations.Delete;
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Options;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UnityFeedSourcesMapper {
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
feed_url AS feedUrl,
|
||||||
|
is_active AS isActive,
|
||||||
|
last_polled_at AS lastPolledAt,
|
||||||
|
last_seen_guid AS lastSeenGuid,
|
||||||
|
last_error AS lastError,
|
||||||
|
created_at AS createdAt
|
||||||
|
FROM unity_feed_sources
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY id ASC
|
||||||
|
""")
|
||||||
|
List<UnityFeedSourceData> listActive();
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
feed_url AS feedUrl,
|
||||||
|
is_active AS isActive,
|
||||||
|
last_polled_at AS lastPolledAt,
|
||||||
|
last_seen_guid AS lastSeenGuid,
|
||||||
|
last_error AS lastError,
|
||||||
|
created_at AS createdAt
|
||||||
|
FROM unity_feed_sources
|
||||||
|
ORDER BY id ASC
|
||||||
|
""")
|
||||||
|
List<UnityFeedSourceData> listAll();
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
feed_url AS feedUrl,
|
||||||
|
is_active AS isActive,
|
||||||
|
last_polled_at AS lastPolledAt,
|
||||||
|
last_seen_guid AS lastSeenGuid,
|
||||||
|
last_error AS lastError,
|
||||||
|
created_at AS createdAt
|
||||||
|
FROM unity_feed_sources
|
||||||
|
WHERE id = #{id}
|
||||||
|
""")
|
||||||
|
UnityFeedSourceData getById(@Param("id") long id);
|
||||||
|
|
||||||
|
@Insert("""
|
||||||
|
INSERT INTO unity_feed_sources (
|
||||||
|
name,
|
||||||
|
feed_url,
|
||||||
|
is_active
|
||||||
|
) VALUES (
|
||||||
|
#{name},
|
||||||
|
#{feedUrl},
|
||||||
|
#{isActive}
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
|
||||||
|
int insert(UnityFeedSourceData s);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE unity_feed_sources
|
||||||
|
SET last_seen_guid = #{lastSeenGuid},
|
||||||
|
last_polled_at = now(),
|
||||||
|
last_error = NULL
|
||||||
|
WHERE id = #{id}
|
||||||
|
""")
|
||||||
|
int updateCursor(@Param("id") long id, @Param("lastSeenGuid") String lastSeenGuid);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE unity_feed_sources
|
||||||
|
SET last_error = #{lastError},
|
||||||
|
last_polled_at = now()
|
||||||
|
WHERE id = #{id}
|
||||||
|
""")
|
||||||
|
int updateError(@Param("id") long id, @Param("lastError") String lastError);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE unity_feed_sources
|
||||||
|
SET is_active = NOT is_active
|
||||||
|
WHERE id = #{id}
|
||||||
|
""")
|
||||||
|
int toggle(@Param("id") long id);
|
||||||
|
|
||||||
|
@Delete("""
|
||||||
|
DELETE FROM unity_feed_sources
|
||||||
|
WHERE id = #{id}
|
||||||
|
""")
|
||||||
|
int delete(@Param("id") long id);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,444 @@
|
||||||
|
package com.pandoli365.bibimbap.security;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.Inet4Address;
|
||||||
|
import java.net.Inet6Address;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpClient.Redirect;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.net.http.HttpResponse.BodyHandlers;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSRF 방어를 적용한 외부 URL fetcher.
|
||||||
|
*
|
||||||
|
* <p>외부에서 입력된 URL(피드 링크, OG 프리뷰 대상 등)을 가져올 때 내부망/메타데이터
|
||||||
|
* 엔드포인트로의 요청을 차단한다. JDK {@link HttpClient}를 followRedirects=NEVER 로 두고
|
||||||
|
* 매 홉마다 host 를 직접 resolve → 전 IP 검증 후 연결하는 방식으로 DNS rebinding 을 방어한다.
|
||||||
|
*
|
||||||
|
* <h2>연결 메커니즘 (W3-3 fix1, 결함1)</h2>
|
||||||
|
* <p>이전 구현은 검증된 IP literal 을 authority 로 핀닝하고 {@code Host} 헤더를 원본 host 로
|
||||||
|
* 덮어썼다. 그러나 (1) JDK {@link HttpClient} 는 {@code Host} 를 restricted header 로 취급해
|
||||||
|
* {@code HttpRequest.build()} 에서 {@code IllegalArgumentException} 을 던지므로 모든 fetch 가
|
||||||
|
* 실패했고, (2) IP literal 로 connect 하면 HTTPS 의 SNI/인증서 hostname 검증이 IP 기준이 되어
|
||||||
|
* 실제 HTTPS 대상(유니티 피드/OG 다수)의 TLS 검증이 깨진다.
|
||||||
|
*
|
||||||
|
* <p>이를 해소하기 위해 <b>option (b): 원본 hostname 으로 connect</b> 하되, connect 전에
|
||||||
|
* resolve 한 <b>모든 IP 를 검증</b>(전부 공인이어야 통과)하고, 비-loopback host 에 대해서는
|
||||||
|
* connect 직전에 <b>실제 사용될 peer IP 를 한 번 더 재검증</b>(rebinding 방어 유지)한다.
|
||||||
|
* hostname 으로 connect 하므로 HTTPS SNI/인증서 검증이 정상 동작하고({@code Host} 헤더 override
|
||||||
|
* 불필요 → restricted header 예외 제거), 사설/예약 IP 로 rebinding 되는 입력은 pre-connect 및
|
||||||
|
* re-validate 양 단계에서 차단된다.
|
||||||
|
*
|
||||||
|
* <p>잔여 TOCTOU(검증 시점 IP 와 HttpClient 가 실제 connect 하는 IP 의 불일치 가능성)는 JVM
|
||||||
|
* positive DNS 캐시({@code networkaddress.cache.ttl})로 동일 lookup 이 재사용되도록 두어 창을
|
||||||
|
* 최소화한다. 추가로 매 홉 connect 직전 재검증을 수행한다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class SsrfSafeFetcher {
|
||||||
|
|
||||||
|
static final int MAX_REDIRECTS = 3;
|
||||||
|
|
||||||
|
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
|
||||||
|
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5);
|
||||||
|
|
||||||
|
private static final String METADATA_IPV4 = "169.254.169.254";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결함3: 포트 allowlist. OG/피드 대상은 웹서버이므로 표준 웹 포트만 허용한다.
|
||||||
|
* 비명시 포트(scheme 기본포트)는 scheme 에 따라 80/443 으로 간주해 통과시킨다.
|
||||||
|
*/
|
||||||
|
private static final Set<Integer> ALLOWED_PORTS = Set.of(80, 443, 8080, 8443);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검증·핀닝을 거친 단일 홉 요청 결과. 응답 본문은 maxBytes 로 제한된 byte[] 이다.
|
||||||
|
*/
|
||||||
|
public record FetchResult(int status, String contentType, byte[] body, URI finalUrl) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||||
|
.followRedirects(Redirect.NEVER)
|
||||||
|
.connectTimeout(CONNECT_TIMEOUT)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 URL 을 SSRF 방어 하에 가져온다.
|
||||||
|
*
|
||||||
|
* <p>scheme/port/IP/redirect/size/timeout/Content-Type 검증 중 하나라도 실패하거나 예외가
|
||||||
|
* 발생하면 {@link Optional#empty()} 를 반환한다(예외를 호출자에 전파하지 않는다).
|
||||||
|
*
|
||||||
|
* @param url 가져올 URL
|
||||||
|
* @param maxBytes 응답 본문 누적 허용 바이트 (초과 시 거부)
|
||||||
|
* @param allowedContentTypes 허용할 media type 집합 (파라미터 제외, 소문자 비교)
|
||||||
|
*/
|
||||||
|
public Optional<FetchResult> fetch(URI url, long maxBytes, Set<String> allowedContentTypes) {
|
||||||
|
try {
|
||||||
|
URI current = url;
|
||||||
|
for (int hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||||
|
// #1 scheme, #1b port allowlist, #2 resolve, #3 전 IP 검증.
|
||||||
|
ValidatedTarget target = validate(current);
|
||||||
|
if (target == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// #4 connect 직전 peer IP 재검증 (rebinding 방어 유지). loopback 테스트 host 는
|
||||||
|
// allowLoopbackForTest 훅 경로에서 이미 통과 판정됨.
|
||||||
|
if (!revalidatePeer(target.host())) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원본 hostname 으로 connect → HTTPS SNI/인증서 검증 정상. Host 헤더 override 없음.
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(target.requestUri())
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<InputStream> response = httpClient.send(request, BodyHandlers.ofInputStream());
|
||||||
|
int status = response.statusCode();
|
||||||
|
|
||||||
|
// #5 redirect 매 홉 재검증: Location 을 새 URI 로 만들고 루프 상단에서 재검증.
|
||||||
|
if (isRedirect(status)) {
|
||||||
|
Optional<String> location = response.headers().firstValue("Location");
|
||||||
|
drain(response.body());
|
||||||
|
if (location.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
current = current.resolve(location.get());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #8 Content-Type 검증 (media type 만, 소문자).
|
||||||
|
String contentType = response.headers().firstValue("Content-Type").orElse("");
|
||||||
|
String mediaType = mediaType(contentType);
|
||||||
|
if (!allowedContentTypes.contains(mediaType)) {
|
||||||
|
drain(response.body());
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// #6 size cap: Content-Length 신뢰 금지, 스트림에서 누적 카운트.
|
||||||
|
byte[] body = readCapped(response.body(), maxBytes);
|
||||||
|
if (body == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional.of(new FetchResult(status, mediaType, body, current));
|
||||||
|
}
|
||||||
|
// redirect 홉 초과.
|
||||||
|
return Optional.empty();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// #9 graceful: timeout/IOException/InterruptedException 등 전 경로 차단.
|
||||||
|
if (e instanceof InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetch 없이 정적으로 #1(scheme) + #1b(port) + #2(resolve) + #3(전 IP 공인) 만 검사한다.
|
||||||
|
* 피드 등록 등 빠른 거부 판정에 사용한다. 예외는 false.
|
||||||
|
*/
|
||||||
|
public boolean isFetchableUrl(URI url) {
|
||||||
|
try {
|
||||||
|
return validate(url) != null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #1~#3 검증 후 connect 대상(원본 hostname authority + 검증 통과한 host)을 만든다.
|
||||||
|
* 검증 실패 시 null 을 반환한다.
|
||||||
|
*/
|
||||||
|
private ValidatedTarget validate(URI url) {
|
||||||
|
if (url == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// #1 scheme allowlist.
|
||||||
|
String scheme = url.getScheme();
|
||||||
|
if (scheme == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
scheme = scheme.toLowerCase(Locale.ROOT);
|
||||||
|
if (!scheme.equals("http") && !scheme.equals("https")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String host = url.getHost();
|
||||||
|
if (host == null || host.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #1b 포트 allowlist (결함3). 명시 안 된 경우 scheme 기본포트(http=80/https=443)는 허용.
|
||||||
|
int port = url.getPort();
|
||||||
|
if (port >= 0 && !isAllowedPort(port)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
InetAddress[] addrs;
|
||||||
|
try {
|
||||||
|
// #2 host resolve → 전 IP 해석.
|
||||||
|
addrs = resolve(host);
|
||||||
|
} catch (UnknownHostException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (addrs.length == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// #3 하나라도 사설/예약이면 거부 (전부 공인이어야 통과).
|
||||||
|
for (InetAddress addr : addrs) {
|
||||||
|
if (isBlockedAddress(addr)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원본 URL 을 그대로 connect 에 사용 (hostname 기반 → HTTPS SNI/인증서 검증 정상).
|
||||||
|
return new ValidatedTarget(url, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #1b 포트 allowlist 판정. OG/피드 대상=웹서버이므로 표준 웹 포트({@code 80,443,8080,8443})만
|
||||||
|
* 허용한다. 로컬 HttpServer 가 ephemeral 포트를 쓰는 테스트는 {@code allowLoopbackForTest}
|
||||||
|
* 훅과 함께 이 메서드를 override 해 임의 포트를 허용한다 — <b>운영 기본은 strict allowlist.</b>
|
||||||
|
*/
|
||||||
|
protected boolean isAllowedPort(int port) {
|
||||||
|
return ALLOWED_PORTS.contains(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #4 connect 직전 재검증 (DNS rebinding 방어 유지). host 를 다시 resolve 해 전 IP 가
|
||||||
|
* 여전히 공인인지 확인한다. 하나라도 사설/예약으로 바뀌었으면 거부.
|
||||||
|
*/
|
||||||
|
private boolean revalidatePeer(String host) {
|
||||||
|
InetAddress[] addrs;
|
||||||
|
try {
|
||||||
|
addrs = resolve(host);
|
||||||
|
} catch (UnknownHostException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (addrs.length == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (InetAddress addr : addrs) {
|
||||||
|
if (isBlockedAddress(addr)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #3 차단 대역 판정.
|
||||||
|
*
|
||||||
|
* <p>차단: loopback/any-local/link-local/site-local/multicast, 메타데이터 IP,
|
||||||
|
* IPv4-mapped IPv6, unique-local IPv6(fc00::/7).
|
||||||
|
*
|
||||||
|
* <p>fix1 결함2 추가: CGNAT 100.64.0.0/10, class-E 240.0.0.0/4, benchmarking
|
||||||
|
* 198.18.0.0/15·192.0.0.0/24, broadcast 255.255.255.255, NAT64 64:ff9b::/96(임베드 v4 재검증),
|
||||||
|
* 6to4 2002::/16(임베드 v4 재검증).
|
||||||
|
*
|
||||||
|
* <p>테스트에서 size/timeout/Content-Type 검증 경로를 로컬 서버로 실측하기 위해
|
||||||
|
* {@link #allowLoopbackForTest} 훅으로만 loopback 통과를 허용한다 — 기본 false(운영 차단).
|
||||||
|
*/
|
||||||
|
protected boolean isBlockedAddress(InetAddress addr) {
|
||||||
|
if (addr == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6 별도 처리: IPv4-mapped, NAT64, 6to4 는 내장 IPv4 추출 후 재검사.
|
||||||
|
if (addr instanceof Inet6Address ipv6) {
|
||||||
|
byte[] raw = ipv6.getAddress();
|
||||||
|
|
||||||
|
// IPv4-mapped IPv6(::ffff:a.b.c.d): 내장 IPv4 를 추출해 재검사.
|
||||||
|
if (isIpv4Mapped(raw)) {
|
||||||
|
return checkEmbeddedV4(raw, 12);
|
||||||
|
}
|
||||||
|
// NAT64 64:ff9b::/96 — 0064:ff9b:0:0:0:0:: prefix, 내장 IPv4 는 마지막 4바이트.
|
||||||
|
if (isNat64(raw)) {
|
||||||
|
return checkEmbeddedV4(raw, 12);
|
||||||
|
}
|
||||||
|
// 6to4 2002::/16 — 다음 4바이트(2..5)가 캡슐화 IPv4.
|
||||||
|
if ((raw[0] & 0xFF) == 0x20 && (raw[1] & 0xFF) == 0x02) {
|
||||||
|
return checkEmbeddedV4(raw, 2);
|
||||||
|
}
|
||||||
|
// unique-local IPv6 fc00::/7 (첫 바이트 0xFC 또는 0xFD).
|
||||||
|
int first = raw[0] & 0xFF;
|
||||||
|
if (first == 0xFC || first == 0xFD) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addr.isLoopbackAddress()) {
|
||||||
|
return !allowLoopbackForTest;
|
||||||
|
}
|
||||||
|
if (addr.isAnyLocalAddress()
|
||||||
|
|| addr.isLinkLocalAddress()
|
||||||
|
|| addr.isSiteLocalAddress()
|
||||||
|
|| addr.isMulticastAddress()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메타데이터 엔드포인트 명시 차단(link-local 로 이미 잡히나 이중 안전장치).
|
||||||
|
if (METADATA_IPV4.equals(addr.getHostAddress())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fix1 결함2: IPv4 추가 예약 대역 (isSiteLocalAddress 등으로 안 잡히는 갭).
|
||||||
|
if (addr instanceof Inet4Address ipv4) {
|
||||||
|
if (isBlockedV4Range(ipv4.getAddress())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPv6 내부 임베드 IPv4(offset 시작 4바이트)를 추출해 IPv4 차단 판정으로 위임한다.
|
||||||
|
*/
|
||||||
|
private boolean checkEmbeddedV4(byte[] raw, int offset) {
|
||||||
|
byte[] v4 = new byte[] { raw[offset], raw[offset + 1], raw[offset + 2], raw[offset + 3] };
|
||||||
|
try {
|
||||||
|
return isBlockedAddress(InetAddress.getByAddress(v4));
|
||||||
|
} catch (UnknownHostException e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fix1 결함2: JDK 표준 판정(loopback/site-local/link-local/multicast)으로 안 잡히는
|
||||||
|
* IPv4 예약 대역을 추가 차단한다.
|
||||||
|
*/
|
||||||
|
private static boolean isBlockedV4Range(byte[] b) {
|
||||||
|
int o0 = b[0] & 0xFF;
|
||||||
|
int o1 = b[1] & 0xFF;
|
||||||
|
int o2 = b[2] & 0xFF;
|
||||||
|
int o3 = b[3] & 0xFF;
|
||||||
|
|
||||||
|
// CGNAT 100.64.0.0/10 (MED) — isSiteLocalAddress=false 라 누락됨.
|
||||||
|
if (o0 == 100 && o1 >= 64 && o1 <= 127) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// class-E 240.0.0.0/4 (LOW).
|
||||||
|
if (o0 >= 240) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// benchmarking 198.18.0.0/15 (LOW).
|
||||||
|
if (o0 == 198 && (o1 == 18 || o1 == 19)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// IETF protocol assignments 192.0.0.0/24 (LOW).
|
||||||
|
if (o0 == 192 && o1 == 0 && o2 == 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// limited broadcast 255.255.255.255 (LOW).
|
||||||
|
if (o0 == 255 && o1 == 255 && o2 == 255 && o3 == 255) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isIpv4Mapped(byte[] raw) {
|
||||||
|
if (raw.length != 16) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
if (raw[i] != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (raw[10] & 0xFF) == 0xFF && (raw[11] & 0xFF) == 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NAT64 well-known prefix 64:ff9b::/96 판정. 첫 12바이트가 00 64 ff 9b 00..00.
|
||||||
|
*/
|
||||||
|
private static boolean isNat64(byte[] raw) {
|
||||||
|
if (raw.length != 16) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ((raw[0] & 0xFF) != 0x00 || (raw[1] & 0xFF) != 0x64
|
||||||
|
|| (raw[2] & 0xFF) != 0xFF || (raw[3] & 0xFF) != 0x9B) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 4; i < 12; i++) {
|
||||||
|
if (raw[i] != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테스트가 host→IP 매핑을 주입할 수 있도록 분리한 resolve 지점.
|
||||||
|
* 운영에서는 시스템 DNS 를 그대로 사용한다.
|
||||||
|
*/
|
||||||
|
protected InetAddress[] resolve(String host) throws UnknownHostException {
|
||||||
|
return InetAddress.getAllByName(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 보안 우회 테스트 훅: loopback 주소 차단을 무력화한다. <b>테스트 전용, 기본 false.</b>
|
||||||
|
* 운영 코드에서는 절대 true 로 설정하지 않는다.
|
||||||
|
*/
|
||||||
|
protected boolean allowLoopbackForTest = false;
|
||||||
|
|
||||||
|
private static boolean isRedirect(int status) {
|
||||||
|
return status == 301 || status == 302 || status == 303 || status == 307 || status == 308;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "text/html; charset=utf-8" → "text/html". 파라미터 제거 후 소문자/trim.
|
||||||
|
*/
|
||||||
|
private static String mediaType(String contentType) {
|
||||||
|
if (contentType == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int semi = contentType.indexOf(';');
|
||||||
|
String base = semi >= 0 ? contentType.substring(0, semi) : contentType;
|
||||||
|
return base.trim().toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #6 InputStream 에서 maxBytes 까지만 읽는다. 초과 시 null(거부). Content-Length 무시.
|
||||||
|
*/
|
||||||
|
private static byte[] readCapped(InputStream in, long maxBytes) throws IOException {
|
||||||
|
try (in) {
|
||||||
|
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
|
||||||
|
byte[] buf = new byte[8192];
|
||||||
|
long total = 0;
|
||||||
|
int n;
|
||||||
|
while ((n = in.read(buf)) != -1) {
|
||||||
|
total += n;
|
||||||
|
if (total > maxBytes) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
out.write(buf, 0, n);
|
||||||
|
}
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void drain(InputStream in) {
|
||||||
|
try (in) {
|
||||||
|
in.readAllBytes();
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// best-effort close; 차단/리다이렉트 경로에서 본문은 버린다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 검증을 통과한 connect 대상. requestUri 는 원본 host 기반(HTTPS SNI/인증서 정상). */
|
||||||
|
private record ValidatedTarget(URI requestUri, String host) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
package com.pandoli365.bibimbap.service;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.w3c.dom.Document;
|
||||||
|
import org.w3c.dom.Element;
|
||||||
|
import org.w3c.dom.Node;
|
||||||
|
import org.w3c.dom.NodeList;
|
||||||
|
|
||||||
|
import javax.xml.parsers.DocumentBuilder;
|
||||||
|
import javax.xml.parsers.DocumentBuilderFactory;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class FeedParser {
|
||||||
|
|
||||||
|
public record FeedItem(String guid, String title, String link, OffsetDateTime publishedAt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int GUID_CAP = 512;
|
||||||
|
private static final int TITLE_CAP = 500;
|
||||||
|
private static final int LINK_CAP = 2048;
|
||||||
|
|
||||||
|
public List<FeedItem> parse(byte[] body) {
|
||||||
|
if (body == null || body.length == 0) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Document doc = parseSafely(body);
|
||||||
|
if (doc == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Element root = doc.getDocumentElement();
|
||||||
|
if (root == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
String rootName = localName(root);
|
||||||
|
if ("feed".equalsIgnoreCase(rootName)) {
|
||||||
|
return parseAtom(doc);
|
||||||
|
}
|
||||||
|
// RSS: root <rss> wraps <channel>, or some feeds expose <channel> at root.
|
||||||
|
return parseRss(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Document parseSafely(byte[] body) {
|
||||||
|
try {
|
||||||
|
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
|
||||||
|
// XXE 방어 (보안 필수)
|
||||||
|
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||||
|
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||||
|
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||||
|
dbf.setXIncludeAware(false);
|
||||||
|
dbf.setExpandEntityReferences(false);
|
||||||
|
dbf.setNamespaceAware(true);
|
||||||
|
DocumentBuilder builder = dbf.newDocumentBuilder();
|
||||||
|
return builder.parse(new ByteArrayInputStream(body));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<FeedItem> parseRss(Document doc) {
|
||||||
|
List<FeedItem> result = new ArrayList<>();
|
||||||
|
NodeList items = doc.getElementsByTagNameNS("*", "item");
|
||||||
|
for (int i = 0; i < items.getLength(); i++) {
|
||||||
|
Node node = items.item(i);
|
||||||
|
if (node.getNodeType() != Node.ELEMENT_NODE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Element item = (Element) node;
|
||||||
|
String guid = firstChildText(item, "guid");
|
||||||
|
String link = firstChildText(item, "link");
|
||||||
|
if (guid == null || guid.isEmpty()) {
|
||||||
|
guid = link;
|
||||||
|
}
|
||||||
|
if (guid == null || guid.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String title = firstChildText(item, "title");
|
||||||
|
OffsetDateTime published = parseRfc1123(firstChildText(item, "pubDate"));
|
||||||
|
result.add(new FeedItem(cap(guid, GUID_CAP), cap(title, TITLE_CAP), cap(link, LINK_CAP), published));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<FeedItem> parseAtom(Document doc) {
|
||||||
|
List<FeedItem> result = new ArrayList<>();
|
||||||
|
NodeList entries = doc.getElementsByTagNameNS("*", "entry");
|
||||||
|
for (int i = 0; i < entries.getLength(); i++) {
|
||||||
|
Node node = entries.item(i);
|
||||||
|
if (node.getNodeType() != Node.ELEMENT_NODE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Element entry = (Element) node;
|
||||||
|
String guid = firstChildText(entry, "id");
|
||||||
|
if (guid == null || guid.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String title = firstChildText(entry, "title");
|
||||||
|
String link = atomLink(entry);
|
||||||
|
String dateText = firstChildText(entry, "updated");
|
||||||
|
if (dateText == null || dateText.isEmpty()) {
|
||||||
|
dateText = firstChildText(entry, "published");
|
||||||
|
}
|
||||||
|
OffsetDateTime published = parseIso(dateText);
|
||||||
|
result.add(new FeedItem(cap(guid, GUID_CAP), cap(title, TITLE_CAP), cap(link, LINK_CAP), published));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String atomLink(Element entry) {
|
||||||
|
NodeList links = entry.getElementsByTagNameNS("*", "link");
|
||||||
|
String firstHref = null;
|
||||||
|
for (int i = 0; i < links.getLength(); i++) {
|
||||||
|
Node node = links.item(i);
|
||||||
|
if (node.getNodeType() != Node.ELEMENT_NODE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Element link = (Element) node;
|
||||||
|
String href = link.getAttribute("href");
|
||||||
|
if (href == null || href.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (firstHref == null) {
|
||||||
|
firstHref = href;
|
||||||
|
}
|
||||||
|
String rel = link.getAttribute("rel");
|
||||||
|
if (rel == null || rel.isEmpty() || "alternate".equalsIgnoreCase(rel)) {
|
||||||
|
return href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstHref;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String firstChildText(Element parent, String childLocalName) {
|
||||||
|
NodeList children = parent.getChildNodes();
|
||||||
|
for (int i = 0; i < children.getLength(); i++) {
|
||||||
|
Node node = children.item(i);
|
||||||
|
if (node.getNodeType() != Node.ELEMENT_NODE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (childLocalName.equalsIgnoreCase(localName((Element) node))) {
|
||||||
|
String text = node.getTextContent();
|
||||||
|
return text == null ? null : text.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String localName(Element element) {
|
||||||
|
String local = element.getLocalName();
|
||||||
|
return local != null ? local : element.getTagName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private OffsetDateTime parseRfc1123(String value) {
|
||||||
|
if (value == null || value.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 외부 피드의 pubDate 는 선행 요일이 실제 날짜와 불일치하는 경우가 흔하다
|
||||||
|
// (예: "Tue, 24 Jun 2026" 이지만 24일은 실제 Wed). RFC_1123_DATE_TIME 은
|
||||||
|
// day-of-week 를 검증하여 이런 입력을 DateTimeException 으로 거부한다.
|
||||||
|
// 외부 신뢰 불가 입력이므로 선행 요일 토큰을 제거한 뒤 파싱한다(요일은 RFC_1123 에서 optional).
|
||||||
|
String normalized = value.replaceFirst("^\\s*[A-Za-z]{3,},\\s*", "");
|
||||||
|
try {
|
||||||
|
// RFC_1123_DATE_TIME 은 "GMT" 등 zone name 으로 해석되어 ZonedDateTime 성격을 가진다.
|
||||||
|
// OffsetDateTime.parse 로 직접 받으면 offset 미해석으로 DateTimeException → ZonedDateTime 경유.
|
||||||
|
return ZonedDateTime.parse(normalized, DateTimeFormatter.RFC_1123_DATE_TIME).toOffsetDateTime();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OffsetDateTime parseIso(String value) {
|
||||||
|
if (value == null || value.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return OffsetDateTime.parse(value);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String cap(String value, int max) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.length() <= max ? value : value.substring(0, max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
package com.pandoli365.bibimbap.service;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import org.jsoup.Jsoup;
|
||||||
|
import org.jsoup.nodes.Document;
|
||||||
|
import org.jsoup.nodes.Element;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import com.pandoli365.bibimbap.security.SsrfSafeFetcher;
|
||||||
|
import com.pandoli365.bibimbap.security.SsrfSafeFetcher.FetchResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 링크의 Open Graph 메타데이터를 SSRF 방어 하에 가져와 프리뷰로 변환한다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class OgPreviewService {
|
||||||
|
|
||||||
|
private static final long OG_MAX_BYTES = 512L * 1024L;
|
||||||
|
private static final Set<String> ALLOWED_CONTENT_TYPES =
|
||||||
|
Set.of("text/html", "application/xhtml+xml");
|
||||||
|
|
||||||
|
// DB 컬럼 길이 한계.
|
||||||
|
private static final int TITLE_MAX = 300;
|
||||||
|
private static final int DESCRIPTION_MAX = 600;
|
||||||
|
private static final int IMAGE_URL_MAX = 2048;
|
||||||
|
private static final int SITE_NAME_MAX = 200;
|
||||||
|
|
||||||
|
private final SsrfSafeFetcher ssrfSafeFetcher;
|
||||||
|
|
||||||
|
public OgPreviewService(SsrfSafeFetcher ssrfSafeFetcher) {
|
||||||
|
this.ssrfSafeFetcher = ssrfSafeFetcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record OgPreview(String title, String description, String imageUrl, String siteName) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<OgPreview> fetch(String linkUrl) {
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(linkUrl);
|
||||||
|
Optional<FetchResult> result =
|
||||||
|
ssrfSafeFetcher.fetch(uri, OG_MAX_BYTES, ALLOWED_CONTENT_TYPES);
|
||||||
|
if (result.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
FetchResult fr = result.get();
|
||||||
|
String html = new String(fr.body(), StandardCharsets.UTF_8);
|
||||||
|
Document doc = Jsoup.parse(html, fr.finalUrl().toString());
|
||||||
|
|
||||||
|
String title = cap(firstNonBlank(ogContent(doc, "og:title"), doc.title()), TITLE_MAX);
|
||||||
|
String description = cap(ogContent(doc, "og:description"), DESCRIPTION_MAX);
|
||||||
|
String imageUrl = cap(ogContent(doc, "og:image"), IMAGE_URL_MAX);
|
||||||
|
String siteName = cap(ogContent(doc, "og:site_name"), SITE_NAME_MAX);
|
||||||
|
|
||||||
|
return Optional.of(new OgPreview(title, description, imageUrl, siteName));
|
||||||
|
} catch (Exception e) {
|
||||||
|
// graceful: URI 파싱 실패/파싱 예외 등 전 경로 차단.
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String ogContent(Document doc, String property) {
|
||||||
|
Element meta = doc.selectFirst("meta[property=" + property + "]");
|
||||||
|
return meta != null ? meta.attr("content") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstNonBlank(String a, String b) {
|
||||||
|
if (a != null && !a.isBlank()) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String cap(String value, int max) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trimmed.length() > max ? trimmed.substring(0, max) : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
package com.pandoli365.bibimbap.service;
|
||||||
|
|
||||||
|
import com.pandoli365.bibimbap.data.UnityFeedItemData;
|
||||||
|
import com.pandoli365.bibimbap.data.UnityFeedSourceData;
|
||||||
|
import com.pandoli365.bibimbap.mapper.UnityFeedItemsMapper;
|
||||||
|
import com.pandoli365.bibimbap.mapper.UnityFeedSourcesMapper;
|
||||||
|
import com.pandoli365.bibimbap.security.SsrfSafeFetcher;
|
||||||
|
import com.pandoli365.bibimbap.security.SsrfSafeFetcher.FetchResult;
|
||||||
|
import com.pandoli365.bibimbap.service.FeedParser.FeedItem;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class UnityFeedPoller {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(UnityFeedPoller.class);
|
||||||
|
|
||||||
|
private static final long FEED_MAX_BYTES = 2L * 1024 * 1024;
|
||||||
|
private static final Set<String> FEED_CONTENT_TYPES = Set.of(
|
||||||
|
"application/rss+xml",
|
||||||
|
"application/atom+xml",
|
||||||
|
"application/xml",
|
||||||
|
"text/xml",
|
||||||
|
"text/html");
|
||||||
|
|
||||||
|
private final SsrfSafeFetcher ssrfSafeFetcher;
|
||||||
|
private final FeedParser feedParser;
|
||||||
|
private final UnityFeedSourcesMapper sourcesMapper;
|
||||||
|
private final UnityFeedItemsMapper itemsMapper;
|
||||||
|
|
||||||
|
public UnityFeedPoller(SsrfSafeFetcher ssrfSafeFetcher,
|
||||||
|
FeedParser feedParser,
|
||||||
|
UnityFeedSourcesMapper sourcesMapper,
|
||||||
|
UnityFeedItemsMapper itemsMapper) {
|
||||||
|
this.ssrfSafeFetcher = ssrfSafeFetcher;
|
||||||
|
this.feedParser = feedParser;
|
||||||
|
this.sourcesMapper = sourcesMapper;
|
||||||
|
this.itemsMapper = itemsMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${unity.feed.poll-delay-ms:1800000}")
|
||||||
|
public void poll() {
|
||||||
|
for (UnityFeedSourceData source : sourcesMapper.listActive()) {
|
||||||
|
try {
|
||||||
|
pollOnce(source.getId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 한 소스 실패가 전체 폴링을 멈추지 않게 격리
|
||||||
|
log.warn("unity feed poll 실패 sourceId={}", source.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int pollOnce(long sourceId) {
|
||||||
|
UnityFeedSourceData source = sourcesMapper.getById(sourceId);
|
||||||
|
if (source == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
URI uri;
|
||||||
|
try {
|
||||||
|
uri = URI.create(source.getFeedUrl());
|
||||||
|
} catch (Exception e) {
|
||||||
|
sourcesMapper.updateError(sourceId, "feed URL 파싱 실패");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<FetchResult> fetched = ssrfSafeFetcher.fetch(uri, FEED_MAX_BYTES, FEED_CONTENT_TYPES);
|
||||||
|
if (fetched.isEmpty()) {
|
||||||
|
sourcesMapper.updateError(sourceId, "fetch 실패 또는 SSRF 차단");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FeedItem> items = feedParser.parse(fetched.get().body());
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
sourcesMapper.updateCursor(sourceId, source.getLastSeenGuid());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int newCount = 0;
|
||||||
|
for (FeedItem item : items) {
|
||||||
|
if (itemsMapper.existsByGuid(sourceId, item.guid())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
UnityFeedItemData data = new UnityFeedItemData();
|
||||||
|
data.setSourceId(sourceId);
|
||||||
|
data.setGuid(item.guid());
|
||||||
|
data.setTitle(item.title());
|
||||||
|
data.setLinkUrl(item.link());
|
||||||
|
data.setPublishedAt(item.publishedAt());
|
||||||
|
if (itemsMapper.insertIgnoreDup(data) == 1) {
|
||||||
|
newCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourcesMapper.updateCursor(sourceId, items.get(0).guid());
|
||||||
|
return newCount;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("unity feed pollOnce 실패 sourceId={}", sourceId, e);
|
||||||
|
try {
|
||||||
|
sourcesMapper.updateError(sourceId, "폴링 처리 중 오류");
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
// updateError 자체 실패도 graceful — 전파 금지
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,355 @@
|
||||||
|
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
|
||||||
|
<%@ page import="org.springframework.web.util.HtmlUtils" %>
|
||||||
|
<%@ page import="com.pandoli365.bibimbap.data.PostCategoryData" %>
|
||||||
|
<%@ page import="java.util.Collections" %>
|
||||||
|
<%@ page import="java.util.List" %>
|
||||||
|
<%
|
||||||
|
String ctx = request.getContextPath();
|
||||||
|
List<PostCategoryData> categories = Collections.emptyList();
|
||||||
|
Object categoriesAttr = request.getAttribute("categories");
|
||||||
|
if (categoriesAttr instanceof List<?>) { categories = (List<PostCategoryData>) categoriesAttr; }
|
||||||
|
String csrfToken = (String) request.getAttribute("csrfToken");
|
||||||
|
String csrfTokenHtml = HtmlUtils.htmlEscape(csrfToken == null ? "" : csrfToken);
|
||||||
|
%>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="_csrf" content="<%= csrfTokenHtml %>">
|
||||||
|
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
|
||||||
|
<title>포스트 카테고리 관리 | bibimbap</title>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
--surface: #faf8f5;
|
||||||
|
--card-bg: #fff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--text-muted: #5c5c5c;
|
||||||
|
--accent: #e8a54b;
|
||||||
|
--accent-soft: rgba(232, 165, 75, 0.16);
|
||||||
|
--border: rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow: rgba(0, 0, 0, 0.06);
|
||||||
|
--field-bg: #fff;
|
||||||
|
--button-text: #1a1a1a;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--surface: #121212;
|
||||||
|
--card-bg: #1e1e1e;
|
||||||
|
--text: #ece8e1;
|
||||||
|
--text-muted: #a39e96;
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--shadow: rgba(0, 0, 0, 0.35);
|
||||||
|
--field-bg: #181818;
|
||||||
|
--button-text: #1a1a1a;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.admin-page {
|
||||||
|
max-width: 78rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
.admin-hero { margin-bottom: 1.5rem; }
|
||||||
|
.admin-hero__eyebrow {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.admin-hero h1 { margin: 0; font-size: 1.9rem; line-height: 1.2; }
|
||||||
|
.admin-hero p { margin: 0.45rem 0 0; color: var(--text-muted); line-height: 1.6; }
|
||||||
|
.admin-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
box-shadow: 0 2px 8px var(--shadow);
|
||||||
|
}
|
||||||
|
.admin-section h2 { margin: 0 0 1rem; font-size: 1.15rem; }
|
||||||
|
.admin-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
.admin-field { display: grid; gap: 0.375rem; }
|
||||||
|
.admin-field label { font-size: 0.8125rem; font-weight: 700; color: var(--text); }
|
||||||
|
.admin-field input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 3rem;
|
||||||
|
padding: 0.7rem 0.875rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--field-bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.admin-form-actions { margin-top: 1rem; }
|
||||||
|
.admin-table-wrap { overflow-x: auto; }
|
||||||
|
.admin-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||||
|
.admin-table th, .admin-table td {
|
||||||
|
padding: 0.65rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.admin-table th {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.admin-actions { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
|
||||||
|
.admin-btn {
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0 0.85rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.admin-btn:hover { border-color: rgba(232, 165, 75, 0.45); }
|
||||||
|
.admin-btn--primary { border-color: transparent; background: var(--accent); color: var(--button-text); }
|
||||||
|
.admin-btn--danger { border-color: rgba(200, 60, 60, 0.45); color: #c83c3c; }
|
||||||
|
.admin-muted { color: var(--text-muted); font-size: 0.8125rem; }
|
||||||
|
.admin-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.admin-input-inline {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
max-width: 9rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--field-bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||||
|
<main class="admin-page">
|
||||||
|
<section class="admin-hero">
|
||||||
|
<p class="admin-hero__eyebrow">POST CATEGORY ADMIN</p>
|
||||||
|
<h1>포스트 카테고리 관리</h1>
|
||||||
|
<p>포스트 카테고리를 등록·수정·비활성화합니다. 소속 포스트가 있는 카테고리는 삭제할 수 없으며 비활성화를 권장합니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<h2>신규 카테고리 등록</h2>
|
||||||
|
<form id="category-create-form" autocomplete="off">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfTokenHtml %>" />
|
||||||
|
<div class="admin-form-grid">
|
||||||
|
<div class="admin-field">
|
||||||
|
<label for="cat-name">이름 (필수)</label>
|
||||||
|
<input type="text" id="cat-name" name="name" maxlength="80" placeholder="카테고리 이름" required />
|
||||||
|
</div>
|
||||||
|
<div class="admin-field">
|
||||||
|
<label for="cat-slug">슬러그 (필수)</label>
|
||||||
|
<input type="text" id="cat-slug" name="slug" maxlength="80" placeholder="slug" required />
|
||||||
|
</div>
|
||||||
|
<div class="admin-field">
|
||||||
|
<label for="cat-sort">정렬 순서</label>
|
||||||
|
<input type="number" id="cat-sort" name="sortOrder" step="1" value="0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-form-actions">
|
||||||
|
<button class="admin-btn admin-btn--primary" type="submit" data-action="create">카테고리 등록</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<h2>카테고리 목록</h2>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">이름</th>
|
||||||
|
<th scope="col">슬러그</th>
|
||||||
|
<th scope="col">정렬</th>
|
||||||
|
<th scope="col">활성</th>
|
||||||
|
<th scope="col">액션</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% if (categories.isEmpty()) { %>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5"><span class="admin-muted">등록된 카테고리가 없습니다.</span></td>
|
||||||
|
</tr>
|
||||||
|
<% } else {
|
||||||
|
for (PostCategoryData cat : categories) {
|
||||||
|
boolean catActive = Boolean.TRUE.equals(cat.getIsActive());
|
||||||
|
String catSortOrder = cat.getSortOrder() == null ? "" : String.valueOf(cat.getSortOrder());
|
||||||
|
%>
|
||||||
|
<tr>
|
||||||
|
<td><%= HtmlUtils.htmlEscape(cat.getName() == null ? "" : cat.getName()) %></td>
|
||||||
|
<td><%= HtmlUtils.htmlEscape(cat.getSlug() == null ? "" : cat.getSlug()) %></td>
|
||||||
|
<td><%= HtmlUtils.htmlEscape(catSortOrder) %></td>
|
||||||
|
<td>
|
||||||
|
<% if (catActive) { %><span class="admin-status">활성</span><% } else { %><span class="admin-muted">비활성</span><% } %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="admin-actions">
|
||||||
|
<input type="number" class="admin-input-inline"
|
||||||
|
data-edit-sort="<%= cat.getId() %>"
|
||||||
|
value="<%= HtmlUtils.htmlEscape(catSortOrder) %>"
|
||||||
|
step="1" aria-label="정렬 순서 수정" />
|
||||||
|
<button class="admin-btn" type="button"
|
||||||
|
data-action="save-sort"
|
||||||
|
data-cat-id="<%= cat.getId() %>">정렬 저장</button>
|
||||||
|
<button class="admin-btn" type="button"
|
||||||
|
data-action="toggle"
|
||||||
|
data-cat-id="<%= cat.getId() %>"
|
||||||
|
data-active="<%= catActive %>">
|
||||||
|
<%= catActive ? "비활성화" : "활성화" %>
|
||||||
|
</button>
|
||||||
|
<button class="admin-btn admin-btn--danger" type="button"
|
||||||
|
data-action="delete"
|
||||||
|
data-cat-id="<%= cat.getId() %>">삭제</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }
|
||||||
|
} %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var ctx = document.querySelector('base') ? '' : '';
|
||||||
|
var meta = document.querySelector('meta[name="_csrf"]');
|
||||||
|
var CSRF_TOKEN = meta ? meta.getAttribute('content') : '';
|
||||||
|
var BASE = '/admin/post-categories';
|
||||||
|
|
||||||
|
function notify(message) {
|
||||||
|
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
|
||||||
|
window.BibimbapModal.alert({ title: '포스트 카테고리 관리', message: message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(url, params) {
|
||||||
|
var options = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-Token': CSRF_TOKEN,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!params) {
|
||||||
|
params = new URLSearchParams();
|
||||||
|
}
|
||||||
|
params.set('_csrf', CSRF_TOKEN);
|
||||||
|
options.headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
||||||
|
options.body = params.toString();
|
||||||
|
return fetch(url, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResult(res) {
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json().then(function (data) {
|
||||||
|
notify((data && data.message) ? data.message : ('요청을 처리하지 못했습니다. (상태 ' + res.status + ')'));
|
||||||
|
}).catch(function () {
|
||||||
|
notify('요청을 처리하지 못했습니다. (상태 ' + res.status + ')');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError() {
|
||||||
|
notify('요청 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCategory() {
|
||||||
|
var name = (document.getElementById('cat-name').value || '').trim();
|
||||||
|
var slug = (document.getElementById('cat-slug').value || '').trim();
|
||||||
|
var sort = (document.getElementById('cat-sort').value || '').trim();
|
||||||
|
if (!name || !slug) {
|
||||||
|
notify('이름과 슬러그를 입력해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var params = new URLSearchParams();
|
||||||
|
params.set('name', name);
|
||||||
|
params.set('slug', slug);
|
||||||
|
if (sort) {
|
||||||
|
params.set('sortOrder', sort);
|
||||||
|
}
|
||||||
|
post(BASE, params).then(handleResult).catch(handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSort(catId) {
|
||||||
|
var input = document.querySelector('[data-edit-sort="' + catId + '"]');
|
||||||
|
var sort = input ? input.value.trim() : '';
|
||||||
|
var params = new URLSearchParams();
|
||||||
|
params.set('sortOrder', sort === '' ? '0' : sort);
|
||||||
|
post(BASE + '/' + encodeURIComponent(catId), params).then(handleResult).catch(handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleActive(catId, currentActive) {
|
||||||
|
var params = new URLSearchParams();
|
||||||
|
params.set('isActive', currentActive === 'true' ? 'false' : 'true');
|
||||||
|
post(BASE + '/' + encodeURIComponent(catId), params).then(handleResult).catch(handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCategory(catId) {
|
||||||
|
if (!window.confirm('이 카테고리를 삭제하시겠습니까? 소속 포스트가 있으면 삭제할 수 없습니다.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
post(BASE + '/' + encodeURIComponent(catId) + '/delete').then(handleResult).catch(handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = document.getElementById('category-create-form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
createCategory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function (ev) {
|
||||||
|
var btn = ev.target.closest('[data-action]');
|
||||||
|
if (!btn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var action = btn.getAttribute('data-action');
|
||||||
|
var catId = btn.getAttribute('data-cat-id');
|
||||||
|
if (action === 'save-sort' && catId) {
|
||||||
|
saveSort(catId);
|
||||||
|
} else if (action === 'toggle' && catId) {
|
||||||
|
toggleActive(catId, btn.getAttribute('data-active'));
|
||||||
|
} else if (action === 'delete' && catId) {
|
||||||
|
removeCategory(catId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,443 @@
|
||||||
|
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
|
||||||
|
<%@ page import="org.springframework.web.util.HtmlUtils" %>
|
||||||
|
<%@ page import="com.pandoli365.bibimbap.data.UnityFeedSourceData" %>
|
||||||
|
<%@ page import="com.pandoli365.bibimbap.data.UnityFeedItemData" %>
|
||||||
|
<%@ page import="java.util.Collections" %>
|
||||||
|
<%@ page import="java.util.List" %>
|
||||||
|
<%
|
||||||
|
String ctx = request.getContextPath();
|
||||||
|
List<UnityFeedSourceData> sources = Collections.emptyList();
|
||||||
|
Object sourcesAttr = request.getAttribute("sources");
|
||||||
|
if (sourcesAttr instanceof List<?>) { sources = (List<UnityFeedSourceData>) sourcesAttr; }
|
||||||
|
List<UnityFeedItemData> unackItems = Collections.emptyList();
|
||||||
|
Object unackItemsAttr = request.getAttribute("unackItems");
|
||||||
|
if (unackItemsAttr instanceof List<?>) { unackItems = (List<UnityFeedItemData>) unackItemsAttr; }
|
||||||
|
Object unackCountAttr = request.getAttribute("unackCount");
|
||||||
|
String unackCount = unackCountAttr == null ? "0" : String.valueOf(unackCountAttr);
|
||||||
|
String csrfToken = (String) request.getAttribute("csrfToken");
|
||||||
|
String csrfTokenHtml = HtmlUtils.htmlEscape(csrfToken == null ? "" : csrfToken);
|
||||||
|
%>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="_csrf" content="<%= csrfTokenHtml %>">
|
||||||
|
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
|
||||||
|
<title>Unity 피드 관리 | bibimbap</title>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
--surface: #faf8f5;
|
||||||
|
--card-bg: #fff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--text-muted: #5c5c5c;
|
||||||
|
--accent: #e8a54b;
|
||||||
|
--accent-soft: rgba(232, 165, 75, 0.16);
|
||||||
|
--border: rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow: rgba(0, 0, 0, 0.06);
|
||||||
|
--field-bg: #fff;
|
||||||
|
--button-text: #1a1a1a;
|
||||||
|
--danger: #c83c3c;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--surface: #121212;
|
||||||
|
--card-bg: #1e1e1e;
|
||||||
|
--text: #ece8e1;
|
||||||
|
--text-muted: #a39e96;
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--shadow: rgba(0, 0, 0, 0.35);
|
||||||
|
--field-bg: #181818;
|
||||||
|
--button-text: #1a1a1a;
|
||||||
|
--danger: #ef7878;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.admin-page {
|
||||||
|
max-width: 78rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
.admin-hero { margin-bottom: 1.5rem; }
|
||||||
|
.admin-hero__eyebrow {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.admin-hero h1 { margin: 0; font-size: 1.9rem; line-height: 1.2; }
|
||||||
|
.admin-hero p { margin: 0.45rem 0 0; color: var(--text-muted); line-height: 1.6; }
|
||||||
|
.admin-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
box-shadow: 0 2px 8px var(--shadow);
|
||||||
|
}
|
||||||
|
.admin-section h2 { margin: 0 0 1rem; font-size: 1.15rem; }
|
||||||
|
.admin-section__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
.admin-section__head h2 { margin: 0; }
|
||||||
|
.admin-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
.admin-field { display: grid; gap: 0.375rem; }
|
||||||
|
.admin-field--full { grid-column: 1 / -1; }
|
||||||
|
.admin-field label { font-size: 0.8125rem; font-weight: 700; color: var(--text); }
|
||||||
|
.admin-field input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 3rem;
|
||||||
|
padding: 0.7rem 0.875rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--field-bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.admin-form-actions { margin-top: 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.admin-table-wrap { overflow-x: auto; }
|
||||||
|
.admin-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||||
|
.admin-table th, .admin-table td {
|
||||||
|
padding: 0.65rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.admin-table th {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.admin-table td a { color: var(--accent); word-break: break-all; }
|
||||||
|
.admin-actions { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
|
||||||
|
.admin-btn {
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0 0.85rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.admin-btn:hover { border-color: rgba(232, 165, 75, 0.45); }
|
||||||
|
.admin-btn--primary { border-color: transparent; background: var(--accent); color: var(--button-text); }
|
||||||
|
.admin-btn--danger { border-color: rgba(200, 60, 60, 0.45); color: var(--danger); }
|
||||||
|
.admin-muted { color: var(--text-muted); font-size: 0.8125rem; }
|
||||||
|
.admin-error { color: var(--danger); font-size: 0.8125rem; }
|
||||||
|
.admin-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.admin-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||||
|
<main class="admin-page">
|
||||||
|
<section class="admin-hero">
|
||||||
|
<p class="admin-hero__eyebrow">UNITY FEED ADMIN</p>
|
||||||
|
<h1>Unity 피드 관리</h1>
|
||||||
|
<p>Unity 관련 외부 피드 소스를 등록하고 폴링하여 미확인 항목을 검토합니다. 피드 URL 은 등록 시 SSRF 안전성을 검증합니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<h2>신규 피드 소스 등록</h2>
|
||||||
|
<form id="source-create-form" autocomplete="off">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfTokenHtml %>" />
|
||||||
|
<div class="admin-form-grid">
|
||||||
|
<div class="admin-field">
|
||||||
|
<label for="src-name">이름 (필수)</label>
|
||||||
|
<input type="text" id="src-name" name="name" maxlength="120" placeholder="피드 이름" required />
|
||||||
|
</div>
|
||||||
|
<div class="admin-field admin-field--full">
|
||||||
|
<label for="src-url">피드 URL (필수)</label>
|
||||||
|
<input type="url" id="src-url" name="feedUrl" maxlength="2048" placeholder="https://example.com/feed.xml" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-form-actions">
|
||||||
|
<button class="admin-btn admin-btn--primary" type="submit" data-action="create">피드 등록</button>
|
||||||
|
<button class="admin-btn" type="button" data-action="poll">지금 폴링</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<h2>피드 소스 목록</h2>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">이름</th>
|
||||||
|
<th scope="col">피드 URL</th>
|
||||||
|
<th scope="col">활성</th>
|
||||||
|
<th scope="col">최근 오류</th>
|
||||||
|
<th scope="col">액션</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% if (sources.isEmpty()) { %>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5"><span class="admin-muted">등록된 피드 소스가 없습니다.</span></td>
|
||||||
|
</tr>
|
||||||
|
<% } else {
|
||||||
|
for (UnityFeedSourceData src : sources) {
|
||||||
|
boolean srcActive = Boolean.TRUE.equals(src.getIsActive());
|
||||||
|
boolean hasError = src.getLastError() != null && !src.getLastError().isBlank();
|
||||||
|
%>
|
||||||
|
<tr>
|
||||||
|
<td><%= HtmlUtils.htmlEscape(src.getName() == null ? "" : src.getName()) %></td>
|
||||||
|
<td><%= HtmlUtils.htmlEscape(src.getFeedUrl() == null ? "" : src.getFeedUrl()) %></td>
|
||||||
|
<td>
|
||||||
|
<% if (srcActive) { %><span class="admin-status">활성</span><% } else { %><span class="admin-muted">비활성</span><% } %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if (hasError) { %>
|
||||||
|
<span class="admin-error"><%= HtmlUtils.htmlEscape(src.getLastError()) %></span>
|
||||||
|
<% } else { %><span class="admin-muted">-</span><% } %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="admin-actions">
|
||||||
|
<button class="admin-btn" type="button"
|
||||||
|
data-action="toggle"
|
||||||
|
data-src-id="<%= src.getId() %>">
|
||||||
|
<%= srcActive ? "비활성화" : "활성화" %>
|
||||||
|
</button>
|
||||||
|
<button class="admin-btn admin-btn--danger" type="button"
|
||||||
|
data-action="delete"
|
||||||
|
data-src-id="<%= src.getId() %>">삭제</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }
|
||||||
|
} %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<div class="admin-section__head">
|
||||||
|
<h2>미확인 항목</h2>
|
||||||
|
<span class="admin-badge"><%= HtmlUtils.htmlEscape(unackCount) %></span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">제목</th>
|
||||||
|
<th scope="col">소스</th>
|
||||||
|
<th scope="col">감지 시각</th>
|
||||||
|
<th scope="col">액션</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% if (unackItems.isEmpty()) { %>
|
||||||
|
<tr>
|
||||||
|
<td colspan="4"><span class="admin-muted">미확인 항목이 없습니다.</span></td>
|
||||||
|
</tr>
|
||||||
|
<% } else {
|
||||||
|
for (UnityFeedItemData item : unackItems) {
|
||||||
|
String itemDetectedAt = item.getDetectedAt() == null ? "" : String.valueOf(item.getDetectedAt());
|
||||||
|
%>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="<%= HtmlUtils.htmlEscape(item.getLinkUrl() == null ? "" : item.getLinkUrl()) %>"
|
||||||
|
target="_blank" rel="nofollow noopener"><%= HtmlUtils.htmlEscape(item.getTitle() == null ? "" : item.getTitle()) %></a>
|
||||||
|
</td>
|
||||||
|
<td><%= HtmlUtils.htmlEscape(item.getSourceName() == null ? "" : item.getSourceName()) %></td>
|
||||||
|
<td><%= HtmlUtils.htmlEscape(itemDetectedAt) %></td>
|
||||||
|
<td>
|
||||||
|
<button class="admin-btn" type="button"
|
||||||
|
data-action="ack"
|
||||||
|
data-item-id="<%= item.getId() %>">확인</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }
|
||||||
|
} %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var meta = document.querySelector('meta[name="_csrf"]');
|
||||||
|
var CSRF_TOKEN = meta ? meta.getAttribute('content') : '';
|
||||||
|
var BASE = '/admin/unity-feeds';
|
||||||
|
|
||||||
|
function notify(message) {
|
||||||
|
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
|
||||||
|
window.BibimbapModal.alert({ title: 'Unity 피드 관리', message: message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(url, params) {
|
||||||
|
var options = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-Token': CSRF_TOKEN,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!params) {
|
||||||
|
params = new URLSearchParams();
|
||||||
|
}
|
||||||
|
params.set('_csrf', CSRF_TOKEN);
|
||||||
|
options.headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
||||||
|
options.body = params.toString();
|
||||||
|
return fetch(url, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResultWithMessage(res, okMessage) {
|
||||||
|
res.json().then(function (data) {
|
||||||
|
var message = (data && data.message) ? data.message : null;
|
||||||
|
if (res.ok) {
|
||||||
|
notify(okMessage || message || '처리되었습니다.');
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notify(message || ('요청을 처리하지 못했습니다. (상태 ' + res.status + ')'));
|
||||||
|
}).catch(function () {
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notify('요청을 처리하지 못했습니다. (상태 ' + res.status + ')');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadOnOk(res) {
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json().then(function (data) {
|
||||||
|
notify((data && data.message) ? data.message : ('요청을 처리하지 못했습니다. (상태 ' + res.status + ')'));
|
||||||
|
}).catch(function () {
|
||||||
|
notify('요청을 처리하지 못했습니다. (상태 ' + res.status + ')');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError() {
|
||||||
|
notify('요청 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSource() {
|
||||||
|
var name = (document.getElementById('src-name').value || '').trim();
|
||||||
|
var url = (document.getElementById('src-url').value || '').trim();
|
||||||
|
if (!name || !url) {
|
||||||
|
notify('이름과 피드 URL 을 입력해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var params = new URLSearchParams();
|
||||||
|
params.set('name', name);
|
||||||
|
params.set('feedUrl', url);
|
||||||
|
post(BASE, params).then(reloadOnOk).catch(handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollNow() {
|
||||||
|
post(BASE + '/poll').then(function (res) {
|
||||||
|
res.json().then(function (data) {
|
||||||
|
if (res.ok) {
|
||||||
|
var count = (data && typeof data.newCount === 'number') ? data.newCount : 0;
|
||||||
|
notify('폴링 완료: 신규 ' + count + '건');
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notify((data && data.message) ? data.message : ('폴링에 실패했습니다. (상태 ' + res.status + ')'));
|
||||||
|
}).catch(function () {
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notify('폴링에 실패했습니다. (상태 ' + res.status + ')');
|
||||||
|
});
|
||||||
|
}).catch(handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSource(id) {
|
||||||
|
post(BASE + '/' + encodeURIComponent(id) + '/toggle').then(reloadOnOk).catch(handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSource(id) {
|
||||||
|
if (!window.confirm('이 피드 소스를 삭제하시겠습니까?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
post(BASE + '/' + encodeURIComponent(id) + '/delete').then(reloadOnOk).catch(handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ackItem(itemId) {
|
||||||
|
post(BASE + '/items/' + encodeURIComponent(itemId) + '/ack').then(reloadOnOk).catch(handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = document.getElementById('source-create-form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
createSource();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function (ev) {
|
||||||
|
var btn = ev.target.closest('[data-action]');
|
||||||
|
if (!btn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var action = btn.getAttribute('data-action');
|
||||||
|
if (action === 'poll') {
|
||||||
|
ev.preventDefault();
|
||||||
|
pollNow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var srcId = btn.getAttribute('data-src-id');
|
||||||
|
var itemId = btn.getAttribute('data-item-id');
|
||||||
|
if (action === 'toggle' && srcId) {
|
||||||
|
toggleSource(srcId);
|
||||||
|
} else if (action === 'delete' && srcId) {
|
||||||
|
removeSource(srcId);
|
||||||
|
} else if (action === 'ack' && itemId) {
|
||||||
|
ackItem(itemId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -200,6 +200,7 @@
|
||||||
</a>
|
</a>
|
||||||
<nav class="site-header__nav" aria-label="주요 메뉴">
|
<nav class="site-header__nav" aria-label="주요 메뉴">
|
||||||
<a class="site-header__nav-link" href="${pageContext.request.contextPath}/recruit">팀원 모집</a>
|
<a class="site-header__nav-link" href="${pageContext.request.contextPath}/recruit">팀원 모집</a>
|
||||||
|
<a class="site-header__nav-link" href="${pageContext.request.contextPath}/posts">포스팅</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="site-header__actions">
|
<div class="site-header__actions">
|
||||||
<button type="button" class="site-header__icon-btn" id="theme-toggle" aria-label="다크 모드로 전환" title="테마 전환">
|
<button type="button" class="site-header__icon-btn" id="theme-toggle" aria-label="다크 모드로 전환" title="테마 전환">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
|
||||||
|
<%@ page import="org.springframework.web.util.HtmlUtils" %>
|
||||||
|
<%@ page import="com.pandoli365.bibimbap.data.PostData" %>
|
||||||
|
<%
|
||||||
|
String ctx = request.getContextPath();
|
||||||
|
PostData post = (PostData) request.getAttribute("post");
|
||||||
|
%>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
|
||||||
|
<title><%= HtmlUtils.htmlEscape(post.getTitle() == null ? "" : post.getTitle()) %> | bibimbap</title>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
--surface: #faf8f5;
|
||||||
|
--card-bg: #fff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--text-muted: #5c5c5c;
|
||||||
|
--accent: #e8a54b;
|
||||||
|
--accent-soft: rgba(232, 165, 75, 0.16);
|
||||||
|
--border: rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--surface: #121212;
|
||||||
|
--card-bg: #1e1e1e;
|
||||||
|
--text: #ece8e1;
|
||||||
|
--text-muted: #a39e96;
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--shadow: rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.post-page {
|
||||||
|
max-width: 48rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
.post-back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.post-back:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.post-category {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 1.7rem;
|
||||||
|
padding: 0 0.6rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.post-title {
|
||||||
|
margin: 0.7rem 0 0.45rem;
|
||||||
|
font-size: 1.85rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.post-author {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.post-body {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.post-body img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.post-body pre {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0.9rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.og-card {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.9rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: 0 2px 8px var(--shadow);
|
||||||
|
}
|
||||||
|
.og-card:hover {
|
||||||
|
border-color: rgba(232, 165, 75, 0.45);
|
||||||
|
}
|
||||||
|
.og-card__img {
|
||||||
|
width: 11rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.og-card__body {
|
||||||
|
padding: 0.95rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.og-card__site {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.og-card__title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.og-card__desc {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.og-card {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.og-card__img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||||
|
<main class="post-page">
|
||||||
|
<a class="post-back" href="<%= ctx %>/posts">목록으로</a>
|
||||||
|
|
||||||
|
<% if (post.getCategoryName() != null && !post.getCategoryName().isBlank()) { %>
|
||||||
|
<span class="post-category"><%= HtmlUtils.htmlEscape(post.getCategoryName()) %></span>
|
||||||
|
<% } %>
|
||||||
|
<h1 class="post-title"><%= HtmlUtils.htmlEscape(post.getTitle() == null ? "" : post.getTitle()) %></h1>
|
||||||
|
<p class="post-author"><%= HtmlUtils.htmlEscape(post.getAuthorDisplayName() == null ? "" : post.getAuthorDisplayName()) %></p>
|
||||||
|
|
||||||
|
<%-- 본문은 PostMarkdownService 가 sanitize 한 신뢰 HTML 을 직접 출력한다 (유일한 신뢰 원천). --%>
|
||||||
|
<article class="post-body"><%= post.getBodySanitizedHtml() == null ? "" : post.getBodySanitizedHtml() %></article>
|
||||||
|
|
||||||
|
<% if (post.getOgTitle() != null && !post.getOgTitle().isBlank()) { %>
|
||||||
|
<a class="og-card" href="<%= HtmlUtils.htmlEscape(post.getLinkUrl() == null ? "" : post.getLinkUrl()) %>" rel="nofollow noopener" target="_blank">
|
||||||
|
<% if (post.getOgImageUrl() != null && !post.getOgImageUrl().isBlank()) { %>
|
||||||
|
<img class="og-card__img" src="<%= HtmlUtils.htmlEscape(post.getOgImageUrl()) %>" alt="">
|
||||||
|
<% } %>
|
||||||
|
<span class="og-card__body">
|
||||||
|
<% if (post.getOgSiteName() != null && !post.getOgSiteName().isBlank()) { %>
|
||||||
|
<span class="og-card__site"><%= HtmlUtils.htmlEscape(post.getOgSiteName()) %></span>
|
||||||
|
<% } %>
|
||||||
|
<span class="og-card__title"><%= HtmlUtils.htmlEscape(post.getOgTitle()) %></span>
|
||||||
|
<% if (post.getOgDescription() != null && !post.getOgDescription().isBlank()) { %>
|
||||||
|
<span class="og-card__desc"><%= HtmlUtils.htmlEscape(post.getOgDescription()) %></span>
|
||||||
|
<% } %>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
</main>
|
||||||
|
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,285 @@
|
||||||
|
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
|
||||||
|
<%@ page import="org.springframework.web.util.HtmlUtils" %>
|
||||||
|
<%@ page import="com.pandoli365.bibimbap.data.PostData" %>
|
||||||
|
<%@ page import="com.pandoli365.bibimbap.data.PostCategoryData" %>
|
||||||
|
<%@ page import="java.util.Collections" %>
|
||||||
|
<%@ page import="java.util.List" %>
|
||||||
|
<%
|
||||||
|
String ctx = request.getContextPath();
|
||||||
|
String mode = (String) request.getAttribute("mode");
|
||||||
|
boolean isEdit = "edit".equals(mode);
|
||||||
|
PostData post = (PostData) request.getAttribute("post");
|
||||||
|
List<PostCategoryData> categories = Collections.emptyList();
|
||||||
|
Object categoriesAttr = request.getAttribute("categories");
|
||||||
|
if (categoriesAttr instanceof List<?>) { categories = (List<PostCategoryData>) categoriesAttr; }
|
||||||
|
String csrfToken = (String) request.getAttribute("csrfToken");
|
||||||
|
String csrfTokenHtml = HtmlUtils.htmlEscape(csrfToken == null ? "" : csrfToken);
|
||||||
|
|
||||||
|
Long postId = post == null ? null : post.getId();
|
||||||
|
Long postCategoryId = post == null ? null : post.getCategoryId();
|
||||||
|
String postTitle = post == null || post.getTitle() == null ? "" : post.getTitle();
|
||||||
|
String postBodyMarkdown = post == null || post.getBodyMarkdown() == null ? "" : post.getBodyMarkdown();
|
||||||
|
String postLinkUrl = post == null || post.getLinkUrl() == null ? "" : post.getLinkUrl();
|
||||||
|
String postStatus = post == null ? null : post.getStatus();
|
||||||
|
boolean isDraft = "DRAFT".equals(postStatus);
|
||||||
|
%>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
|
||||||
|
<title><%= isEdit ? "포스트 수정" : "포스트 작성" %> | bibimbap</title>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
--surface: #faf8f5;
|
||||||
|
--card-bg: #fff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--text-muted: #5c5c5c;
|
||||||
|
--accent: #e8a54b;
|
||||||
|
--accent-soft: rgba(232, 165, 75, 0.16);
|
||||||
|
--border: rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow: rgba(0, 0, 0, 0.06);
|
||||||
|
--field-bg: #fff;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--surface: #121212;
|
||||||
|
--card-bg: #1e1e1e;
|
||||||
|
--text: #ece8e1;
|
||||||
|
--text-muted: #a39e96;
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--shadow: rgba(0, 0, 0, 0.35);
|
||||||
|
--field-bg: #181818;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.form-page {
|
||||||
|
max-width: 48rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
.form-heading {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.form-heading__eyebrow {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.form-heading h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.85rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.post-form {
|
||||||
|
padding: 1.35rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
box-shadow: 0 2px 8px var(--shadow);
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
}
|
||||||
|
.field label {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.field input,
|
||||||
|
.field select,
|
||||||
|
.field textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--field-bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
height: 2.85rem;
|
||||||
|
padding: 0 0.8rem;
|
||||||
|
}
|
||||||
|
.field textarea {
|
||||||
|
min-height: 16rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus,
|
||||||
|
.field textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.22);
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.form-button {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 900;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.form-button--primary {
|
||||||
|
border-color: transparent;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||||
|
<main class="form-page">
|
||||||
|
<section class="form-heading" aria-labelledby="form-title">
|
||||||
|
<p class="form-heading__eyebrow">POSTING</p>
|
||||||
|
<h1 id="form-title"><%= isEdit ? "포스트 수정" : "포스트 작성" %></h1>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="post-form" aria-label="포스트 입력">
|
||||||
|
<form id="post-form" novalidate>
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfTokenHtml %>">
|
||||||
|
<div class="field">
|
||||||
|
<label for="categoryId">카테고리</label>
|
||||||
|
<select id="categoryId" name="categoryId" required>
|
||||||
|
<option value="">카테고리 선택</option>
|
||||||
|
<% for (PostCategoryData cat : categories) { %>
|
||||||
|
<option value="<%= cat.getId() %>" <%= postCategoryId != null && postCategoryId.equals(cat.getId()) ? "selected" : "" %>><%= HtmlUtils.htmlEscape(cat.getName() == null ? "" : cat.getName()) %></option>
|
||||||
|
<% } %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="title">제목</label>
|
||||||
|
<input id="title" name="title" type="text" maxlength="200" value="<%= HtmlUtils.htmlEscape(postTitle) %>" placeholder="제목을 입력해 주세요." required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="bodyMarkdown">본문 (Markdown)</label>
|
||||||
|
<textarea id="bodyMarkdown" name="bodyMarkdown" maxlength="20000" placeholder="마크다운으로 작성해 주세요."><%= HtmlUtils.htmlEscape(postBodyMarkdown) %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="linkUrl">링크 URL</label>
|
||||||
|
<input id="linkUrl" name="linkUrl" type="url" maxlength="2048" value="<%= HtmlUtils.htmlEscape(postLinkUrl) %>" placeholder="https://example.com (선택)">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="status">공개 상태</label>
|
||||||
|
<select id="status" name="status">
|
||||||
|
<option value="PUBLISHED" <%= isDraft ? "" : "selected" %>>공개</option>
|
||||||
|
<option value="DRAFT" <%= isDraft ? "selected" : "" %>>비공개(임시저장)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<a class="form-button" href="<%= ctx %>/posts">취소</a>
|
||||||
|
<button class="form-button form-button--primary" type="submit"><%= isEdit ? "수정" : "등록" %></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var ctx = '<%= HtmlUtils.htmlEscape(ctx) %>';
|
||||||
|
var mode = '<%= HtmlUtils.htmlEscape(mode == null ? "" : mode) %>';
|
||||||
|
var postId = '<%= postId == null ? "" : postId %>';
|
||||||
|
var csrfToken = '<%= csrfTokenHtml %>';
|
||||||
|
var form = document.getElementById('post-form');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
var action = mode === 'edit' ? ctx + '/posts/' + postId : ctx + '/posts';
|
||||||
|
|
||||||
|
form.addEventListener('submit', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
form.reportValidity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseHeaders = {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-CSRF-Token': csrfToken
|
||||||
|
};
|
||||||
|
var headers = window.BibimbapCsrf ? window.BibimbapCsrf.headers(baseHeaders) : baseHeaders;
|
||||||
|
|
||||||
|
var body = new URLSearchParams(new FormData(form));
|
||||||
|
fetch(action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers,
|
||||||
|
body: body
|
||||||
|
}).then(function (res) {
|
||||||
|
return res.json().catch(function () {
|
||||||
|
return { message: '포스트를 저장하지 못했습니다.' };
|
||||||
|
}).then(function (data) {
|
||||||
|
if (!res.ok) {
|
||||||
|
var error = new Error(data && data.message ? data.message : '포스트를 저장하지 못했습니다.');
|
||||||
|
error.status = res.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}).then(function (data) {
|
||||||
|
var go = function () {
|
||||||
|
window.location.href = ctx + (data.location || '/posts');
|
||||||
|
};
|
||||||
|
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
|
||||||
|
window.BibimbapModal.alert({
|
||||||
|
title: mode === 'edit' ? '포스트 수정 완료' : '포스트 등록 완료',
|
||||||
|
message: mode === 'edit' ? '포스트가 수정되었습니다.' : '포스트가 등록되었습니다.',
|
||||||
|
confirmText: '확인',
|
||||||
|
onConfirm: go
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
go();
|
||||||
|
}
|
||||||
|
}).catch(function (err) {
|
||||||
|
var message = err.message || '포스트를 저장하지 못했습니다.';
|
||||||
|
var redirectLogin = function () {
|
||||||
|
if (err.status === 401) {
|
||||||
|
window.location.href = ctx + '/login';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
|
||||||
|
window.BibimbapModal.alert({
|
||||||
|
title: '저장 실패',
|
||||||
|
message: message,
|
||||||
|
confirmText: '확인',
|
||||||
|
onConfirm: redirectLogin
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert(message);
|
||||||
|
redirectLogin();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,276 @@
|
||||||
|
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
|
||||||
|
<%@ page import="org.springframework.web.util.HtmlUtils" %>
|
||||||
|
<%@ page import="com.pandoli365.bibimbap.data.PostData" %>
|
||||||
|
<%@ page import="com.pandoli365.bibimbap.data.PostCategoryData" %>
|
||||||
|
<%@ page import="java.net.URLEncoder" %>
|
||||||
|
<%@ page import="java.time.OffsetDateTime" %>
|
||||||
|
<%@ page import="java.util.Collections" %>
|
||||||
|
<%@ page import="java.util.List" %>
|
||||||
|
<%
|
||||||
|
String ctx = request.getContextPath();
|
||||||
|
List<PostData> posts = Collections.emptyList();
|
||||||
|
Object postsAttr = request.getAttribute("posts");
|
||||||
|
if (postsAttr instanceof List<?>) { posts = (List<PostData>) postsAttr; }
|
||||||
|
List<PostCategoryData> categories = Collections.emptyList();
|
||||||
|
Object categoriesAttr = request.getAttribute("categories");
|
||||||
|
if (categoriesAttr instanceof List<?>) { categories = (List<PostCategoryData>) categoriesAttr; }
|
||||||
|
Long categoryId = (Long) request.getAttribute("categoryId");
|
||||||
|
boolean hasNext = Boolean.TRUE.equals(request.getAttribute("hasNext"));
|
||||||
|
OffsetDateTime nextCursorCreatedAt = (OffsetDateTime) request.getAttribute("nextCursorCreatedAt");
|
||||||
|
Long nextCursorId = (Long) request.getAttribute("nextCursorId");
|
||||||
|
%>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
|
||||||
|
<title>포스팅 | bibimbap</title>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
--surface: #faf8f5;
|
||||||
|
--card-bg: #fff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--text-muted: #5c5c5c;
|
||||||
|
--accent: #e8a54b;
|
||||||
|
--accent-soft: rgba(232, 165, 75, 0.16);
|
||||||
|
--border: rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--surface: #121212;
|
||||||
|
--card-bg: #1e1e1e;
|
||||||
|
--text: #ece8e1;
|
||||||
|
--text-muted: #a39e96;
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--shadow: rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.posts-page {
|
||||||
|
max-width: 72rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
.posts-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.posts-hero__eyebrow {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.posts-hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.9rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.posts-write {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 900;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.posts-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.posts-tab {
|
||||||
|
min-height: 2.25rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 0.95rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.posts-tab.is-active {
|
||||||
|
border-color: rgba(232, 165, 75, 0.65);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.posts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.post-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px var(--shadow);
|
||||||
|
}
|
||||||
|
.post-card:hover {
|
||||||
|
border-color: rgba(232, 165, 75, 0.45);
|
||||||
|
box-shadow: 0 8px 20px var(--shadow);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.post-card__thumb {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.post-card__body {
|
||||||
|
padding: 1.05rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
.post-card__category {
|
||||||
|
align-self: flex-start;
|
||||||
|
min-height: 1.6rem;
|
||||||
|
padding: 0 0.55rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.post-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
letter-spacing: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.post-card__author {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.posts-empty {
|
||||||
|
min-height: 10rem;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.posts-more {
|
||||||
|
margin: 1.5rem auto 0;
|
||||||
|
max-width: 16rem;
|
||||||
|
min-height: 2.85rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 900;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.posts-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.posts-hero {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.posts-write {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.posts-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||||
|
<main class="posts-page">
|
||||||
|
<section class="posts-hero" aria-labelledby="posts-title">
|
||||||
|
<div>
|
||||||
|
<p class="posts-hero__eyebrow">POSTING</p>
|
||||||
|
<h1 id="posts-title">포스팅</h1>
|
||||||
|
</div>
|
||||||
|
<a class="posts-write" href="<%= ctx %>/posts/new">글쓰기</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="posts-tabs" aria-label="카테고리">
|
||||||
|
<a class="posts-tab <%= categoryId == null ? "is-active" : "" %>"
|
||||||
|
href="<%= ctx %>/posts">전체</a>
|
||||||
|
<% for (PostCategoryData cat : categories) { %>
|
||||||
|
<a class="posts-tab <%= categoryId != null && categoryId.equals(cat.getId()) ? "is-active" : "" %>"
|
||||||
|
href="<%= ctx %>/posts?categoryId=<%= cat.getId() %>"><%= HtmlUtils.htmlEscape(cat.getName() == null ? "" : cat.getName()) %></a>
|
||||||
|
<% } %>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<% if (posts.isEmpty()) { %>
|
||||||
|
<div class="posts-empty">아직 등록된 포스트가 없습니다.</div>
|
||||||
|
<% } else { %>
|
||||||
|
<section class="posts-grid" aria-label="포스트 목록">
|
||||||
|
<% for (PostData post : posts) { %>
|
||||||
|
<a class="post-card" href="<%= ctx %>/posts/<%= post.getId() %>">
|
||||||
|
<% if (post.getOgImageUrl() != null && !post.getOgImageUrl().isBlank()) { %>
|
||||||
|
<img class="post-card__thumb" src="<%= HtmlUtils.htmlEscape(post.getOgImageUrl()) %>" alt="" loading="lazy">
|
||||||
|
<% } %>
|
||||||
|
<div class="post-card__body">
|
||||||
|
<% if (post.getCategoryName() != null && !post.getCategoryName().isBlank()) { %>
|
||||||
|
<span class="post-card__category"><%= HtmlUtils.htmlEscape(post.getCategoryName()) %></span>
|
||||||
|
<% } %>
|
||||||
|
<h2><%= HtmlUtils.htmlEscape(post.getTitle() == null ? "" : post.getTitle()) %></h2>
|
||||||
|
<span class="post-card__author"><%= HtmlUtils.htmlEscape(post.getAuthorDisplayName() == null ? "" : post.getAuthorDisplayName()) %></span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<% if (hasNext) {
|
||||||
|
StringBuilder moreUrl = new StringBuilder(ctx).append("/posts?");
|
||||||
|
moreUrl.append("cursorCreatedAt=").append(URLEncoder.encode(String.valueOf(nextCursorCreatedAt), "UTF-8"));
|
||||||
|
moreUrl.append("&cursorId=").append(nextCursorId);
|
||||||
|
if (categoryId != null) {
|
||||||
|
moreUrl.append("&categoryId=").append(categoryId);
|
||||||
|
}
|
||||||
|
%>
|
||||||
|
<a class="posts-more" href="<%= HtmlUtils.htmlEscape(moreUrl.toString()) %>">더보기</a>
|
||||||
|
<% } %>
|
||||||
|
<% } %>
|
||||||
|
</main>
|
||||||
|
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -21,14 +21,22 @@ import com.pandoli365.bibimbap.mapper.JamTeamsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamVotesMapper;
|
import com.pandoli365.bibimbap.mapper.JamVotesMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamsMapper;
|
import com.pandoli365.bibimbap.mapper.JamsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.PermissionsMapper;
|
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.RbacAuditMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.RecruitPostsMapper;
|
import com.pandoli365.bibimbap.mapper.RecruitPostsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.TagsMapper;
|
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.UserAuthIdentitiesMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
|
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
||||||
import com.pandoli365.bibimbap.security.JamRoleGate;
|
import com.pandoli365.bibimbap.security.JamRoleGate;
|
||||||
import com.pandoli365.bibimbap.security.PermissionGate;
|
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.junit.jupiter.api.Test;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
|
@ -128,6 +136,32 @@ class BibimbapApplicationTests {
|
||||||
@MockBean
|
@MockBean
|
||||||
private JamRoleGate jamRoleGate;
|
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
|
@Test
|
||||||
void contextLoads() {
|
void contextLoads() {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
package com.pandoli365.bibimbap.controller;
|
||||||
|
|
||||||
|
import com.pandoli365.bibimbap.data.PostCategoryData;
|
||||||
|
import com.pandoli365.bibimbap.data.PostData;
|
||||||
|
import com.pandoli365.bibimbap.mapper.PostCategoriesMapper;
|
||||||
|
import com.pandoli365.bibimbap.mapper.PostsMapper;
|
||||||
|
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||||
|
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||||
|
import com.pandoli365.bibimbap.security.PermissionKeys;
|
||||||
|
import com.pandoli365.bibimbap.service.OgPreviewService;
|
||||||
|
import com.pandoli365.bibimbap.service.PostMarkdownService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
|
import org.springframework.mock.web.MockHttpSession;
|
||||||
|
import org.springframework.ui.ExtendedModelMap;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostController 단위 테스트 (plain Mockito, MockMvc 미사용 — 동일 패키지 컨벤션).
|
||||||
|
*
|
||||||
|
* <p>create/update/delete 의 게이트 순서(CSRF→인증→POST_WRITE 인가→검증)와
|
||||||
|
* keyset 페이징 hasNext 처리를 검증한다. 차단 경로마다 mapper 쓰기(insert/softDelete)
|
||||||
|
* 가 호출되지 않음을 verify(never) 로 확인해 "쓰기 차단"(VP-5) 을 grounding 한다.
|
||||||
|
*
|
||||||
|
* <p>설계 매핑: VP-1(POST_WRITE 게이트), VP-5(상태변경 CSRF + 미인가 시 쓰기 차단),
|
||||||
|
* VP-6(keyset 페이징 hasNext).
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PostControllerTest {
|
||||||
|
|
||||||
|
private static final long CATEGORY_ID = 3L;
|
||||||
|
private static final long USER_ID = 5L;
|
||||||
|
private static final long POST_ID = 1L;
|
||||||
|
private static final int PAGE_SIZE = 20;
|
||||||
|
private static final String POST_WRITE = PermissionKeys.POST_WRITE.name();
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PostsMapper postsMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PostCategoriesMapper postCategoriesMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PermissionGate gate;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PostMarkdownService postMarkdownService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private OgPreviewService ogPreviewService;
|
||||||
|
|
||||||
|
// ==== create (POST /posts): 게이트 + 검증 ====
|
||||||
|
|
||||||
|
/** VP-5: CSRF 누락 → 403, 인가 게이트/쓰기 매퍼 진입 전 차단. */
|
||||||
|
@Test
|
||||||
|
void create_withoutCsrf_returns403() {
|
||||||
|
PostController controller = controller();
|
||||||
|
MockHttpSession session = userSession(USER_ID);
|
||||||
|
MockHttpServletRequest request = noCsrfPost(session);
|
||||||
|
|
||||||
|
ResponseEntity<Map<String, Object>> response = controller.create(
|
||||||
|
CATEGORY_ID, "제목", "본문", null, null, request, session);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
assertThat(response.getBody()).containsEntry("status", 403);
|
||||||
|
verifyNoInteractions(gate);
|
||||||
|
verify(postsMapper, never()).insert(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 미인증(userId 미설정) → 401, 인가 게이트/쓰기 매퍼 미진입. */
|
||||||
|
@Test
|
||||||
|
void create_unauthenticated_returns401() {
|
||||||
|
PostController controller = controller();
|
||||||
|
MockHttpSession session = anonymousSession();
|
||||||
|
MockHttpServletRequest request = csrfPost(session);
|
||||||
|
|
||||||
|
ResponseEntity<Map<String, Object>> response = controller.create(
|
||||||
|
CATEGORY_ID, "제목", "본문", null, null, request, session);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
|
verifyNoInteractions(gate);
|
||||||
|
verify(postsMapper, never()).insert(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VP-1/VP-5: POST_WRITE 미보유 → 403, 검증/쓰기 매퍼 미진입. */
|
||||||
|
@Test
|
||||||
|
void create_withoutPostWrite_returns403() {
|
||||||
|
PostController controller = controller();
|
||||||
|
MockHttpSession session = userSession(USER_ID);
|
||||||
|
MockHttpServletRequest request = csrfPost(session);
|
||||||
|
when(gate.has(any(), eq(POST_WRITE))).thenReturn(false);
|
||||||
|
|
||||||
|
ResponseEntity<Map<String, Object>> response = controller.create(
|
||||||
|
CATEGORY_ID, "제목", "본문", null, null, request, session);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
verify(postsMapper, never()).insert(any());
|
||||||
|
verifyNoInteractions(postCategoriesMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VP-1: 권한 보유 + 유효 입력 → 200 {postId, location}, insert 1회. */
|
||||||
|
@Test
|
||||||
|
void create_withPostWrite_returns200() {
|
||||||
|
PostController controller = controller();
|
||||||
|
MockHttpSession session = userSession(USER_ID);
|
||||||
|
MockHttpServletRequest request = csrfPost(session);
|
||||||
|
when(gate.has(any(), eq(POST_WRITE))).thenReturn(true);
|
||||||
|
when(postCategoriesMapper.getActive(CATEGORY_ID)).thenReturn(activeCategory());
|
||||||
|
when(postMarkdownService.render(any())).thenReturn("<p>본문</p>");
|
||||||
|
when(postsMapper.insert(any())).thenAnswer(inv -> {
|
||||||
|
((PostData) inv.getArgument(0)).setId(POST_ID);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
ResponseEntity<Map<String, Object>> response = controller.create(
|
||||||
|
CATEGORY_ID, "제목", "본문", null, "PUBLISHED", request, session);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
assertThat(response.getBody()).containsEntry("postId", POST_ID);
|
||||||
|
assertThat(response.getBody()).containsEntry("location", "/posts/" + POST_ID);
|
||||||
|
verify(postsMapper).insert(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 검증 실패(title 201자) → 400, 쓰기 매퍼/카테고리 조회 미진입. */
|
||||||
|
@Test
|
||||||
|
void create_titleTooLong_returns4xx() {
|
||||||
|
PostController controller = controller();
|
||||||
|
MockHttpSession session = userSession(USER_ID);
|
||||||
|
MockHttpServletRequest request = csrfPost(session);
|
||||||
|
when(gate.has(any(), eq(POST_WRITE))).thenReturn(true);
|
||||||
|
|
||||||
|
String longTitle = "x".repeat(201);
|
||||||
|
ResponseEntity<Map<String, Object>> response = controller.create(
|
||||||
|
CATEGORY_ID, longTitle, "본문", null, null, request, session);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().is4xxClientError()).isTrue();
|
||||||
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
verify(postsMapper, never()).insert(any());
|
||||||
|
// title 검증이 categoryId 조회보다 먼저라 getActive 미호출.
|
||||||
|
verify(postCategoriesMapper, never()).getActive(anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 카테고리 없음(getActive→null) → 404, 쓰기 매퍼 미진입. */
|
||||||
|
@Test
|
||||||
|
void create_unknownCategory_returns404() {
|
||||||
|
PostController controller = controller();
|
||||||
|
MockHttpSession session = userSession(USER_ID);
|
||||||
|
MockHttpServletRequest request = csrfPost(session);
|
||||||
|
when(gate.has(any(), eq(POST_WRITE))).thenReturn(true);
|
||||||
|
when(postCategoriesMapper.getActive(CATEGORY_ID)).thenReturn(null);
|
||||||
|
|
||||||
|
ResponseEntity<Map<String, Object>> response = controller.create(
|
||||||
|
CATEGORY_ID, "제목", "본문", null, null, request, session);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||||
|
verify(postsMapper, never()).insert(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== delete (POST /posts/{id}/delete): 게이트 ====
|
||||||
|
|
||||||
|
/** VP-1/VP-5: delete 도 POST_WRITE 게이트 — 미보유 → 403, softDelete 미수행. */
|
||||||
|
@Test
|
||||||
|
void delete_withoutPostWrite_returns403() {
|
||||||
|
PostController controller = controller();
|
||||||
|
MockHttpSession session = userSession(USER_ID);
|
||||||
|
MockHttpServletRequest request = csrfPost(session);
|
||||||
|
when(gate.has(any(), eq(POST_WRITE))).thenReturn(false);
|
||||||
|
|
||||||
|
ResponseEntity<Map<String, Object>> response =
|
||||||
|
controller.delete(POST_ID, request, session);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
verify(postsMapper, never()).softDelete(anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== list (GET /posts): keyset 페이징 ====
|
||||||
|
|
||||||
|
/** VP-6: listPublishedKeyset 가 PAGE_SIZE+1(21)건 → hasNext=true, 표시 20건. */
|
||||||
|
@Test
|
||||||
|
void list_keysetPaging() {
|
||||||
|
PostController controller = controller();
|
||||||
|
when(postsMapper.listPublishedKeyset(any(), any(), any(), eq(PAGE_SIZE + 1)))
|
||||||
|
.thenReturn(rows(PAGE_SIZE + 1));
|
||||||
|
lenient().when(postCategoriesMapper.listActive()).thenReturn(List.of());
|
||||||
|
Model model = new ExtendedModelMap();
|
||||||
|
|
||||||
|
String view = controller.list(null, null, null, model);
|
||||||
|
|
||||||
|
assertThat(view).isEqualTo("posts-list");
|
||||||
|
assertThat(model.getAttribute("hasNext")).isEqualTo(true);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<PostData> shown = (List<PostData>) model.getAttribute("posts");
|
||||||
|
assertThat(shown).hasSize(PAGE_SIZE);
|
||||||
|
assertThat(model.getAttribute("nextCursorId")).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VP-6: PAGE_SIZE 이하(20)면 hasNext=false, 커서 미노출. */
|
||||||
|
@Test
|
||||||
|
void list_lastPage_hasNextFalse() {
|
||||||
|
PostController controller = controller();
|
||||||
|
when(postsMapper.listPublishedKeyset(any(), any(), any(), eq(PAGE_SIZE + 1)))
|
||||||
|
.thenReturn(rows(PAGE_SIZE));
|
||||||
|
lenient().when(postCategoriesMapper.listActive()).thenReturn(List.of());
|
||||||
|
Model model = new ExtendedModelMap();
|
||||||
|
|
||||||
|
controller.list(null, null, null, model);
|
||||||
|
|
||||||
|
assertThat(model.getAttribute("hasNext")).isEqualTo(false);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<PostData> shown = (List<PostData>) model.getAttribute("posts");
|
||||||
|
assertThat(shown).hasSize(PAGE_SIZE);
|
||||||
|
assertThat(model.getAttribute("nextCursorId")).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== helpers ====
|
||||||
|
|
||||||
|
private PostController controller() {
|
||||||
|
return new PostController(postsMapper, postCategoriesMapper, gate,
|
||||||
|
postMarkdownService, ogPreviewService);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PostCategoryData activeCategory() {
|
||||||
|
PostCategoryData category = new PostCategoryData();
|
||||||
|
category.setId(CATEGORY_ID);
|
||||||
|
category.setName("공지");
|
||||||
|
category.setSlug("notice");
|
||||||
|
category.setIsActive(true);
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PostData> rows(int count) {
|
||||||
|
List<PostData> rows = new ArrayList<>(count);
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
PostData post = new PostData();
|
||||||
|
post.setId((long) (i + 1));
|
||||||
|
rows.add(post);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpSession userSession(long userId) {
|
||||||
|
MockHttpSession session = new MockHttpSession();
|
||||||
|
session.setAttribute("userId", userId);
|
||||||
|
CsrfTokens.getOrCreate(session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** userId 미설정 — CSRF 통과를 위해 토큰만 보유. */
|
||||||
|
private MockHttpSession anonymousSession() {
|
||||||
|
MockHttpSession session = new MockHttpSession();
|
||||||
|
CsrfTokens.getOrCreate(session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpServletRequest csrfPost(MockHttpSession session) {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.setSession(session);
|
||||||
|
request.addHeader(CsrfTokens.HEADER_NAME,
|
||||||
|
(String) session.getAttribute(CsrfTokens.SESSION_ATTRIBUTE));
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpServletRequest noCsrfPost(MockHttpSession session) {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.setSession(session);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,351 @@
|
||||||
|
package com.pandoli365.bibimbap.security;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import com.pandoli365.bibimbap.security.SsrfSafeFetcher.FetchResult;
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import com.sun.net.httpserver.HttpHandler;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
|
||||||
|
class SsrfSafeFetcherTest {
|
||||||
|
|
||||||
|
private static final Set<String> HTML = Set.of("text/html");
|
||||||
|
private static final long MAX = 64 * 1024;
|
||||||
|
|
||||||
|
private HttpServer server;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void stopServer() {
|
||||||
|
if (server != null) {
|
||||||
|
server.stop(0);
|
||||||
|
server = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolve() 를 stub 으로 주입하고, 로컬 HttpServer 실측을 위해 loopback 차단을
|
||||||
|
* 선택적으로 무력화할 수 있는 테스트 전용 서브클래스.
|
||||||
|
*/
|
||||||
|
static class TestableFetcher extends SsrfSafeFetcher {
|
||||||
|
final Map<String, InetAddress[]> stub = new HashMap<>();
|
||||||
|
private boolean allowAnyPort = false;
|
||||||
|
|
||||||
|
TestableFetcher allowLoopback() {
|
||||||
|
this.allowLoopbackForTest = true;
|
||||||
|
// 로컬 HttpServer 는 ephemeral 포트를 쓰므로 포트 allowlist 를 완화한다(테스트 전용).
|
||||||
|
this.allowAnyPort = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean isAllowedPort(int port) {
|
||||||
|
return allowAnyPort || super.isAllowedPort(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected InetAddress[] resolve(String host) throws UnknownHostException {
|
||||||
|
InetAddress[] stubbed = stub.get(host);
|
||||||
|
if (stubbed != null) {
|
||||||
|
return stubbed;
|
||||||
|
}
|
||||||
|
return super.resolve(host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InetAddress addr(String literal) throws UnknownHostException {
|
||||||
|
return InetAddress.getByName(literal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── #1 scheme allowlist ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNonHttpSchemes() {
|
||||||
|
SsrfSafeFetcher fetcher = new SsrfSafeFetcher();
|
||||||
|
assertTrue(fetcher.fetch(URI.create("file:///etc/passwd"), MAX, HTML).isEmpty());
|
||||||
|
assertTrue(fetcher.fetch(URI.create("gopher://example.com/"), MAX, HTML).isEmpty());
|
||||||
|
assertTrue(fetcher.fetch(URI.create("ftp://example.com/"), MAX, HTML).isEmpty());
|
||||||
|
// 유효한 data URI (불법 <> 문자 없이) — scheme 거부 의도만 입증.
|
||||||
|
assertTrue(fetcher.fetch(URI.create("data:text/plain;base64,aGk="), MAX, HTML).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── #2 resolve → IP 공인 (사설 IP 직접) ───────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsPrivateIpLiteral() {
|
||||||
|
SsrfSafeFetcher fetcher = new SsrfSafeFetcher();
|
||||||
|
assertTrue(fetcher.fetch(URI.create("http://10.0.0.1/"), MAX, HTML).isEmpty());
|
||||||
|
assertTrue(fetcher.fetch(URI.create("http://192.168.1.1/"), MAX, HTML).isEmpty());
|
||||||
|
assertTrue(fetcher.fetch(URI.create("http://172.16.0.1/"), MAX, HTML).isEmpty());
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://10.0.0.1/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── #3 loopback ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsLoopback() {
|
||||||
|
SsrfSafeFetcher fetcher = new SsrfSafeFetcher();
|
||||||
|
assertTrue(fetcher.fetch(URI.create("http://127.0.0.1/"), MAX, HTML).isEmpty());
|
||||||
|
assertTrue(fetcher.fetch(URI.create("http://[::1]/"), MAX, HTML).isEmpty());
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://127.0.0.1/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── #4 (3항목 메타데이터) 메타데이터 엔드포인트 ──────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMetadataEndpoint() {
|
||||||
|
SsrfSafeFetcher fetcher = new SsrfSafeFetcher();
|
||||||
|
assertTrue(fetcher.fetch(
|
||||||
|
URI.create("http://169.254.169.254/latest/meta-data"), MAX, HTML).isEmpty());
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://169.254.169.254/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── #5 (3항목 link-local) 링크로컬 ───────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsLinkLocal() {
|
||||||
|
SsrfSafeFetcher fetcher = new SsrfSafeFetcher();
|
||||||
|
assertTrue(fetcher.fetch(URI.create("http://169.254.1.1/"), MAX, HTML).isEmpty());
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://169.254.1.1/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── #6 (3항목 IPv4-mapped IPv6) ──────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsIpv4MappedIpv6() throws Exception {
|
||||||
|
TestableFetcher fetcher = new TestableFetcher();
|
||||||
|
// host 가 ::ffff:127.0.0.1 (loopback) / ::ffff:10.0.0.1 (사설) 로 해석되는 시나리오.
|
||||||
|
fetcher.stub.put("mapped-loopback.test", new InetAddress[] { addr("::ffff:7f00:1") });
|
||||||
|
fetcher.stub.put("mapped-private.test", new InetAddress[] { addr("::ffff:0a00:1") });
|
||||||
|
|
||||||
|
assertTrue(fetcher.fetch(URI.create("http://mapped-loopback.test/"), MAX, HTML).isEmpty());
|
||||||
|
assertTrue(fetcher.fetch(URI.create("http://mapped-private.test/"), MAX, HTML).isEmpty());
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://mapped-private.test/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── #7 DNS rebinding mock (resolve→사설) ─────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsRebindingToPrivateIp() throws Exception {
|
||||||
|
TestableFetcher fetcher = new TestableFetcher();
|
||||||
|
// 정상 외부처럼 보이는 host 가 실제로는 사설 IP 로 resolve 되는 rebinding 시나리오.
|
||||||
|
// IP 핀닝 구현은 resolve 결과 IP 자체를 검증하므로 사설 반환 시 차단된다.
|
||||||
|
fetcher.stub.put("evil-rebind.test", new InetAddress[] { addr("10.0.0.5") });
|
||||||
|
fetcher.stub.put("evil-loopback.test", new InetAddress[] { addr("127.0.0.1") });
|
||||||
|
|
||||||
|
assertTrue(fetcher.fetch(URI.create("http://evil-rebind.test/"), MAX, HTML).isEmpty());
|
||||||
|
assertTrue(fetcher.fetch(URI.create("http://evil-loopback.test/"), MAX, HTML).isEmpty());
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://evil-rebind.test/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMixedPublicAndPrivateIps() throws Exception {
|
||||||
|
TestableFetcher fetcher = new TestableFetcher();
|
||||||
|
// 하나라도 사설이면 거부 (전부 공인이어야 통과).
|
||||||
|
fetcher.stub.put("mixed.test",
|
||||||
|
new InetAddress[] { addr("93.184.216.34"), addr("10.0.0.9") });
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://mixed.test/")));
|
||||||
|
assertTrue(fetcher.fetch(URI.create("http://mixed.test/"), MAX, HTML).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fix1 결함2: 확장 예약대역 차단 (CGNAT 등) ─────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsCgnatAddress() throws Exception {
|
||||||
|
// CGNAT 100.64.0.0/10 — isSiteLocalAddress=false 라 기존 갭. 클라우드 내부 라우팅 차단.
|
||||||
|
TestableFetcher fetcher = new TestableFetcher();
|
||||||
|
fetcher.stub.put("cgnat.test", new InetAddress[] { addr("100.64.1.1") });
|
||||||
|
fetcher.stub.put("cgnat-edge.test", new InetAddress[] { addr("100.127.255.254") });
|
||||||
|
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://cgnat.test/")));
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://cgnat-edge.test/")));
|
||||||
|
assertTrue(fetcher.fetch(URI.create("http://cgnat.test/"), MAX, HTML).isEmpty());
|
||||||
|
// 인접 공인 대역(100.63.x.x, 100.128.x.x)은 CGNAT 가 아니므로 차단 대상 아님 (경계 입증).
|
||||||
|
fetcher.stub.put("below-cgnat.test", new InetAddress[] { addr("100.63.255.255") });
|
||||||
|
assertTrue(fetcher.isFetchableUrl(URI.create("http://below-cgnat.test/")),
|
||||||
|
"100.63.x 는 CGNAT 미만이므로 차단되지 않아야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsOtherReservedV4Ranges() throws Exception {
|
||||||
|
TestableFetcher fetcher = new TestableFetcher();
|
||||||
|
fetcher.stub.put("class-e.test", new InetAddress[] { addr("240.0.0.1") });
|
||||||
|
fetcher.stub.put("bench.test", new InetAddress[] { addr("198.18.0.1") });
|
||||||
|
fetcher.stub.put("ietf.test", new InetAddress[] { addr("192.0.0.1") });
|
||||||
|
fetcher.stub.put("bcast.test", new InetAddress[] { addr("255.255.255.255") });
|
||||||
|
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://class-e.test/")));
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://bench.test/")));
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://ietf.test/")));
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://bcast.test/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNat64AndSixToFourEmbeddingPrivateV4() throws Exception {
|
||||||
|
TestableFetcher fetcher = new TestableFetcher();
|
||||||
|
// NAT64 64:ff9b::/96 임베드 v4 = 10.0.0.1 (사설).
|
||||||
|
fetcher.stub.put("nat64.test", new InetAddress[] { addr("64:ff9b::a00:1") });
|
||||||
|
// 6to4 2002::/16 임베드 v4 = 192.168.0.1 (사설) → 2002:c0a8:1::.
|
||||||
|
fetcher.stub.put("sixtofour.test", new InetAddress[] { addr("2002:c0a8:1::1") });
|
||||||
|
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://nat64.test/")));
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://sixtofour.test/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fix1 결함3: 포트 allowlist ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNonAllowedPort() throws Exception {
|
||||||
|
TestableFetcher fetcher = new TestableFetcher();
|
||||||
|
// 공인 IP 라도 비-웹 포트(22/6379/5432)는 거부.
|
||||||
|
fetcher.stub.put("pub.test", new InetAddress[] { addr("93.184.216.34") });
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://pub.test:22/")));
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://pub.test:6379/")));
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("https://pub.test:5432/")));
|
||||||
|
assertTrue(fetcher.fetch(URI.create("http://pub.test:22/"), MAX, HTML).isEmpty());
|
||||||
|
// 허용 포트 + scheme 기본포트(미명시)는 통과.
|
||||||
|
assertTrue(fetcher.isFetchableUrl(URI.create("http://pub.test:8080/")));
|
||||||
|
assertTrue(fetcher.isFetchableUrl(URI.create("https://pub.test:8443/")));
|
||||||
|
assertTrue(fetcher.isFetchableUrl(URI.create("https://pub.test/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── #8 (항목5) redirect → 사설 재검증 차단 ───────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsRedirectTargetToPrivateHost() throws Exception {
|
||||||
|
// redirect 대상 host 가 사설 IP 로 resolve 되면 재검증에서 차단됨을 정적 입증.
|
||||||
|
TestableFetcher fetcher = new TestableFetcher();
|
||||||
|
fetcher.stub.put("internal-redirect.test", new InetAddress[] { addr("10.1.2.3") });
|
||||||
|
assertFalse(fetcher.isFetchableUrl(URI.create("http://internal-redirect.test/admin")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void redirectToPrivateHostIsBlockedEndToEnd() throws Exception {
|
||||||
|
// 로컬 서버(공인처럼 통과)가 사설 host 로 302 redirect → 다음 홉 재검증 차단.
|
||||||
|
TestableFetcher fetcher = new TestableFetcher().allowLoopback();
|
||||||
|
fetcher.stub.put("internal-redirect.test", new InetAddress[] { addr("10.1.2.3") });
|
||||||
|
|
||||||
|
startServer(exchange -> {
|
||||||
|
exchange.getResponseHeaders().add("Location", "http://internal-redirect.test/secret");
|
||||||
|
exchange.sendResponseHeaders(302, -1);
|
||||||
|
exchange.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
Optional<FetchResult> result = fetcher.fetch(localUri("/"), MAX, HTML);
|
||||||
|
assertTrue(result.isEmpty(), "redirect 대상이 사설이면 empty 여야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exceedingMaxRedirectsIsBlocked() throws Exception {
|
||||||
|
// 무한/과다 redirect → MAX_REDIRECTS 초과 시 abort.
|
||||||
|
TestableFetcher fetcher = new TestableFetcher().allowLoopback();
|
||||||
|
startServer(exchange -> {
|
||||||
|
exchange.getResponseHeaders().add("Location", "/loop");
|
||||||
|
exchange.sendResponseHeaders(302, -1);
|
||||||
|
exchange.close();
|
||||||
|
});
|
||||||
|
Optional<FetchResult> result = fetcher.fetch(localUri("/loop"), MAX, HTML);
|
||||||
|
assertTrue(result.isEmpty(), "redirect 홉 초과 시 empty 여야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── #9 size / timeout / Content-Type + 정상 (로컬 서버 실측, graceful) ──
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsOversizedBody() throws Exception {
|
||||||
|
TestableFetcher fetcher = new TestableFetcher().allowLoopback();
|
||||||
|
byte[] big = new byte[(int) MAX + 1024];
|
||||||
|
startServer(exchange -> {
|
||||||
|
exchange.getResponseHeaders().add("Content-Type", "text/html");
|
||||||
|
exchange.sendResponseHeaders(200, big.length);
|
||||||
|
try (OutputStream os = exchange.getResponseBody()) {
|
||||||
|
os.write(big);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Optional<FetchResult> result = fetcher.fetch(localUri("/"), MAX, HTML);
|
||||||
|
assertTrue(result.isEmpty(), "maxBytes 초과 본문은 empty 여야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsOnTimeout() throws Exception {
|
||||||
|
TestableFetcher fetcher = new TestableFetcher().allowLoopback();
|
||||||
|
startServer(exchange -> {
|
||||||
|
try {
|
||||||
|
// REQUEST_TIMEOUT(5s) 보다 길게 지연 → request timeout 발동.
|
||||||
|
Thread.sleep(7000);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
exchange.getResponseHeaders().add("Content-Type", "text/html");
|
||||||
|
exchange.sendResponseHeaders(200, -1);
|
||||||
|
exchange.close();
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// 클라이언트 timeout 후 응답 시도는 무시.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Optional<FetchResult> result = fetcher.fetch(localUri("/"), MAX, HTML);
|
||||||
|
assertTrue(result.isEmpty(), "응답 지연 시 timeout 으로 empty 여야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDisallowedContentType() throws Exception {
|
||||||
|
TestableFetcher fetcher = new TestableFetcher().allowLoopback();
|
||||||
|
startServer(exchange -> writeBody(exchange, "text/plain", "hello"));
|
||||||
|
Optional<FetchResult> result = fetcher.fetch(localUri("/"), MAX, HTML);
|
||||||
|
assertTrue(result.isEmpty(), "허용되지 않은 Content-Type 은 empty 여야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fetchesAllowedHtmlSuccessfully() throws Exception {
|
||||||
|
TestableFetcher fetcher = new TestableFetcher().allowLoopback();
|
||||||
|
String html = "<html><head><title>ok</title></head><body>hi</body></html>";
|
||||||
|
startServer(exchange -> writeBody(exchange, "text/html; charset=utf-8", html));
|
||||||
|
|
||||||
|
Optional<FetchResult> result = fetcher.fetch(localUri("/"), MAX, HTML);
|
||||||
|
assertTrue(result.isPresent(), "정상 text/html 은 present 여야 한다");
|
||||||
|
assertEquals(200, result.get().status());
|
||||||
|
assertEquals("text/html", result.get().contentType());
|
||||||
|
assertEquals(html, new String(result.get().body(), StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void startServer(HttpHandler handler) throws IOException {
|
||||||
|
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||||
|
server.createContext("/", handler);
|
||||||
|
server.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private URI localUri(String path) {
|
||||||
|
return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeBody(HttpExchange exchange, String contentType, String body) {
|
||||||
|
try {
|
||||||
|
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.getResponseHeaders().add("Content-Type", contentType);
|
||||||
|
exchange.sendResponseHeaders(200, bytes.length);
|
||||||
|
try (OutputStream os = exchange.getResponseBody()) {
|
||||||
|
os.write(bytes);
|
||||||
|
}
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// 테스트 핸들러 best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
package com.pandoli365.bibimbap.service;
|
||||||
|
|
||||||
|
import com.pandoli365.bibimbap.service.FeedParser.FeedItem;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class FeedParserTest {
|
||||||
|
|
||||||
|
private final FeedParser parser = new FeedParser();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parsesRss20() {
|
||||||
|
String xml = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Unity Blog</title>
|
||||||
|
<item>
|
||||||
|
<guid>urn:unity:1</guid>
|
||||||
|
<title>First Post</title>
|
||||||
|
<link>https://unity.com/blog/first</link>
|
||||||
|
<pubDate>Tue, 24 Jun 2026 10:00:00 GMT</pubDate>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<guid>urn:unity:2</guid>
|
||||||
|
<title>Second Post</title>
|
||||||
|
<link>https://unity.com/blog/second</link>
|
||||||
|
<pubDate>Wed, 25 Jun 2026 12:30:00 GMT</pubDate>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
""";
|
||||||
|
|
||||||
|
List<FeedItem> items = parser.parse(xml.getBytes(UTF_8));
|
||||||
|
|
||||||
|
assertEquals(2, items.size());
|
||||||
|
FeedItem first = items.get(0);
|
||||||
|
assertEquals("urn:unity:1", first.guid());
|
||||||
|
assertEquals("First Post", first.title());
|
||||||
|
assertEquals("https://unity.com/blog/first", first.link());
|
||||||
|
assertNotNull(first.publishedAt());
|
||||||
|
FeedItem second = items.get(1);
|
||||||
|
assertEquals("urn:unity:2", second.guid());
|
||||||
|
assertEquals("Second Post", second.title());
|
||||||
|
assertEquals("https://unity.com/blog/second", second.link());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parsesRssGuidFallsBackToLink() {
|
||||||
|
String xml = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<item>
|
||||||
|
<title>No Guid</title>
|
||||||
|
<link>https://unity.com/blog/no-guid</link>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
""";
|
||||||
|
|
||||||
|
List<FeedItem> items = parser.parse(xml.getBytes(UTF_8));
|
||||||
|
|
||||||
|
assertEquals(1, items.size());
|
||||||
|
assertEquals("https://unity.com/blog/no-guid", items.get(0).guid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parsesAtom10() {
|
||||||
|
String xml = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<title>Unity News</title>
|
||||||
|
<entry>
|
||||||
|
<id>tag:unity,2026:a</id>
|
||||||
|
<title>Atom First</title>
|
||||||
|
<link rel="alternate" href="https://unity.com/news/a"/>
|
||||||
|
<updated>2026-06-24T10:00:00Z</updated>
|
||||||
|
</entry>
|
||||||
|
<entry>
|
||||||
|
<id>tag:unity,2026:b</id>
|
||||||
|
<title>Atom Second</title>
|
||||||
|
<link rel="alternate" href="https://unity.com/news/b"/>
|
||||||
|
<published>2026-06-25T12:30:00Z</published>
|
||||||
|
</entry>
|
||||||
|
</feed>
|
||||||
|
""";
|
||||||
|
|
||||||
|
List<FeedItem> items = parser.parse(xml.getBytes(UTF_8));
|
||||||
|
|
||||||
|
assertEquals(2, items.size());
|
||||||
|
FeedItem first = items.get(0);
|
||||||
|
assertEquals("tag:unity,2026:a", first.guid());
|
||||||
|
assertEquals("Atom First", first.title());
|
||||||
|
assertEquals("https://unity.com/news/a", first.link());
|
||||||
|
assertNotNull(first.publishedAt());
|
||||||
|
FeedItem second = items.get(1);
|
||||||
|
assertEquals("tag:unity,2026:b", second.guid());
|
||||||
|
assertEquals("Atom Second", second.title());
|
||||||
|
assertEquals("https://unity.com/news/b", second.link());
|
||||||
|
assertNotNull(second.publishedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void atomPrefersAlternateLink() {
|
||||||
|
String xml = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<entry>
|
||||||
|
<id>tag:unity,2026:c</id>
|
||||||
|
<title>Multi Link</title>
|
||||||
|
<link rel="self" href="https://unity.com/news/self"/>
|
||||||
|
<link rel="alternate" href="https://unity.com/news/alternate"/>
|
||||||
|
</entry>
|
||||||
|
</feed>
|
||||||
|
""";
|
||||||
|
|
||||||
|
List<FeedItem> items = parser.parse(xml.getBytes(UTF_8));
|
||||||
|
|
||||||
|
assertEquals(1, items.size());
|
||||||
|
assertEquals("https://unity.com/news/alternate", items.get(0).link());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blocksXxeExternalEntity() {
|
||||||
|
// DOCTYPE + external entity 포함 악성 XML — disallow-doctype-decl 로 파싱 거부 기대
|
||||||
|
String malicious = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE rss [
|
||||||
|
<!ENTITY xxe SYSTEM "file:///etc/passwd">
|
||||||
|
]>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<item>
|
||||||
|
<guid>x</guid>
|
||||||
|
<title>&xxe;</title>
|
||||||
|
<link>https://example.com/x</link>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
""";
|
||||||
|
|
||||||
|
List<FeedItem> items = assertDoesNotThrow(() -> parser.parse(malicious.getBytes(UTF_8)));
|
||||||
|
|
||||||
|
// DOCTYPE 거부로 빈 리스트. 만약 파싱됐더라도 entity 가 확장되어 시스템 파일 내용이
|
||||||
|
// 노출되어선 안 된다.
|
||||||
|
if (!items.isEmpty()) {
|
||||||
|
String title = items.get(0).title();
|
||||||
|
assertFalse(title != null && title.contains("root:"),
|
||||||
|
"external entity 가 확장되어선 안 된다");
|
||||||
|
} else {
|
||||||
|
assertTrue(items.isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsEmptyForEmptyInput() {
|
||||||
|
assertTrue(parser.parse(new byte[0]).isEmpty());
|
||||||
|
assertTrue(parser.parse(null).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsEmptyForBrokenInput() {
|
||||||
|
byte[] notXml = "this is not xml at all <<< >>>".getBytes(UTF_8);
|
||||||
|
List<FeedItem> items = assertDoesNotThrow(() -> parser.parse(notXml));
|
||||||
|
assertTrue(items.isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
package com.pandoli365.bibimbap.service;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class PostMarkdownServiceTest {
|
||||||
|
|
||||||
|
private final PostMarkdownService service = new PostMarkdownService();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stripsScriptTag() {
|
||||||
|
String html = service.render("<script>alert(1)</script>");
|
||||||
|
assertFalse(html.contains("<script"), "script tag must be removed: " + html);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stripsOnErrorAttribute() {
|
||||||
|
String html = service.render("<img src=x onerror=alert(1)>");
|
||||||
|
assertFalse(html.contains("onerror"), "onerror attribute must be removed: " + html);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stripsJavascriptHref() {
|
||||||
|
String html = service.render("[클릭](javascript:alert(1))");
|
||||||
|
assertFalse(html.contains("javascript:"), "javascript: protocol must be removed: " + html);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stripsIframeTag() {
|
||||||
|
String html = service.render("<iframe src=\"https://evil.example.com\"></iframe>");
|
||||||
|
assertFalse(html.contains("<iframe"), "iframe tag must be removed: " + html);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void preservesSafeContent() {
|
||||||
|
String bold = service.render("**굵게**");
|
||||||
|
assertTrue(bold.contains("<strong>"), "bold should render <strong>: " + bold);
|
||||||
|
|
||||||
|
String link = service.render("[링크](https://example.com)");
|
||||||
|
assertTrue(link.contains("<a"), "link should render <a>: " + link);
|
||||||
|
assertTrue(link.contains("href=\"https://example.com\""), "link href preserved: " + link);
|
||||||
|
assertTrue(link.contains("rel=\"nofollow noopener\""), "link rel enforced: " + link);
|
||||||
|
|
||||||
|
String list = service.render("- 항목1\n- 항목2");
|
||||||
|
assertTrue(list.contains("<ul>"), "list should render <ul>: " + list);
|
||||||
|
assertTrue(list.contains("<li>"), "list should render <li>: " + list);
|
||||||
|
|
||||||
|
String heading = service.render("# 제목");
|
||||||
|
assertTrue(heading.contains("<h1>"), "heading should render <h1>: " + heading);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
package com.pandoli365.bibimbap.service;
|
||||||
|
|
||||||
|
import com.pandoli365.bibimbap.data.UnityFeedItemData;
|
||||||
|
import com.pandoli365.bibimbap.data.UnityFeedSourceData;
|
||||||
|
import com.pandoli365.bibimbap.mapper.UnityFeedItemsMapper;
|
||||||
|
import com.pandoli365.bibimbap.mapper.UnityFeedSourcesMapper;
|
||||||
|
import com.pandoli365.bibimbap.security.SsrfSafeFetcher;
|
||||||
|
import com.pandoli365.bibimbap.security.SsrfSafeFetcher.FetchResult;
|
||||||
|
import com.pandoli365.bibimbap.service.FeedParser.FeedItem;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class UnityFeedPollerTest {
|
||||||
|
|
||||||
|
private static final long SOURCE_ID = 7L;
|
||||||
|
private static final String FEED_URL = "https://unity.com/feed.xml";
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SsrfSafeFetcher ssrfSafeFetcher;
|
||||||
|
@Mock
|
||||||
|
private FeedParser feedParser;
|
||||||
|
@Mock
|
||||||
|
private UnityFeedSourcesMapper sourcesMapper;
|
||||||
|
@Mock
|
||||||
|
private UnityFeedItemsMapper itemsMapper;
|
||||||
|
|
||||||
|
private UnityFeedPoller poller;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
poller = new UnityFeedPoller(ssrfSafeFetcher, feedParser, sourcesMapper, itemsMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UnityFeedSourceData source() {
|
||||||
|
UnityFeedSourceData source = new UnityFeedSourceData();
|
||||||
|
source.setId(SOURCE_ID);
|
||||||
|
source.setName("Unity");
|
||||||
|
source.setFeedUrl(FEED_URL);
|
||||||
|
source.setIsActive(true);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<FetchResult> okFetch() {
|
||||||
|
return Optional.of(new FetchResult(
|
||||||
|
200,
|
||||||
|
"application/rss+xml",
|
||||||
|
new byte[]{1, 2, 3},
|
||||||
|
URI.create(FEED_URL)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private FeedItem item(String guid) {
|
||||||
|
return new FeedItem(guid, "title-" + guid, "https://unity.com/" + guid, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void insertsNewGuids() {
|
||||||
|
when(sourcesMapper.getById(SOURCE_ID)).thenReturn(source());
|
||||||
|
when(ssrfSafeFetcher.fetch(any(URI.class), anyLong(), any())).thenReturn(okFetch());
|
||||||
|
when(feedParser.parse(any())).thenReturn(List.of(item("A"), item("B")));
|
||||||
|
when(itemsMapper.existsByGuid(eq(SOURCE_ID), anyString())).thenReturn(false);
|
||||||
|
when(itemsMapper.insertIgnoreDup(any(UnityFeedItemData.class))).thenReturn(1);
|
||||||
|
|
||||||
|
int newCount = poller.pollOnce(SOURCE_ID);
|
||||||
|
|
||||||
|
assertEquals(2, newCount);
|
||||||
|
verify(itemsMapper, times(2)).insertIgnoreDup(any(UnityFeedItemData.class));
|
||||||
|
verify(sourcesMapper).updateCursor(SOURCE_ID, "A");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dedupesAllExistingGuids() {
|
||||||
|
when(sourcesMapper.getById(SOURCE_ID)).thenReturn(source());
|
||||||
|
when(ssrfSafeFetcher.fetch(any(URI.class), anyLong(), any())).thenReturn(okFetch());
|
||||||
|
when(feedParser.parse(any())).thenReturn(List.of(item("A"), item("B")));
|
||||||
|
when(itemsMapper.existsByGuid(eq(SOURCE_ID), anyString())).thenReturn(true);
|
||||||
|
|
||||||
|
int newCount = poller.pollOnce(SOURCE_ID);
|
||||||
|
|
||||||
|
assertEquals(0, newCount);
|
||||||
|
verify(itemsMapper, never()).insertIgnoreDup(any(UnityFeedItemData.class));
|
||||||
|
verify(sourcesMapper).updateCursor(SOURCE_ID, "A");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void insertsOnlyNewGuidInMixedBatch() {
|
||||||
|
when(sourcesMapper.getById(SOURCE_ID)).thenReturn(source());
|
||||||
|
when(ssrfSafeFetcher.fetch(any(URI.class), anyLong(), any())).thenReturn(okFetch());
|
||||||
|
when(feedParser.parse(any())).thenReturn(List.of(item("A"), item("B")));
|
||||||
|
when(itemsMapper.existsByGuid(SOURCE_ID, "A")).thenReturn(true);
|
||||||
|
when(itemsMapper.existsByGuid(SOURCE_ID, "B")).thenReturn(false);
|
||||||
|
when(itemsMapper.insertIgnoreDup(any(UnityFeedItemData.class))).thenReturn(1);
|
||||||
|
|
||||||
|
int newCount = poller.pollOnce(SOURCE_ID);
|
||||||
|
|
||||||
|
assertEquals(1, newCount);
|
||||||
|
verify(itemsMapper, times(1)).insertIgnoreDup(any(UnityFeedItemData.class));
|
||||||
|
verify(sourcesMapper).updateCursor(SOURCE_ID, "A");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordsErrorWhenFetchFails() {
|
||||||
|
when(sourcesMapper.getById(SOURCE_ID)).thenReturn(source());
|
||||||
|
when(ssrfSafeFetcher.fetch(any(URI.class), anyLong(), any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
int newCount = poller.pollOnce(SOURCE_ID);
|
||||||
|
|
||||||
|
assertEquals(0, newCount);
|
||||||
|
verify(sourcesMapper).updateError(eq(SOURCE_ID), anyString());
|
||||||
|
verify(itemsMapper, never()).insertIgnoreDup(any(UnityFeedItemData.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue