Uploading and tag searching now working
This commit is contained in:
parent
1a1594bcc1
commit
52bf9b1898
2
.gitignore
vendored
2
.gitignore
vendored
@ -31,3 +31,5 @@ build/
|
|||||||
|
|
||||||
### VS Code ###
|
### VS Code ###
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
images/
|
@ -4,7 +4,6 @@ import org.springframework.beans.factory.annotation.Autowired
|
|||||||
import org.springframework.data.domain.Page
|
import org.springframework.data.domain.Page
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.security.crypto.codec.Hex
|
|
||||||
import org.springframework.stereotype.Controller
|
import org.springframework.stereotype.Controller
|
||||||
import org.springframework.ui.Model
|
import org.springframework.ui.Model
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
@ -12,8 +11,9 @@ import org.springframework.web.bind.annotation.PostMapping
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.multipart.MultipartFile
|
import org.springframework.web.multipart.MultipartFile
|
||||||
import java.io.File
|
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
import java.util.*
|
||||||
|
import javax.validation.constraints.NotEmpty
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
class BaseController
|
class BaseController
|
||||||
@ -46,24 +46,33 @@ class ImageController
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun getGalleryPage(
|
fun getGalleryPage(
|
||||||
@RequestParam(defaultValue = "1") pageNumber: Int,
|
@RequestParam(name = "page", defaultValue = "1") pageNumber: Int,
|
||||||
@RequestParam tags: String?,
|
@RequestParam tags: String?,
|
||||||
model: Model
|
model: Model
|
||||||
): String {
|
): String {
|
||||||
val page = PageRequest.of(pageNumber - 1, 20)
|
val page = PageRequest.of(pageNumber - 1, 20)
|
||||||
val images : Page<Image>? = when (tags) {
|
val images: Page<Image> = when (tags) {
|
||||||
null -> imageRepository.findAll(page) // No tags were given to search for
|
null -> imageRepository.findAll(page) // No tags were given to search for
|
||||||
|
"" -> imageRepository.findAll(page)
|
||||||
else -> {
|
else -> {
|
||||||
val tagData = tags.split(" ") // Tags arrive separated by spaces, tags themselves cannot contain spaces
|
val distinctTags = tags.split(" ").distinct()
|
||||||
.distinct() // Eliminate duplicates
|
val tagData = distinctTags.mapNotNull { tagRepository.findByTagIs(it) } // Try to get actual tag objects
|
||||||
.mapNotNull { tagRepository.findByTagIs(it) } // Try to get actual tag objects
|
|
||||||
when {
|
when {
|
||||||
tagData.isEmpty() -> null // No tags existed with the specified search terms
|
tagData.isEmpty() -> null // No tags existed with the specified search terms
|
||||||
else -> imageRepository.findByTags(tagData, page)
|
tagData.size != distinctTags.size -> null // Error if an invalid tag was supplied
|
||||||
|
tagData.size == 1 -> {
|
||||||
|
// Simpler query for single tag searches
|
||||||
|
val result = imageRepository.findAllByTagsContaining(tagData[0], page)
|
||||||
|
if (result.isEmpty) null else result
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val result = imageRepository.findByTags(tagData, tagData.size.toLong(), page)
|
||||||
|
if (result.isEmpty) null else result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
model.addAttribute("images", images)
|
} ?: return "noresults"
|
||||||
|
model.addAttribute("imagePage", images)
|
||||||
return "gallery"
|
return "gallery"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,11 +85,10 @@ class UploadController
|
|||||||
val imageRepository: ImageRepository,
|
val imageRepository: ImageRepository,
|
||||||
val tagRepository: TagRepository,
|
val tagRepository: TagRepository,
|
||||||
val userRepository: UserRepository,
|
val userRepository: UserRepository,
|
||||||
val imageConfigurationProperties: ImageConfigurationProperties
|
val imageConfigurationProperties: ImageConfigurationProperties,
|
||||||
|
val storage: FileSystemStorage
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val digest: MessageDigest = MessageDigest.getInstance("SHA-512/256")
|
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun showUploadPage(): String = "upload"
|
fun showUploadPage(): String = "upload"
|
||||||
|
|
||||||
@ -88,17 +96,27 @@ class UploadController
|
|||||||
fun uploadFile(
|
fun uploadFile(
|
||||||
@AuthenticationPrincipal userDetails: CustomUserDetails,
|
@AuthenticationPrincipal userDetails: CustomUserDetails,
|
||||||
@RequestParam file: MultipartFile,
|
@RequestParam file: MultipartFile,
|
||||||
@RequestParam tags: List<String>
|
@RequestParam @NotEmpty tags: String
|
||||||
): String {
|
): String {
|
||||||
if (file.isEmpty) return "upload" // TODO: Show error on page, can't upload nothing
|
if (file.isEmpty) return "upload" // TODO: Show error on page, can't upload nothing
|
||||||
val extension = File(file.originalFilename!!).extension
|
val extension = java.io.File(file.originalFilename!!).extension
|
||||||
if (!imageConfigurationProperties.types.contains(extension)) return "upload" // TODO: Show error on page, unrecognised file type
|
if (!imageConfigurationProperties.types.contains(extension)) return "upload" // TODO: Show error on page, unrecognised file type
|
||||||
val user = userRepository.findByName(userDetails.username)!!
|
val user = userRepository.findByName(userDetails.username)!!
|
||||||
val hash: String = Hex.encode(digest.digest(file.bytes)).toString()
|
val hash: String = generateFileHash(file)
|
||||||
val tagData = tags.map { tagRepository.findByTagIs(it) ?: tagRepository.save(Tag(it)) }.toMutableSet()
|
val tagData = tags.split(" ").map { tagRepository.findByTagIs(it) ?: tagRepository.save(Tag(it)) }.toMutableSet()
|
||||||
val outputFile = File(imageConfigurationProperties.directory, "$hash.$extension")
|
storage.addImageFile(hash, extension, file.bytes)
|
||||||
outputFile.writeBytes(file.bytes)
|
|
||||||
imageRepository.save(Image("$hash.$extension", user, tagData))
|
imageRepository.save(Image("$hash.$extension", user, tagData))
|
||||||
return "upload" // TODO: Show success on page
|
return "upload" // TODO: Show success on page
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val digest: MessageDigest = MessageDigest.getInstance("SHA-512/256")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a URL safe string representing the hash of a file
|
||||||
|
*/
|
||||||
|
fun generateFileHash(file: MultipartFile): String = Base64
|
||||||
|
.getUrlEncoder()
|
||||||
|
.encodeToString(
|
||||||
|
digest.digest(file.bytes)
|
||||||
|
)
|
@ -4,6 +4,8 @@ import com.fasterxml.jackson.annotation.JsonIgnore
|
|||||||
import javax.persistence.Column
|
import javax.persistence.Column
|
||||||
import javax.persistence.Entity
|
import javax.persistence.Entity
|
||||||
import javax.persistence.FetchType
|
import javax.persistence.FetchType
|
||||||
|
import javax.persistence.GeneratedValue
|
||||||
|
import javax.persistence.GenerationType
|
||||||
import javax.persistence.Id
|
import javax.persistence.Id
|
||||||
import javax.persistence.JoinColumn
|
import javax.persistence.JoinColumn
|
||||||
import javax.persistence.JoinTable
|
import javax.persistence.JoinTable
|
||||||
@ -28,14 +30,14 @@ open class User(
|
|||||||
inverseJoinColumns = [JoinColumn(name = "role_id", referencedColumnName = "id")]
|
inverseJoinColumns = [JoinColumn(name = "role_id", referencedColumnName = "id")]
|
||||||
)
|
)
|
||||||
open var roles: MutableSet<Role> = mutableSetOf(),
|
open var roles: MutableSet<Role> = mutableSetOf(),
|
||||||
@Id open var id: Long = -1
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) open var id: Long = -1
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
open class Role(
|
open class Role(
|
||||||
@Column(unique = true)
|
@Column(unique = true)
|
||||||
open var name: String = "",
|
open var name: String = "",
|
||||||
@Id open var id: Long = -1
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) open var id: Long = -1
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@ -52,7 +54,7 @@ open class Image(
|
|||||||
inverseJoinColumns = [JoinColumn(name = "tag_id", referencedColumnName = "id")]
|
inverseJoinColumns = [JoinColumn(name = "tag_id", referencedColumnName = "id")]
|
||||||
)
|
)
|
||||||
open var tags: MutableSet<Tag> = mutableSetOf(),
|
open var tags: MutableSet<Tag> = mutableSetOf(),
|
||||||
@Id open var id: Long = -1
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) open var id: Long = -1
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@ -60,5 +62,5 @@ open class Tag(
|
|||||||
@Column(unique = true)
|
@Column(unique = true)
|
||||||
@Pattern(regexp = "[a-zA-Z0-9_]*") // Only allow alphanumeric and underscores
|
@Pattern(regexp = "[a-zA-Z0-9_]*") // Only allow alphanumeric and underscores
|
||||||
open var tag: String = "",
|
open var tag: String = "",
|
||||||
@Id open var id: Long = -1
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) open var id: Long = -1
|
||||||
)
|
)
|
27
src/main/kotlin/uk/co/neviyn/booru/FileSystemStorage.kt
Normal file
27
src/main/kotlin/uk/co/neviyn/booru/FileSystemStorage.kt
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package uk.co.neviyn.booru
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
interface FileSystemStorage {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a file to the filesystem with [name] and file [extension]
|
||||||
|
*/
|
||||||
|
fun addImageFile(name: String, extension: String, data: ByteArray)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class FileSystemStorageService
|
||||||
|
@Autowired constructor(
|
||||||
|
val imageConfigurationProperties: ImageConfigurationProperties
|
||||||
|
) : FileSystemStorage {
|
||||||
|
|
||||||
|
override fun addImageFile(name: String, extension: String, data: ByteArray) {
|
||||||
|
val outputFile = File(imageConfigurationProperties.directory, "$name.$extension")
|
||||||
|
outputFile.writeBytes(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -13,8 +13,14 @@ interface UserRepository : CrudRepository<User, Long> {
|
|||||||
interface RoleRepository : CrudRepository<Role, Long>
|
interface RoleRepository : CrudRepository<Role, Long>
|
||||||
|
|
||||||
interface ImageRepository : JpaRepository<Image, Long> {
|
interface ImageRepository : JpaRepository<Image, Long> {
|
||||||
@Query("select i from Image i where ?1 in (i.tags)")
|
//@Query("select i from Image i join i.tags t where t in ?1 group by i.id having count(i.id) = ?2")
|
||||||
fun findByTags(tags: List<Tag>, pageable: Pageable) : Page<Image>
|
@Query(
|
||||||
|
value = "select * from booru.image i where i.id in (select image_id from booru.tag_image ti where ti.tag_id in ?1 group by ti.image_id having count(ti.image_id) = ?2)",
|
||||||
|
nativeQuery = true
|
||||||
|
)
|
||||||
|
fun findByTags(tagIDs: List<Tag>, tagCount: Long, pageable: Pageable): Page<Image>
|
||||||
|
|
||||||
|
fun findAllByTagsContaining(tag: Tag, pageable: Pageable): Page<Image>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TagRepository : CrudRepository<Tag, Long> {
|
interface TagRepository : CrudRepository<Tag, Long> {
|
||||||
|
@ -5,6 +5,14 @@
|
|||||||
<title>Gallery</title>
|
<title>Gallery</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-2" th:each="image : ${imagePage.content}">
|
||||||
|
<!--/*@thymesVar id="image" type="uk.co.neviyn.booru.Image"*/-->
|
||||||
|
<img class="img-thumbnail" th:src="'/i/' + ${image.filename}" alt=""/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
@ -13,24 +13,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<p class="lead text-center">
|
<p class="text-center">
|
||||||
Image board.
|
<a href="/gallery" class="text-decoration-none">Browse all</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<form class="col-6 text-center" action="/gallery">
|
<form class="col-6 text-center" action="/gallery">
|
||||||
<label for="imageSearch"></label>
|
|
||||||
<input class="form-control" id="imageSearch" pattern="[a-zA-Z0-9\s]*" placeholder="Ex: blue_eyes smile" type="search" name="tags">
|
<input class="form-control" id="imageSearch" pattern="[a-zA-Z0-9\s]*" placeholder="Ex: blue_eyes smile" type="search" name="tags">
|
||||||
<div class="d-grid col-4 mx-auto">
|
<button class="btn btn-primary mt-1 w-25" type="submit">Search</button>
|
||||||
<button class="btn btn-primary mt-1" type="submit">Search</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<p class="text-center">
|
<p class="text-center lead">
|
||||||
Serving <span th:text="${count}"></span> images.
|
<small class="text-muted">Serving </small><span th:text="${count}"></span> <small class="text-muted">images.</small>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
17
src/main/resources/templates/noresults.html
Normal file
17
src/main/resources/templates/noresults.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta th:replace="fragments :: header"/>
|
||||||
|
<title>No Images Found</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col text-center">
|
||||||
|
<p>Couldn't find any images with those tags.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -12,11 +12,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<form action="/" class="mb-3 col-6 text-center" enctype="multipart/form-data" method="POST">
|
<form action="/upload" class="mb-3 col-6 text-center" enctype="multipart/form-data" method="POST">
|
||||||
|
<input th:name="${_csrf.parameterName}" type="hidden" th:value="${_csrf.token}"/>
|
||||||
<label class="form-label" for="formFile">Image file</label>
|
<label class="form-label" for="formFile">Image file</label>
|
||||||
<input class="form-control" id="formFile" name="file" type="file" th:accept="${@imageConfigurationProperties.typeListForFormFilter()}">
|
<input class="form-control" id="formFile" name="file" type="file" th:accept="${@imageConfigurationProperties.typeListForFormFilter()}">
|
||||||
<label class="form-label" for="formTags">Image tags</label>
|
<label class="form-label" for="formTags">Image tags</label>
|
||||||
<input class="form-control" id="formTags" placeholder="Tags separated by spaces" type="text">
|
<input class="form-control" id="formTags" placeholder="Tags separated by spaces" type="text" name="tags">
|
||||||
<button type="submit" class="btn btn-primary">Submit</button>
|
<button type="submit" class="btn btn-primary">Submit</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user