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
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ services:
app:
image: 254201323850.dkr.ecr.ap-northeast-2.amazonaws.com/teaching-app:latest
container_name: teaching-app
init: true
ports:
- "8080:8080"
environment:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public enum MaterialErrorCode implements BaseErrorCode {
CONTENT_REQUIRED(HttpStatus.BAD_REQUEST, "MATERIAL4007", "분석할 본문 내용을 입력해주세요."),
UNSUPPORTED_MATERIAL_PLATFORM(HttpStatus.BAD_REQUEST, "MATERIAL4008", "지원하지 않는 자료 플랫폼입니다."),
MATERIAL_ACCESS_DENIED(HttpStatus.FORBIDDEN, "MATERIAL4031", "해당 자료에 접근할 권한이 없습니다."),
MATERIAL_SOURCE_AUTH_REQUIRED(HttpStatus.FORBIDDEN, "MATERIAL4032", "로그인 또는 회원가입이 필요한 페이지는 분석할 수 없습니다."),
MATERIAL_NOT_FOUND(HttpStatus.NOT_FOUND, "MATERIAL4041", "자료를 찾을 수 없습니다."),
MATERIAL_ANALYSIS_NOT_FOUND(HttpStatus.NOT_FOUND, "MATERIAL4042", "자료의 AI 분석 결과를 찾을 수 없습니다."),
MATERIAL_NOT_IN_TRASH(HttpStatus.CONFLICT, "MATERIAL4093", "휴지통에 있는 자료가 아닙니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ protected ParsedHtmlContent parseDocument(String originalUrl, HtmlDocument docum
}

protected void validateDocument(HtmlDocument document) {
if (document == null) {
return;
}
if (ProtectedSourceDetector.isProtectedHtml(document.body(), document.originalUrl())) {
throw new MaterialException(MaterialErrorCode.MATERIAL_SOURCE_AUTH_REQUIRED);
}
}

protected List<String> contentClassSignals() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ public CafeMaterialContentExtractor(ExternalHtmlDocumentClient htmlDocumentClien

@Override
protected void validateDocument(HtmlDocument document) {
super.validateDocument(document);
if (document == null) {
return;
}
String body = document.body();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (body == null || body.isBlank()) {
return;
Expand All @@ -44,7 +48,7 @@ protected void validateDocument(HtmlDocument document) {
safeUrl(document.originalUrl()),
body.length()
);
throw new MaterialException(MaterialErrorCode.MATERIAL_CONTENT_EXTRACTION_FAILED);
throw new MaterialException(MaterialErrorCode.MATERIAL_SOURCE_AUTH_REQUIRED);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ public HtmlDocument fetch(String originalUrl) {
.exchangeToMono(response -> {
HttpStatusCode statusCode = response.statusCode();
if (!statusCode.is2xxSuccessful()) {
if (isAuthenticationRequiredStatus(statusCode)) {
logExtractionFailure("fetch-auth-required", host, statusCode, null, null);
return response.releaseBody()
.then(Mono.error(new MaterialException(
MaterialErrorCode.MATERIAL_SOURCE_AUTH_REQUIRED
)));
}
Comment on lines +108 to +114

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

403 응답을 모두 인증 필요로 분류하면 오분류가 발생합니다.

403은 인증 요구 외에도 봇 차단, 지역 차단, WAF 규칙에서 자주 사용됩니다. 이 경로는 이제 그러한 응답에도 "로그인 또는 회원가입이 필요한 페이지"라는 메시지를 반환합니다.

또한 이 분기는 logExtractionFailure를 호출하지 않습니다. 401·403 실패는 로그에 남지 않으므로 원인 분석이 어려워집니다. 최소한 호스트와 상태 코드를 기록하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/teaching/backend/domain/material/service/extract/ExternalHtmlDocumentClient.java`
around lines 108 - 113, Update the authentication-status handling in
ExternalHtmlDocumentClient so only responses that can be confidently identified
as authentication-required produce MATERIAL_SOURCE_AUTH_REQUIRED; do not
classify every 403 response this way, allowing bot, regional, and WAF blocks to
follow the generic failure path. Ensure the 401/403 handling also invokes
logExtractionFailure with at least the request host and status code before
returning or propagating the error.

return response.bodyToMono(String.class)
.defaultIfEmpty("")
.flatMap(body -> extractionFailed(
Expand Down Expand Up @@ -158,6 +165,11 @@ public HtmlDocument fetch(String originalUrl) {
false
);
}
if (ProtectedSourceDetector.isProtectedHtml(body, originalUrl)) {
throw new MaterialException(
MaterialErrorCode.MATERIAL_SOURCE_AUTH_REQUIRED
);
}
return new HtmlDocument(originalUrl, body, contentType.toString());
});
})
Expand Down Expand Up @@ -311,6 +323,13 @@ private boolean isRecoverableStatus(HttpStatusCode statusCode) {
return statusCode != null && statusCode.is5xxServerError();
}

private boolean isAuthenticationRequiredStatus(HttpStatusCode statusCode) {
if (statusCode == null) {
return false;
}
return statusCode.value() == 401;
}

private String normalizeHost(String host) {
String normalizedHost = host.toLowerCase(Locale.ROOT);
if (normalizedHost.startsWith("[") && normalizedHost.endsWith("]")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,20 @@ private List<String> contentClassSignals() {
}

private ParsedHtmlContent parse(String originalUrl, HtmlDocument document) {
validateDocument(document);
return htmlContentParser.parse(
originalUrl,
document.body(),
contentClassSignals()
);
}

private void validateDocument(HtmlDocument document) {
if (document != null && ProtectedSourceDetector.isProtectedHtml(document.body(), document.originalUrl())) {
throw new MaterialException(MaterialErrorCode.MATERIAL_SOURCE_AUTH_REQUIRED);
}
}

private boolean hasSufficientContent(ParsedHtmlContent parsed) {
return parsed != null
&& parsed.content() != null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,16 @@ public NotionMaterialContentExtractor(ExternalHtmlDocumentClient htmlDocumentCli

@Override
protected void validateDocument(HtmlDocument document) {
super.validateDocument(document);
if (document == null) {
return;
}
String body = document.body();
if (body == null || body.isBlank()) {
return;
}
if (body.contains("This page is private") || body.contains("You do not have access")) {
throw new MaterialException(MaterialErrorCode.MATERIAL_CONTENT_EXTRACTION_FAILED);
throw new MaterialException(MaterialErrorCode.MATERIAL_SOURCE_AUTH_REQUIRED);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
package com.teaching.backend.domain.material.service.extract;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;

import java.net.URI;
import java.util.List;
import java.util.Locale;

final class ProtectedSourceDetector {

private static final List<String> AUTH_HOSTS = List.of(
"accounts.google.com",
"login.microsoftonline.com",
"login.live.com",
"appleid.apple.com"
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
private static final List<String> AUTH_TEXT_SIGNALS = List.of(
"login",
"log in",
"sign in",
"signin",
"sign up",
"signup",
"register",
"로그인",
"회원가입"
);
private static final List<String> ACCESS_DENIED_SIGNALS = List.of(
"access denied",
"unauthorized",
"you do not have access",
"this page is private",
"권한이 없습니다"
);

private ProtectedSourceDetector() {
}

static boolean isAuthenticationUrl(String url) {
try {
URI uri = URI.create(url);
String host = normalize(uri.getHost());
String path = normalize(uri.getPath());
if (host.isBlank()) {
return false;
}
if (isKnownAuthHost(host)) {
return true;
}
return hasAuthPathSegment(path);
} catch (RuntimeException e) {
return false;
}
}

static boolean isProtectedHtml(String html, String currentUrl) {
return isProtectedHtml(html, currentUrl, currentUrl);
}

static boolean isProtectedHtml(String html, String currentUrl, String originalUrl) {
if (html == null || html.isBlank()) {
return false;
}
if (isAuthenticationUrl(currentUrl)) {
return true;
}

Document document = Jsoup.parse(html, currentUrl == null ? "" : currentUrl);
if (hasNotionPrivateSignal(document)) {
return true;
}
if (isNotionAppPrivateShell(document, originalUrl, currentUrl)) {
return true;
}
if (hasAccessDeniedHeading(document)) {
return true;
}

boolean hasPasswordInput = !document.select("input[type=password]").isEmpty();
if (!hasPasswordInput) {
return false;
}

return hasAuthTitleOrHeading(document) || hasAuthFormSignal(document);
}

static boolean hasNotionPrivateSignal(String html) {
if (html == null || html.isBlank()) {
return false;
}
return hasNotionPrivateSignal(Jsoup.parse(html));
}

private static boolean isKnownAuthHost(String host) {
return AUTH_HOSTS.stream().anyMatch(authHost -> host.equals(authHost) || host.endsWith("." + authHost));
}

private static boolean hasAuthPathSegment(String path) {
if (path.isBlank()) {
return false;
}
String[] segments = path.split("/+");
for (int i = 0; i < segments.length; i++) {
String segment = segments[i];
if (segment.isBlank()) {
continue;
}
if (segment.equals("login") || segment.equals("signin") || segment.equals("sign-in")) {
return true;
}
if ((segment.equals("auth") || segment.equals("oauth") || segment.equals("sso")) && isFirstPathSegment(segments, i)) {
return true;
}
}
return false;
}

private static boolean isFirstPathSegment(String[] segments, int index) {
for (int i = 0; i < index; i++) {
if (!segments[i].isBlank()) {
return false;
}
}
return true;
}

private static boolean hasAuthTitleOrHeading(Document document) {
String title = normalizeText(document.title());
if (hasAuthTextSignal(title)) {
return true;
}
for (Element heading : document.select("h1, h2, h3, [role=heading]")) {
if (hasAuthTextSignal(normalizeText(heading.text()))) {
return true;
}
}
return false;
}

private static boolean hasAuthFormSignal(Document document) {
for (Element form : document.select("form")) {
String signalSource = normalizeText(String.join(" ",
form.attr("action"),
form.id(),
form.className(),
form.attr("aria-label"),
form.attr("name")
));
if (hasAuthTextSignal(signalSource) || hasAuthPathSegment(signalSource.replace(' ', '/'))) {
return true;
}
}
return false;
}

private static boolean hasNotionPrivateSignal(Document document) {
String text = normalizeText(document.text());
return text.contains("this page is private") || text.contains("you do not have access");
}

private static boolean isNotionAppPrivateShell(Document document, String originalUrl, String currentUrl) {
if (!isNotionAppDocumentUrl(originalUrl) && !isNotionAppDocumentUrl(currentUrl)) {
return false;
}
if (hasNotionDocumentContentSignal(document)) {
return false;
}

String title = normalizeText(document.title());
String text = normalizeText(document.text());
boolean marketingTitle = title.contains("notion")
&& title.contains("where teams and agents work together");
boolean shellAuthSignal = hasNotionShellAuthSignal(document, text);

return marketingTitle && shellAuthSignal;
}

private static boolean isNotionAppDocumentUrl(String url) {
try {
URI uri = URI.create(url);
String host = normalize(uri.getHost());
if (!host.equals("app.notion.com")) {
return false;
}
String[] segments = normalize(uri.getPath()).split("/+");
for (String segment : segments) {
if (!segment.isBlank()) {
return segment.equals("p");
}
}
return false;
} catch (RuntimeException e) {
return false;
}
}

private static boolean hasNotionDocumentContentSignal(Document document) {
return document.selectFirst(".notion-page-content, .notion-page-block, [data-block-id], [data-content-editable-leaf]") != null;
}

private static boolean hasNotionShellAuthSignal(Document document, String text) {
if (text.contains("log in") || text.contains("sign up") || text.contains("get notion free")) {
return true;
}
for (Element element : document.select("a[href], button, [role=button]")) {
String signalSource = normalizeText(String.join(" ",
element.attr("href"),
element.attr("aria-label"),
element.text()
));
if (signalSource.contains("log in")
|| signalSource.contains("login")
|| signalSource.contains("sign in")
|| signalSource.contains("sign up")) {
return true;
}
}
return false;
}

private static boolean hasAccessDeniedHeading(Document document) {
for (Element heading : document.select("title, h1, h2, h3, [role=heading]")) {
String text = normalizeText(heading.text());
if (ACCESS_DENIED_SIGNALS.stream().anyMatch(text::equals)) {
return true;
}
}
return false;
}

private static boolean hasAuthTextSignal(String value) {
if (value == null || value.isBlank()) {
return false;
}
return AUTH_TEXT_SIGNALS.stream().anyMatch(value::contains);
}

private static String normalize(String value) {
return value == null ? "" : value.toLowerCase(Locale.ROOT);
}

private static String normalizeText(String value) {
return normalize(value).replaceAll("[\\r\\n\\t]+", " ").replaceAll("\\s+", " ").trim();
}
}
Loading
Loading