package com.estateunified.panel import com.google.gson.Gson import com.google.gson.reflect.TypeToken import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption import java.util.UUID internal data class ListingDocumentDto( val id: String = UUID.randomUUID().toString().take(12), val listingId: String, val ownerUserId: String, val storedName: String, val originalName: String = "", val mime: String = "application/pdf", val sizeBytes: Long = 0, val titleRu: String = "", val kind: String = "GENERAL", val sortOrder: Int = 0, val uploadedAtIso: String = java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(java.time.LocalDateTime.now()), ) internal class ListingDocumentsStore( private val gson: Gson, private val metaFile: Path, private val docsRoot: Path, ) { private val lock = Any() fun absolutePath(): String = metaFile.toAbsolutePath().normalize().toString() fun docsDir(): Path = docsRoot fun forListing(listingId: String): List = synchronized(lock) { load().filter { it.listingId == listingId }.sortedWith( compareBy { it.sortOrder }.thenBy { it.uploadedAtIso }, ) } fun get(id: String): ListingDocumentDto? = synchronized(lock) { load().firstOrNull { it.id == id } } fun saveBytes( listingId: String, ownerUserId: String, originalName: String, mime: String, bytes: ByteArray, titleRu: String = "", kind: String = "GENERAL", ): ListingDocumentDto { val ext = extOf(originalName, mime).lowercase() val storedName = "${UUID.randomUUID().toString().take(16)}.$ext" val dir = docsRoot.resolve(safeSegment(listingId)) Files.createDirectories(dir) Files.write(dir.resolve(storedName), bytes) synchronized(lock) { val list = load().toMutableList() val maxOrder = list.filter { it.listingId == listingId }.maxOfOrNull { it.sortOrder } ?: -1 val dto = ListingDocumentDto( listingId = listingId, ownerUserId = ownerUserId, storedName = storedName, originalName = originalName, mime = mime, sizeBytes = bytes.size.toLong(), titleRu = titleRu.ifBlank { originalName.substringBeforeLast('.', originalName) }, kind = kind.ifBlank { "GENERAL" }, sortOrder = maxOrder + 1, ) list.add(0, dto) persist(list) return dto } } fun delete(id: String): Boolean = synchronized(lock) { val list = load().toMutableList() val idx = list.indexOfFirst { it.id == id } if (idx < 0) return@synchronized false val cur = list[idx] list.removeAt(idx) runCatching { Files.deleteIfExists(docsRoot.resolve(safeSegment(cur.listingId)).resolve(cur.storedName)) } persist(list) true } fun bytesOf(listingId: String, storedName: String): ByteArray? { val path = docsRoot.resolve(safeSegment(listingId)).resolve(safeSegment(storedName)) if (!Files.isRegularFile(path)) return null return runCatching { Files.readAllBytes(path) }.getOrNull() } fun mimeFor(storedName: String): String { val lc = storedName.lowercase() return when { lc.endsWith(".pdf") -> "application/pdf" lc.endsWith(".doc") -> "application/msword" lc.endsWith(".docx") -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document" lc.endsWith(".xls") -> "application/vnd.ms-excel" lc.endsWith(".xlsx") -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" lc.endsWith(".png") -> "image/png" lc.endsWith(".webp") -> "image/webp" lc.endsWith(".jpg") || lc.endsWith(".jpeg") -> "image/jpeg" else -> "application/octet-stream" } } private fun load(): MutableList { if (!Files.isRegularFile(metaFile)) return mutableListOf() val text = Files.readString(metaFile, StandardCharsets.UTF_8) if (text.isBlank()) return mutableListOf() return runCatching { gson.fromJson>(text, listType) ?: mutableListOf() }.getOrElse { mutableListOf() } } private fun persist(list: List) { Files.createDirectories(metaFile.toAbsolutePath().parent) val tmp = metaFile.resolveSibling(metaFile.fileName.toString() + ".tmp") Files.writeString(tmp, gson.toJson(list), StandardCharsets.UTF_8) Files.move(tmp, metaFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) } private fun extOf(originalName: String, mime: String): String { val dot = originalName.lastIndexOf('.') if (dot > 0 && dot < originalName.length - 1) { val ext = originalName.substring(dot + 1).lowercase() if (ext.length in 2..5 && ext.all { it.isLetterOrDigit() }) return ext } return when (mime.lowercase()) { "application/pdf" -> "pdf" "application/msword" -> "doc" "application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> "docx" "image/png" -> "png" "image/webp" -> "webp" "image/jpeg" -> "jpg" else -> "bin" } } private fun safeSegment(s: String): String = s.replace(Regex("[^A-Za-z0-9._-]"), "_").take(120) companion object { private val listType = object : TypeToken>() {}.type val allowedMimes: Set = setOf( "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "image/jpeg", "image/png", "image/webp", ) const val MAX_BYTES: Long = 16L * 1024L * 1024L } }