FileStore.save 에 중복 업로드 방지를 추가하세요. 파일 내용의 SHA-256 을 스트리밍 중에 계산해(DigestInputStream 또는 MessageDigest.update) 메타에 4번째 줄로 기록하고, 같은 해시의 파일이 이미 있으면 새 파일을 저장하지 않고 기존 StoredFile 을 돌려주세요(임시 파일은 삭제). 데모에서 par-0.txt 를 두 번 올려 같은 id 가 나오는지 확인하세요.
// FileStore.save 안: 복사 루프를 DigestInputStream 으로 감싼다
MessageDigest md = MessageDigest.getInstance("SHA-256");
try (OutputStream out = new BufferedOutputStream(Files.newOutputStream(tmp));
InputStream in = new DigestInputStream(body, md)) {
byte[] b = new byte[8192];
int n;
while ((n = in.read(b)) > 0) { /* 기존 검증 로직 그대로 */ size += n; if (size > maxBytes) throw ...; out.write(b, 0, n); }
}
String hash = HexFormat.of().formatHex(md.digest());
StoredFile dup = findByHash(hash); // .meta 4번째 줄 비교
if (dup != null) { Files.deleteIfExists(tmp); return dup; }
Files.move(tmp, dest, StandardCopyOption.ATOMIC_MOVE);
Files.writeString(dir.resolve(id + ".meta"), name + "\n" + type + "\n" + size + "\n" + hash, StandardCharsets.UTF_8);
StoredFile findByHash(String hash) throws IOException {
for (StoredFile f : list()) {
String[] lines = Files.readString(dir.resolve(f.id() + ".meta"), StandardCharsets.UTF_8).split("\n");
if (lines.length > 3 && lines[3].equals(hash)) return f;
}
return null; // 데모 한계: 전체 스캔. 파일이 수만 개면 hash → id 인덱스(Map 또는 DB 유니크 컬럼)
}
// 출력: par-0.txt 두 번 업로드 → 같은 id, 목록 파일 수 변화 없음GET /files?format=json 에 페이징과 정렬을 붙이세요. ?page=0&size=20&sort=size,desc 를 받아 {"total":N,"page":0,"items":[...]} 를 돌려줍니다. sort 는 name, size 두 가지, 방향은 asc/desc. 잘못된 값은 400 을 반환하세요.
server.createContext("/files", ex -> {
Map<String, String> q = HtmlPages.query(ex.getRequestURI().getRawQuery());
try {
int page = Integer.parseInt(q.getOrDefault("page", "0"));
int size = Integer.parseInt(q.getOrDefault("size", "20"));
String[] sort = q.getOrDefault("sort", "name,asc").split(",");
Comparator<FileStore.StoredFile> cmp = switch (sort[0]) {
case "name" -> Comparator.comparing(FileStore.StoredFile::originalName);
case "size" -> Comparator.comparingLong(FileStore.StoredFile::size);
default -> throw new IllegalArgumentException("sort=" + sort[0]);
};
if (sort.length > 1 && sort[1].equals("desc")) cmp = cmp.reversed();
else if (sort.length > 1 && !sort[1].equals("asc")) throw new IllegalArgumentException("방향=" + sort[1]);
if (page < 0 || size <= 0 || size > 100) throw new IllegalArgumentException("page/size 범위");
List<FileStore.StoredFile> all = store.list();
List<FileStore.StoredFile> items = all.stream().sorted(cmp).skip((long) page * size).limit(size).toList();
HtmlPages.send(ex, 200, "application/json; charset=UTF-8",
"{\"total\":" + all.size() + ",\"page\":" + page + ",\"items\":" + HtmlPages.listJson(items) + "}");
} catch (IllegalArgumentException e) { // NumberFormatException 포함
HtmlPages.send(ex, 400, "text/plain; charset=UTF-8", "잘못된 파라미터: " + e.getMessage());
}
});
// 출력: GET /files?format=json&page=0&size=2&sort=size,desc
// {"total":10,"page":0,"items":[{"id":"...","name":"회원명단.csv","size":64,...},{"id":"...","name":"par-7.txt","size":6,...}]}
// GET /files?sort=date → 400 잘못된 파라미터: sort=dateDownloadHandler 에 조건부 요청을 붙이세요. 응답에 ETag: "<id>" 와 Last-Modified 를 넣고, 요청에 If-None-Match 가 같은 ETag 이면 본문 없이 304 Not Modified 를 반환합니다. Range 요청에 If-Range 가 있고 ETag 와 다르면 Range 를 무시하고 200 전체를 보내세요.
String etag = "\"" + f.id() + "\""; // 내용 불변(UUID = 버전) 이므로 id 가 곧 ETag
h.set("ETag", etag);
h.set("Last-Modified", DateTimeFormatter.RFC_1123_DATE_TIME.format(
Files.getLastModifiedTime(f.path()).toInstant().atZone(ZoneOffset.UTC)));
if (etag.equals(ex.getRequestHeaders().getFirst("If-None-Match"))) {
ex.sendResponseHeaders(304, -1); // 본문 없음
ex.close();
return;
}
String range = ex.getRequestHeaders().getFirst("Range");
String ifRange = ex.getRequestHeaders().getFirst("If-Range");
if (range != null && ifRange != null && !ifRange.equals(etag)) range = null; // 버전이 바뀌었으면 전체 전송
// 이후 기존 Range 처리 그대로
// 출력:
// GET (If-None-Match: "6089a292-...") → 304, 본문 없음
// GET (Range: bytes=0-9, If-Range: "다른값") → 200, 64바이트 전체
// GET (Range: bytes=0-9, If-Range: "6089a292-...") → 206, 10바이트