Merge pull request 'feat/infer_dev_260211' (#74) from feat/infer_dev_260211 into develop
Reviewed-on: #74
This commit was merged in pull request #74.
This commit is contained in:
@@ -0,0 +1,92 @@
|
|||||||
|
package com.kamco.cd.kamcoback.common.download;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.kamco.cd.kamcoback.common.download.dto.DownloadAuditEvent;
|
||||||
|
import com.kamco.cd.kamcoback.menu.dto.MenuDto;
|
||||||
|
import com.kamco.cd.kamcoback.menu.service.MenuService;
|
||||||
|
import com.kamco.cd.kamcoback.postgres.entity.AuditLogEntity;
|
||||||
|
import com.kamco.cd.kamcoback.postgres.repository.log.AuditLogRepository;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DownloadAuditEventListener {
|
||||||
|
|
||||||
|
private final AuditLogRepository auditLogRepository;
|
||||||
|
private final MenuService menuService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Async("auditLogExecutor")
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
@EventListener
|
||||||
|
public void onDownloadAudit(DownloadAuditEvent ev) {
|
||||||
|
try {
|
||||||
|
String menuUid = resolveMenuUid(ev.normalizedUri());
|
||||||
|
if (menuUid == null) {
|
||||||
|
// menuUid null 불가 -> 스킵
|
||||||
|
log.warn(
|
||||||
|
"MenuUid not resolved. skip audit. uri={}, normalized={}",
|
||||||
|
ev.requestUri(),
|
||||||
|
ev.normalizedUri());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLogEntity logEntity =
|
||||||
|
AuditLogEntity.forFileDownload(
|
||||||
|
ev.userId(), ev.requestUri(), menuUid, ev.ip(), ev.status(), ev.downloadUuid());
|
||||||
|
|
||||||
|
auditLogRepository.save(logEntity);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 본 요청과 분리되어야 함
|
||||||
|
log.warn("Download audit save failed. uri={}, err={}", ev.requestUri(), e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveMenuUid(String normalizedUri) {
|
||||||
|
try {
|
||||||
|
List<?> list = menuService.getFindAll();
|
||||||
|
|
||||||
|
List<MenuDto.Basic> basics =
|
||||||
|
list.stream()
|
||||||
|
.map(
|
||||||
|
item -> {
|
||||||
|
if (item instanceof LinkedHashMap<?, ?> map) {
|
||||||
|
return objectMapper.convertValue(map, MenuDto.Basic.class);
|
||||||
|
} else if (item instanceof MenuDto.Basic dto) {
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
MenuDto.Basic basic =
|
||||||
|
basics.stream()
|
||||||
|
.filter(m -> m.getMenuUrl() != null && normalizedUri.startsWith(m.getMenuUrl()))
|
||||||
|
.max(Comparator.comparingInt(m -> m.getMenuUrl().length()))
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (basic == null) return null;
|
||||||
|
|
||||||
|
String menuUidStr = basic.getMenuUid(); // ← String
|
||||||
|
if (menuUidStr == null || menuUidStr.isBlank()) return null;
|
||||||
|
|
||||||
|
return menuUidStr; // ← Long 변환
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import java.io.IOException;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import org.springframework.core.io.FileSystemResource;
|
import org.springframework.core.io.FileSystemResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.support.ResourceRegion;
|
import org.springframework.core.io.support.ResourceRegion;
|
||||||
@@ -15,43 +14,11 @@ import org.springframework.http.MediaType;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
|
||||||
* Range(이어받기) 지원 파일 다운로드 응답 생성기.
|
|
||||||
*
|
|
||||||
* <p>- Range 헤더 없으면 200 + Resource - Range 헤더 있으면 206 + ResourceRegion - 잘못된 Range면 416 +
|
|
||||||
* Content-Range: bytes
|
|
||||||
*
|
|
||||||
* <p>주의: - 다중 Range는 첫 번째 Range만 처리(안정성 목적). - 파일이 이미 생성되어 있는 대용량(zip 등) 다운로드에 적합.
|
|
||||||
*/
|
|
||||||
@Component
|
@Component
|
||||||
public class RangeDownloadResponder {
|
public class RangeDownloadResponder {
|
||||||
|
|
||||||
/** 기본(zip) 다운로드 응답 생성. */
|
|
||||||
public ResponseEntity<?> buildZipResponse(
|
public ResponseEntity<?> buildZipResponse(
|
||||||
Path filePath, String downloadFileName, HttpServletRequest request) throws IOException {
|
Path filePath, String downloadFileName, HttpServletRequest request) throws IOException {
|
||||||
return buildResponse(
|
|
||||||
filePath, downloadFileName, MediaType.APPLICATION_OCTET_STREAM, request, Map.of());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 추가 헤더가 필요한 경우(예: X-Accel-Buffering) 사용. */
|
|
||||||
public ResponseEntity<?> buildZipResponse(
|
|
||||||
Path filePath,
|
|
||||||
String downloadFileName,
|
|
||||||
HttpServletRequest request,
|
|
||||||
Map<String, String> extraHeaders)
|
|
||||||
throws IOException {
|
|
||||||
return buildResponse(
|
|
||||||
filePath, downloadFileName, MediaType.APPLICATION_OCTET_STREAM, request, extraHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 콘텐츠 타입까지 지정하고 싶을 때 사용. */
|
|
||||||
public ResponseEntity<?> buildResponse(
|
|
||||||
Path filePath,
|
|
||||||
String downloadFileName,
|
|
||||||
MediaType contentType,
|
|
||||||
HttpServletRequest request,
|
|
||||||
Map<String, String> extraHeaders)
|
|
||||||
throws IOException {
|
|
||||||
|
|
||||||
if (!Files.isRegularFile(filePath)) {
|
if (!Files.isRegularFile(filePath)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
@@ -60,73 +27,53 @@ public class RangeDownloadResponder {
|
|||||||
long totalSize = Files.size(filePath);
|
long totalSize = Files.size(filePath);
|
||||||
Resource resource = new FileSystemResource(filePath);
|
Resource resource = new FileSystemResource(filePath);
|
||||||
|
|
||||||
// 공통 헤더(200/206 공통)
|
|
||||||
String disposition = "attachment; filename=\"" + downloadFileName + "\"";
|
String disposition = "attachment; filename=\"" + downloadFileName + "\"";
|
||||||
String rangeHeader = request.getHeader(HttpHeaders.RANGE);
|
String rangeHeader = request.getHeader(HttpHeaders.RANGE);
|
||||||
|
|
||||||
if (rangeHeader == null || rangeHeader.isBlank()) {
|
// 🔥 공통 헤더 (여기 고정)
|
||||||
// 200 OK (전체 다운로드)
|
ResponseEntity.BodyBuilder base =
|
||||||
ResponseEntity.BodyBuilder ok =
|
ResponseEntity.ok()
|
||||||
ResponseEntity.ok()
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
.contentType(contentType)
|
.header(HttpHeaders.CONTENT_DISPOSITION, disposition)
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, disposition)
|
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
|
||||||
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
|
.header("X-Accel-Buffering", "no");
|
||||||
.contentLength(totalSize);
|
|
||||||
|
|
||||||
applyExtraHeaders(ok, extraHeaders);
|
if (rangeHeader == null || rangeHeader.isBlank()) {
|
||||||
return ok.body(resource);
|
return base.contentLength(totalSize).body(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Range 파싱
|
|
||||||
List<HttpRange> ranges;
|
List<HttpRange> ranges;
|
||||||
try {
|
try {
|
||||||
ranges = HttpRange.parseRanges(rangeHeader);
|
ranges = HttpRange.parseRanges(rangeHeader);
|
||||||
} catch (IllegalArgumentException ex) {
|
} catch (IllegalArgumentException ex) {
|
||||||
// 416 Range Not Satisfiable
|
return ResponseEntity.status(416)
|
||||||
ResponseEntity.BodyBuilder badRange =
|
.header(HttpHeaders.CONTENT_RANGE, "bytes */" + totalSize)
|
||||||
ResponseEntity.status(416).header(HttpHeaders.CONTENT_RANGE, "bytes */" + totalSize);
|
.header("X-Accel-Buffering", "no")
|
||||||
applyExtraHeaders(badRange, extraHeaders);
|
.build();
|
||||||
return badRange.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 실무에선 단일 Range만 처리하는 게 안전합니다.
|
|
||||||
HttpRange range = ranges.get(0);
|
HttpRange range = ranges.get(0);
|
||||||
|
|
||||||
long start = range.getRangeStart(totalSize);
|
long start = range.getRangeStart(totalSize);
|
||||||
long end = range.getRangeEnd(totalSize);
|
long end = range.getRangeEnd(totalSize);
|
||||||
|
|
||||||
if (start >= totalSize) {
|
if (start >= totalSize) {
|
||||||
ResponseEntity.BodyBuilder badRange =
|
return ResponseEntity.status(416)
|
||||||
ResponseEntity.status(416).header(HttpHeaders.CONTENT_RANGE, "bytes */" + totalSize);
|
.header(HttpHeaders.CONTENT_RANGE, "bytes */" + totalSize)
|
||||||
applyExtraHeaders(badRange, extraHeaders);
|
.header("X-Accel-Buffering", "no")
|
||||||
return badRange.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
long regionLength = end - start + 1;
|
long regionLength = end - start + 1;
|
||||||
ResourceRegion region = new ResourceRegion(resource, start, regionLength);
|
ResourceRegion region = new ResourceRegion(resource, start, regionLength);
|
||||||
|
|
||||||
// 206 Partial Content
|
return ResponseEntity.status(206)
|
||||||
ResponseEntity.BodyBuilder partial =
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
ResponseEntity.status(206)
|
.header(HttpHeaders.CONTENT_DISPOSITION, disposition)
|
||||||
.contentType(contentType)
|
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, disposition)
|
.header("X-Accel-Buffering", "no")
|
||||||
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
|
.header(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + totalSize)
|
||||||
.header(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + totalSize)
|
.contentLength(regionLength)
|
||||||
.contentLength(regionLength);
|
.body(region);
|
||||||
|
|
||||||
applyExtraHeaders(partial, extraHeaders);
|
|
||||||
return partial.body(region);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void applyExtraHeaders(
|
|
||||||
ResponseEntity.HeadersBuilder<?> builder, Map<String, String> extraHeaders) {
|
|
||||||
if (extraHeaders == null || extraHeaders.isEmpty()) return;
|
|
||||||
|
|
||||||
extraHeaders.forEach(
|
|
||||||
(k, v) -> {
|
|
||||||
if (k != null && !k.isBlank() && v != null) {
|
|
||||||
builder.header(k, v);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.kamco.cd.kamcoback.common.download.dto;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record DownloadAuditEvent(
|
||||||
|
Long userId,
|
||||||
|
String requestUri,
|
||||||
|
String normalizedUri,
|
||||||
|
String ip,
|
||||||
|
int status,
|
||||||
|
UUID downloadUuid) {}
|
||||||
@@ -1,98 +1,64 @@
|
|||||||
package com.kamco.cd.kamcoback.config;
|
package com.kamco.cd.kamcoback.config;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.kamco.cd.kamcoback.common.download.dto.DownloadAuditEvent;
|
||||||
import com.kamco.cd.kamcoback.common.utils.HeaderUtil;
|
|
||||||
import com.kamco.cd.kamcoback.common.utils.UserUtil;
|
import com.kamco.cd.kamcoback.common.utils.UserUtil;
|
||||||
import com.kamco.cd.kamcoback.config.api.ApiLogFunction;
|
import com.kamco.cd.kamcoback.config.api.ApiLogFunction;
|
||||||
import com.kamco.cd.kamcoback.menu.dto.MenuDto;
|
import jakarta.servlet.DispatcherType;
|
||||||
import com.kamco.cd.kamcoback.menu.service.MenuService;
|
|
||||||
import com.kamco.cd.kamcoback.postgres.entity.AuditLogEntity;
|
|
||||||
import com.kamco.cd.kamcoback.postgres.repository.log.AuditLogRepository;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.servlet.HandlerInterceptor;
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class FileDownloadInteceptor implements HandlerInterceptor {
|
public class FileDownloadInteceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
private final AuditLogRepository auditLogRepository;
|
private final ApplicationEventPublisher publisher;
|
||||||
private final MenuService menuService;
|
|
||||||
private final UserUtil userUtil;
|
private final UserUtil userUtil;
|
||||||
|
|
||||||
@Autowired private ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
public FileDownloadInteceptor(
|
|
||||||
AuditLogRepository auditLogRepository, MenuService menuService, UserUtil userUtil) {
|
|
||||||
this.auditLogRepository = auditLogRepository;
|
|
||||||
this.menuService = menuService;
|
|
||||||
this.userUtil = userUtil;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean preHandle(
|
public void afterCompletion(
|
||||||
HttpServletRequest request, HttpServletResponse response, Object handler) {
|
HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
|
||||||
|
|
||||||
if (!request.getRequestURI().contains("/download")) return true;
|
String uri = request.getRequestURI();
|
||||||
|
if (uri == null || !uri.contains("/download")) return;
|
||||||
|
if (request.getDispatcherType() != DispatcherType.REQUEST) return;
|
||||||
|
|
||||||
if (request.getDispatcherType() != jakarta.servlet.DispatcherType.REQUEST) {
|
Long userId;
|
||||||
return true;
|
try {
|
||||||
}
|
userId = userUtil.getId();
|
||||||
|
if (userId == null) return; // userId null 불가면 스킵
|
||||||
saveLog(request, response);
|
} catch (Exception e) {
|
||||||
|
log.warn("Download audit userId resolve failed. uri={}, err={}", uri, e.toString());
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void saveLog(HttpServletRequest request, HttpServletResponse response) {
|
|
||||||
// 파일 다운로드 API만 필터링
|
|
||||||
if (!request.getRequestURI().contains("/download")) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Long userId = userUtil.getId();
|
|
||||||
String ip = ApiLogFunction.getClientIp(request);
|
String ip = ApiLogFunction.getClientIp(request);
|
||||||
|
int status = response.getStatus();
|
||||||
|
String normalizedUri = uri.replace("/api", "");
|
||||||
|
|
||||||
List<?> list = menuService.getFindAll();
|
UUID downloadUuid = extractUuidFromUri(uri);
|
||||||
List<MenuDto.Basic> result =
|
if (downloadUuid == null) {
|
||||||
list.stream()
|
log.warn("Download UUID parse failed. uri={}", uri);
|
||||||
.map(
|
return; // downloadUuid null 불가 -> 스킵
|
||||||
item -> {
|
}
|
||||||
if (item instanceof LinkedHashMap<?, ?> map) {
|
|
||||||
return objectMapper.convertValue(map, MenuDto.Basic.class);
|
|
||||||
} else if (item instanceof MenuDto.Basic dto) {
|
|
||||||
return dto;
|
|
||||||
} else {
|
|
||||||
throw new IllegalStateException("Unsupported cache type: " + item.getClass());
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
String normalizedUri = request.getRequestURI().replace("/api", "");
|
publisher.publishEvent(
|
||||||
MenuDto.Basic basic =
|
new DownloadAuditEvent(userId, uri, normalizedUri, ip, status, downloadUuid));
|
||||||
result.stream()
|
}
|
||||||
.filter(
|
|
||||||
menu -> menu.getMenuUrl() != null && normalizedUri.startsWith(menu.getMenuUrl()))
|
|
||||||
.max(Comparator.comparingInt(m -> m.getMenuUrl().length()))
|
|
||||||
.orElse(null);
|
|
||||||
|
|
||||||
AuditLogEntity log =
|
private UUID extractUuidFromUri(String uri) {
|
||||||
AuditLogEntity.forFileDownload(
|
try {
|
||||||
userId,
|
String[] parts = uri.split("/");
|
||||||
request.getRequestURI(),
|
String last = parts[parts.length - 1];
|
||||||
Objects.requireNonNull(basic).getMenuUid(),
|
return UUID.fromString(last);
|
||||||
ip,
|
} catch (Exception e) {
|
||||||
response.getStatus(),
|
return null;
|
||||||
UUID.fromString(HeaderUtil.get(request, "kamco-download-uuid")));
|
}
|
||||||
|
|
||||||
auditLogRepository.save(log);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,8 +374,7 @@ public class InferenceResultApiController {
|
|||||||
Path zipPath = Path.of(path);
|
Path zipPath = Path.of(path);
|
||||||
|
|
||||||
// Range + 200/206/416 공통 처리 (추가 헤더 포함)
|
// Range + 200/206/416 공통 처리 (추가 헤더 포함)
|
||||||
return rangeDownloadResponder.buildZipResponse(
|
return rangeDownloadResponder.buildZipResponse(zipPath, uid + ".zip", request);
|
||||||
zipPath, uid + ".zip", request, Map.of("X-Accel-Buffering", "no"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "shp 파일 다운로드 이력 조회", description = "추론관리 분석결과 shp 파일 다운로드 이력 조회")
|
@Operation(summary = "shp 파일 다운로드 이력 조회", description = "추론관리 분석결과 shp 파일 다운로드 이력 조회")
|
||||||
@@ -415,7 +414,7 @@ public class InferenceResultApiController {
|
|||||||
downloadReq.setStartDate(strtDttm);
|
downloadReq.setStartDate(strtDttm);
|
||||||
downloadReq.setEndDate(endDttm);
|
downloadReq.setEndDate(endDttm);
|
||||||
downloadReq.setSearchValue(searchValue);
|
downloadReq.setSearchValue(searchValue);
|
||||||
downloadReq.setRequestUri("/api/inference/download-audit/download/" + uuid);
|
downloadReq.setRequestUri("/api/inference/download/" + uuid);
|
||||||
|
|
||||||
return ApiResponseDto.ok(inferenceResultService.getDownloadAudit(searchReq, downloadReq));
|
return ApiResponseDto.ok(inferenceResultService.getDownloadAudit(searchReq, downloadReq));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,4 +20,15 @@ public class AsyncConfig {
|
|||||||
ex.initialize();
|
ex.initialize();
|
||||||
return ex;
|
return ex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean(name = "auditLogExecutor")
|
||||||
|
public Executor auditLogExecutor() {
|
||||||
|
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
|
||||||
|
exec.setCorePoolSize(2);
|
||||||
|
exec.setMaxPoolSize(8);
|
||||||
|
exec.setQueueCapacity(2000);
|
||||||
|
exec.setThreadNamePrefix("auditlog-");
|
||||||
|
exec.initialize();
|
||||||
|
return exec;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user