package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties; import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript; import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.HashSet; import java.util.Set; import java.util.regex.Pattern; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; /** * Read-only catalogue of security deployment SQL bundled from the Git-tracked * sql/adb directory. Script ids are an application whitelist: request input * never becomes a filesystem or classpath path. */ @Service public class SecuritySqlScriptService { private static final Pattern SCRIPT_ID = Pattern.compile("[a-z][a-z0-9-]{0,63}"); private static final Pattern RESOURCE_PATH = Pattern.compile( "(?:[A-Za-z0-9][A-Za-z0-9_-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.sql" ); private final List scripts; public SecuritySqlScriptService( SecuritySqlScriptProperties properties, ObjectMapper objectMapper ) { scripts = parse(properties.scripts(), objectMapper); } public List list() { return scripts.stream() .map(definition -> new SecuritySqlScriptSummary( definition.scriptId(), definition.category(), definition.fileName(), definition.title(), definition.description() )) .toList(); } public SecuritySqlScript find(String scriptId) { ScriptDefinition definition = scripts.stream() .filter(candidate -> candidate.scriptId().equals(scriptId)) .findFirst() .orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다.")); return new SecuritySqlScript( definition.scriptId(), definition.category(), definition.fileName(), definition.title(), definition.description(), readSource(definition.fileName()) ); } private String readSource(String fileName) { ClassPathResource resource = new ClassPathResource("sql/adb/" + fileName); try (InputStream input = resource.getInputStream()) { return new String(input.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException exception) { throw new AppException("배포된 보안 SQL 스크립트를 읽을 수 없습니다: " + fileName); } } private List parse(String raw, ObjectMapper objectMapper) { if (raw == null || raw.isBlank()) { return List.of(); } try { List parsed = objectMapper.readValue(raw, new TypeReference<>() {}); if (parsed.isEmpty()) { throw new IllegalArgumentException("보안 SQL 목록이 비어 있습니다."); } Set scriptIds = new HashSet<>(); Set fileNames = new HashSet<>(); parsed.forEach(definition -> { validate(definition); if (!scriptIds.add(definition.scriptId())) { throw new IllegalArgumentException("중복 scriptId"); } if (!fileNames.add(definition.fileName())) { throw new IllegalArgumentException("중복 fileName"); } }); return List.copyOf(parsed); } catch (Exception exception) { throw new IllegalStateException("BACKOFFICE_SECURITY_SQL_SCRIPTS 설정을 확인하세요.", exception); } } private void validate(ScriptDefinition definition) { if (definition == null || definition.scriptId() == null || !SCRIPT_ID.matcher(definition.scriptId()).matches() || definition.fileName() == null || !RESOURCE_PATH.matcher(definition.fileName()).matches() || blank(definition.category()) || blank(definition.title()) || blank(definition.description())) { throw new IllegalArgumentException("보안 SQL 정의가 올바르지 않습니다."); } } private boolean blank(String value) { return value == null || value.isBlank(); } public record ScriptDefinition( String scriptId, String category, String fileName, String title, String description ) { } }