Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.permitseoul.permitserver.domain.admin.timetable.base.api.dto.res.TimetableInfoResponse;
import com.permitseoul.permitserver.domain.admin.timetable.base.core.components.AdminTimetableRetriever;
import com.permitseoul.permitserver.domain.admin.timetable.base.core.components.AdminTimetableUpdater;
import com.permitseoul.permitserver.domain.admin.timetable.base.core.exception.NotionPublicUrlNotFoundException;
import com.permitseoul.permitserver.domain.admin.timetable.base.core.exception.NotionUrlMalformedException;
import com.permitseoul.permitserver.domain.admin.util.exception.PermitListSizeNotMatchException;
import com.permitseoul.permitserver.domain.eventtimetable.timetable.core.domain.Timetable;
import com.permitseoul.permitserver.domain.eventtimetable.timetable.core.domain.entity.TimetableEntity;
Expand Down Expand Up @@ -83,6 +85,8 @@ public void saveInitialTimetableInfo(final long eventId,
throw new AdminApiException(ErrorCode.NOT_FOUND_NOTION_DATABASE_SOURCE);
} catch (final LocalDateTimeException e) {
throw new AdminApiException(ErrorCode.BAD_REQUEST_DATE_TIME_ERROR);
} catch (final NotionPublicUrlNotFoundException | NotionUrlMalformedException e) {
throw new AdminApiException(ErrorCode.NOT_FOUND_NOTION_PUBLIC_ID);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.permitseoul.permitserver.domain.admin.timetable.base.api.exception;
package com.permitseoul.permitserver.domain.admin.timetable.base.core.exception;

import com.permitseoul.permitserver.domain.admin.base.AdminBaseException;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.permitseoul.permitserver.domain.admin.timetable.base.core.exception;

import com.permitseoul.permitserver.domain.admin.base.AdminBaseException;

public class NotionPublicUrlNotFoundException extends AdminBaseException {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.permitseoul.permitserver.domain.admin.timetable.base.core.exception;

import com.permitseoul.permitserver.domain.admin.base.AdminBaseException;

public class NotionUrlMalformedException extends AdminBaseException {
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.permitseoul.permitserver.domain.admin.timetable.block.api.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotNull;

import java.util.List;

Expand All @@ -11,6 +12,9 @@ public record NotionTimetableData(
String id, // 노션 page id (== timetable row id)
@JsonProperty("last_edited_time")
String lastEditedTime,
@NotNull
@JsonProperty("public_url")
String publicUrl,
NotionTimetableParent parent,
NotionTimetableProperties properties
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.permitseoul.permitserver.domain.admin.timetable.block.api.service;

import com.permitseoul.permitserver.domain.admin.base.api.exception.AdminApiException;
import com.permitseoul.permitserver.domain.admin.timetable.base.api.exception.AdminNotionException;
import com.permitseoul.permitserver.domain.admin.timetable.base.core.exception.AdminNotionException;
import com.permitseoul.permitserver.domain.admin.timetable.base.core.components.AdminTimetableRetriever;
import com.permitseoul.permitserver.domain.admin.timetable.base.core.exception.NotionPublicUrlNotFoundException;
import com.permitseoul.permitserver.domain.admin.timetable.base.core.exception.NotionUrlMalformedException;
import com.permitseoul.permitserver.domain.admin.timetable.block.api.dto.NotionTimetableBlockUpdateWebhookRequest;
import com.permitseoul.permitserver.domain.admin.timetable.block.core.component.AdminTimetableBlockSaver;
import com.permitseoul.permitserver.domain.admin.timetable.block.core.strategy.domain.NotionTimetableBlockWebhookType;
Expand Down Expand Up @@ -50,6 +52,9 @@ public void updateNotionTimetableBlock(final NotionTimetableBlockUpdateWebhookRe
} catch (IndexOutOfBoundsException | NullPointerException e) {
log.error("웹훅 데이터에 필수 필드가 누락되었습니다. request={}", webhookRequest, e);
throw new AdminNotionException();
} catch(NotionPublicUrlNotFoundException | NotionUrlMalformedException e) {
log.error("[Notion 자동화 웹훅 에러] publicUrl이 잘못되었습니다. request={}", webhookRequest, e);
throw new AdminNotionException();
} catch (Exception e) {
log.error("타임테이블 블럭 웹훅 처리 중 알 수 없는 예외 발생. request={}", webhookRequest, e);
throw new AdminNotionException();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
package com.permitseoul.permitserver.domain.admin.timetable.block.core.strategy.impl;

import com.permitseoul.permitserver.domain.admin.timetable.base.core.exception.NotionUrlMalformedException;
import com.permitseoul.permitserver.domain.admin.timetable.block.api.dto.NotionTimetableBlockUpdateWebhookRequest;
import com.permitseoul.permitserver.domain.admin.timetable.block.core.strategy.domain.NotionTimetableBlockWebhookType;
import com.permitseoul.permitserver.domain.admin.timetable.block.core.strategy.NotionTimetableBlockUpdateWebhookStrategy;
import com.permitseoul.permitserver.domain.admin.timetable.blockmedia.core.component.AdminTimetableBlockMediaSaver;
import com.permitseoul.permitserver.domain.admin.timetable.blockmedia.core.component.AdminTimetableBlockMediaRemover;
import com.permitseoul.permitserver.domain.admin.util.NotionImageUrlUtil;
import com.permitseoul.permitserver.domain.eventtimetable.block.core.component.AdminTimetableBlockRetriever;
import com.permitseoul.permitserver.domain.eventtimetable.block.core.domain.TimetableBlock;
import com.permitseoul.permitserver.domain.eventtimetable.blockmedia.domain.entity.TimetableBlockMediaEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.net.MalformedURLException;
import java.util.ArrayList;
import java.util.List;

Expand All @@ -30,6 +33,17 @@ public NotionTimetableBlockWebhookType getType() {
@Override
public void updateNotionTimetableBlockByNotionWebhook(final NotionTimetableBlockUpdateWebhookRequest request) {
final String rowId = request.data().id();
final String publicUrl = request.data().publicUrl();

final String host;
try {
host = new java.net.URL(publicUrl).getHost();
} catch (MalformedURLException e) {
throw new NotionUrlMalformedException();
}
Comment on lines +36 to +43
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

host 값 검증과 코드 중복을 확인하세요.

두 가지 개선이 필요합니다:

  1. Host 검증 누락: URL.getHost()는 일부 URL 형식에서 null 또는 빈 문자열을 반환할 수 있습니다. host 값이 유효한지 검증이 필요합니다.

  2. 코드 중복: 동일한 로직이 NotionResponseMapper.java (lines 156-161)에도 존재합니다. 공통 유틸리티 메서드로 추출하는 것을 고려하세요.

🔎 제안하는 개선 방안

방안 1: Host 검증 추가

 final String host;
 try {
     host = new java.net.URL(publicUrl).getHost();
+    if (host == null || host.isBlank()) {
+        throw new NotionUrlMalformedException();
+    }
 } catch (MalformedURLException e) {
     throw new NotionUrlMalformedException();
 }

방안 2: 공통 메서드 추출 (선호)

NotionImageUrlUtil에 다음 메서드를 추가:

public static String extractHostFromPublicUrl(final String publicUrl) {
    if (publicUrl == null || publicUrl.isBlank()) {
        throw new NotionPublicUrlNotFoundException();
    }
    try {
        final String host = new URL(publicUrl).getHost();
        if (host == null || host.isBlank()) {
            throw new NotionUrlMalformedException();
        }
        return host;
    } catch (MalformedURLException e) {
        throw new NotionUrlMalformedException();
    }
}

그리고 이 메서드를 NotionResponseMapper.java와 이 클래스 모두에서 사용하세요.

🤖 Prompt for AI Agents
In
src/main/java/com/permitseoul/permitserver/domain/admin/timetable/block/core/strategy/impl/NotionTimetableBlockMediaUpdateStrategyImpl.java
around lines 37-44: the current extraction of host from
request.data().publicUrl() lacks validation for null/blank host and duplicates
logic found in NotionResponseMapper.java (lines ~156-161); extract the logic
into a shared util (e.g., NotionImageUrlUtil.extractHostFromPublicUrl) that
checks publicUrl for null/blank (throw NotionPublicUrlNotFoundException), parses
the URL catching MalformedURLException (throw NotionUrlMalformedException),
verifies host is non-null/non-blank (throw NotionUrlMalformedException) and
returns the host, then replace the duplicated code in both
NotionTimetableBlockMediaUpdateStrategyImpl and NotionResponseMapper to call
this new utility.

if (host == null) {
throw new NotionUrlMalformedException();
}

final TimetableBlock block = adminTimetableBlockRetriever.findTimetableBlockByNotionTimetableBlockRowId(rowId);
final long timetableBlockId = block.getTimetableBlockId();
Expand All @@ -42,16 +56,17 @@ public void updateNotionTimetableBlockByNotionWebhook(final NotionTimetableBlock

int sequence = 0;
final List<TimetableBlockMediaEntity> medias = new ArrayList<>();
for (NotionTimetableBlockUpdateWebhookRequest.NotionFileValue file : mediaProp.files()) {
String url = null;
if (file.file() != null && file.file().url() != null) {
url = file.file().url();
}
if (url == null || url.isBlank()) {
continue;
}

medias.add(TimetableBlockMediaEntity.create(timetableBlockId, sequence++, url));

for (NotionTimetableBlockUpdateWebhookRequest.NotionFileValue fileItem : mediaProp.files()) {
if (fileItem == null || fileItem.file() == null) continue;

final String original = fileItem.file().url();
if (original == null || original.isBlank()) continue;

final String proxyUrl = NotionImageUrlUtil.buildProxyUrl(host, rowId, original);
if (proxyUrl == null || proxyUrl.isBlank()) continue;

medias.add(TimetableBlockMediaEntity.create(timetableBlockId, sequence++, proxyUrl));
}
adminTimetableBlockMediaSaver.saveAllBlockMedia(medias);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.permitseoul.permitserver.domain.admin.timetable.category.api.service;

import com.permitseoul.permitserver.domain.admin.base.api.exception.AdminApiException;
import com.permitseoul.permitserver.domain.admin.timetable.base.api.exception.AdminNotionException;
import com.permitseoul.permitserver.domain.admin.timetable.base.core.exception.AdminNotionException;
import com.permitseoul.permitserver.domain.admin.timetable.base.core.components.AdminTimetableRetriever;
import com.permitseoul.permitserver.domain.admin.timetable.category.api.dto.NotionTimetableCategoryUpdateWebhookRequest;
import com.permitseoul.permitserver.domain.admin.timetable.category.core.component.AdminTimetableCategorySaver;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.permitseoul.permitserver.domain.admin.timetable.stage.api.service;

import com.permitseoul.permitserver.domain.admin.base.api.exception.AdminApiException;
import com.permitseoul.permitserver.domain.admin.timetable.base.api.exception.AdminNotionException;
import com.permitseoul.permitserver.domain.admin.timetable.base.core.exception.AdminNotionException;
import com.permitseoul.permitserver.domain.admin.timetable.base.core.components.AdminTimetableRetriever;
import com.permitseoul.permitserver.domain.admin.timetable.stage.api.dto.NotionTimetableStageUpdateWebhookRequest;
import com.permitseoul.permitserver.domain.admin.timetable.stage.core.component.AdminTimetableStageSaver;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.permitseoul.permitserver.domain.admin.util;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class NotionImageUrlUtil {

private static final String HTTPS = "https://";
private static final String IMAGE_PATH = "/image/";
private static final String QUERY_PREFIX = "?table=block&id=";
private static final String CACHE_SUFFIX = "&cache=v2";

public static String buildProxyUrl(final String publishedHost,
final String pageId,
final String originalUrl) {
if (publishedHost == null || publishedHost.isBlank()) return null;
if (pageId == null || pageId.isBlank()) return null;
if (originalUrl == null || originalUrl.isBlank()) return null;

// presigned query 제거하는 과정
final String baseUrl = originalUrl.split("\\?")[0];
return HTTPS + publishedHost
+ IMAGE_PATH + encodeURIComponent(baseUrl)
+ QUERY_PREFIX + pageId
+ CACHE_SUFFIX;
}

private static String encodeURIComponent(final String s) {
String encoded = URLEncoder.encode(s, StandardCharsets.UTF_8);
return encoded.replace("+", "%20")
.replace("%21", "!")
.replace("%27", "'")
.replace("%28", "(")
.replace("%29", ")")
.replace("%2A", "*")
.replace("%7E", "~");
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.permitseoul.permitserver.domain.admin.util;

import com.permitseoul.permitserver.domain.admin.base.AdminBaseException;
import com.permitseoul.permitserver.domain.admin.timetable.base.core.exception.NotionPublicUrlNotFoundException;
import com.permitseoul.permitserver.domain.admin.timetable.base.core.exception.NotionUrlMalformedException;
import com.permitseoul.permitserver.domain.admin.util.exception.PermitListSizeNotMatchException;
import com.permitseoul.permitserver.domain.eventtimetable.block.core.domain.TimetableBlock;
import com.permitseoul.permitserver.domain.eventtimetable.block.core.domain.entity.TimetableBlockEntity;
Expand All @@ -8,11 +11,15 @@
import com.permitseoul.permitserver.domain.eventtimetable.stage.core.domain.entity.TimetableStageEntity;
import com.permitseoul.permitserver.global.exception.DateFormatException;
import com.permitseoul.permitserver.global.exception.PermitIllegalStateException;
import com.permitseoul.permitserver.global.exception.UrlSecureException;
import com.permitseoul.permitserver.global.external.notion.dto.NotionCategoryDatasourceResponse;
import com.permitseoul.permitserver.global.external.notion.dto.NotionStageDatasourceResponse;
import com.permitseoul.permitserver.global.external.notion.dto.NotionTimetableDatasourceResponse;
import com.permitseoul.permitserver.global.util.LocalDateTimeFormatterUtil;
import org.jetbrains.annotations.NotNull;

import java.net.MalformedURLException;
import java.net.URL;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
Expand Down Expand Up @@ -140,19 +147,51 @@ public static List<TimetableBlockMediaEntity> mapToTimetableBlockMediaEntities(f

for (int i = 0; i < savedBlocks.size(); i++) {
final TimetableBlock block = savedBlocks.get(i);
final List<NotionTimetableDatasourceResponse.FilesProperty.FileItem> files = notionTimetableDatasourceResponse.results().get(i).properties().media().files();
if (files == null) continue;
final NotionTimetableDatasourceResponse.NotionPage notionPage = notionTimetableDatasourceResponse.results().get(i);
final String pageId = notionPage.id();
final String host = getHost(notionPage);

final NotionTimetableDatasourceResponse.FilesProperty filesProperty = notionPage.properties().media();
if (filesProperty == null || filesProperty.files() == null) continue;
final List<NotionTimetableDatasourceResponse.FilesProperty.FileItem> files = filesProperty.files();

for (int seq = 0; seq < files.size(); seq++) {
String mediaUrl = files.get(seq).file().url();
final NotionTimetableDatasourceResponse.FilesProperty.FileItem fileItem = files.get(seq);
if (fileItem == null || fileItem.file() == null) continue;

final String originalUrl = fileItem.file().url();
if (originalUrl == null || originalUrl.isBlank()) continue;

//노션에서 주는 url은 유효기간이 있어서 proxyUrl로 우회해서 조회하기
final String proxyUrl = NotionImageUrlUtil.buildProxyUrl(host, pageId, originalUrl);
if (proxyUrl == null) continue;

mediaEntities.add(TimetableBlockMediaEntity.create(
block.getTimetableBlockId(),
seq,
mediaUrl
proxyUrl
));
}
}

return mediaEntities;
}

private static String getHost(NotionTimetableDatasourceResponse.NotionPage notionPage) {
final String publicUrl = notionPage.publicUrl();
if (publicUrl == null || publicUrl.isBlank()) {
throw new NotionPublicUrlNotFoundException();
}

final String host;
try {
host = new URL(publicUrl).getHost();
} catch (MalformedURLException e) {
throw new NotionUrlMalformedException();
}
if (host == null) {
throw new NotionUrlMalformedException();
}
return host;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ public record UserInfoRequest(
Gender gender,

@Email(message = "이메일 형식이 아닙니다.")
@NotBlank(message = "이메일은 공백이 될 수 없습니다.")
String email
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ public void updateUserInfo(final long userId, final String name, final Gender ge
final UserEntity userEntity;
try {
userEntity = userRetriever.findUserEntityById(userId);
userRetriever.validEmailDuplicated(email);
if (email != null) {
userRetriever.validEmailDuplicated(email);
}
userEntity.updateUserInfo(name, gender, email);
} catch (UserNotFoundException e) {
throw new NotfoundUserException(ErrorCode.NOT_FOUND_USER);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ public void updateUserInfo(final String name,
final String email) {
this.name = name;
this.gender = gender;
this.email = email;
if (email != null) {
this.email = email;
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ public record NotionTimetableDatasourceResponse(
public record NotionPage(
String id,
Parent parent,
NotionProperties properties
NotionProperties properties,
@JsonProperty("public_url")
String publicUrl
Comment on lines +13 to +15
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n -A5 -B5 "publicUrl|public_url" --type java

Repository: PERMIT-SEOUL/permit-server

Length of output: 10825


NotionTimetableBlockMediaUpdateStrategyImpl에서 publicUrl null 체크 누락

public_url 필드 매핑은 올바릅니다. 다만 NotionResponseMapper에서는 null 체크가 있으나, NotionTimetableBlockMediaUpdateStrategyImpl (37-44줄)에서는 publicUrl을 null 체크 없이 바로 new URL(publicUrl)에 전달하고 있습니다. Notion 웹훅 응답에서 public_url이 누락될 경우 NullPointerException이 발생할 수 있으므로, NotionResponseMapper와 동일하게 null/blank 체크를 추가하거나 exception 처리를 명시적으로 추가해야 합니다.

🤖 Prompt for AI Agents
In
src/main/java/com/permitseoul/permitserver/global/external/notion/dto/NotionTimetableDatasourceResponse.java
around lines 13-15, the public_url mapping is correct but the calling code
(NotionTimetableBlockMediaUpdateStrategyImpl lines ~37-44) passes publicUrl
directly into new URL(publicUrl) without null/blank checks; update the strategy
implementation to first validate publicUrl (e.g., check for null or blank using
Objects.isNull or StringUtils.isBlank) and either skip processing/return early
or throw a clear, handled exception; alternatively wrap new URL(publicUrl) in a
try/catch for MalformedURLException and handle null/blank the same way
NotionResponseMapper does to prevent NullPointerException.

) {}

public record Parent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ public enum ErrorCode implements ApiCode {
NOT_FOUND_NOTION_DATABASE_SOURCE(HttpStatus.NOT_FOUND, 40427, "Notion 데이터베이스 소스를 찾을 수 없습니다."),
NOT_FOUND_COUPON(HttpStatus.NOT_FOUND, 40428, "coupon을 찾을 수 없습니다."),
NOT_FOUND_SITE_MAP_IMAGE(HttpStatus.NOT_FOUND, 40429, "sitemap image를 찾을 수 없습니다."),
NOT_FOUND_NOTION_PUBLIC_ID(HttpStatus.NOT_FOUND, 40430, "public url을 찾을 수 없습니다."),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

에러 메시지 수정 필요.

에러 코드는 NOT_FOUND_NOTION_PUBLIC_ID인데 메시지가 "public url을 찾을 수 있습니다"로 되어 있습니다. "찾을 수 없습니다"로 수정해야 합니다.

🔎 수정 제안
-    NOT_FOUND_NOTION_PUBLIC_ID(HttpStatus.NOT_FOUND, 40430, "public url을 찾을 수 있습니다."),
+    NOT_FOUND_NOTION_PUBLIC_ID(HttpStatus.NOT_FOUND, 40430, "public url을 찾을 수 없습니다."),

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
src/main/java/com/permitseoul/permitserver/global/response/code/ErrorCode.java
around line 91, the enum constant NOT_FOUND_NOTION_PUBLIC_ID has the wrong
Korean message (says "찾을 수 있습니다" instead of negative); update its message string
to "public url을 찾을 수 없습니다." so the error text matches the NOT_FOUND intent.




Expand Down