Skip to content

Commit

Permalink
Feature/file owners (#177)
Browse files Browse the repository at this point in the history
* file owners

* upload history

* but setup-java action version

* endpoint descriptions

* add migration

* fix migration
  • Loading branch information
HubertBalcerzak authored Feb 5, 2021
1 parent e68977a commit 03de689
Show file tree
Hide file tree
Showing 11 changed files with 256 additions and 23 deletions.
4 changes: 4 additions & 0 deletions spring-app/src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ include::build/generated-snippets/GetFileDetails/auto-section.adoc[]
'''
include::build/generated-snippets/DeleteFile/auto-section.adoc[]

== User Controller
'''
include::build/generated-snippets/ListUserUploadHistory/auto-section.adoc[]

== Configuration Controller
include::build/generated-snippets/GetConfiguration/auto-section.adoc[]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class UploadController(
private val logger = LoggerFactory.getLogger(this::class.java)

/**
* Upload new file
* @param file Uploaded file content
*/
@PostMapping("/api/upload")
Expand All @@ -58,6 +59,7 @@ class UploadController(
}

/**
* Download a previously uploaded file
* @param fileKey File key obtained during upload
*/
@GetMapping("/u/{fileKey}")
Expand Down Expand Up @@ -97,29 +99,41 @@ class UploadController(
}
}

/**
* Verify requesting user's permission to modify this upload
*/
@PostMapping("/api/u/{fileKey}/verify")
fun verifyFileAccess(
@PathVariable fileKey: String,
@Validated @RequestBody operationDto: AuthorizedOperationDTO
@Validated @RequestBody operationDto: AuthorizedOperationDTO?,
principal: Principal?
): BasicResponseDTO {
val fileEntry = fileService.findFileEntry(FileKey(fileKey)) ?: throw NotFoundException()

if (!fileService.verifyFileAccess(fileEntry, FileAccessToken(operationDto.accessToken))) throw AccessDeniedException()
val user = userService.fromPrincipal(principal)
if (!fileService.verifyFileAccess(fileEntry, operationDto?.accessToken?.let { FileAccessToken(it) }, user))
throw AccessDeniedException()
return BasicResponseDTO()
}

@DeleteMapping("/api/u/{fileKey}")
fun deleteFile(
@PathVariable fileKey: String,
@Validated @RequestBody operationDto: AuthorizedOperationDTO
@Validated @RequestBody operationDto: AuthorizedOperationDTO?,
principal: Principal?
) {
val fileEntry = fileService.findFileEntry(FileKey(fileKey)) ?: throw NotFoundException()
val user = userService.fromPrincipal(principal)

if (!fileService.verifyFileAccess(fileEntry, FileAccessToken(operationDto.accessToken))) throw AccessDeniedException()
if (!fileService.verifyFileAccess(fileEntry, operationDto?.accessToken?.let { FileAccessToken(it) }, user))
throw AccessDeniedException()

fileService.deleteFile(fileEntry)
}

/**
* @return Uploaded file metadata
*/
@GetMapping("/api/u/{fileKey}/details")
fun getFileDetails(@PathVariable fileKey: String): FileDetailsDTO = fileService.getFileDetails(FileKey(fileKey))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package pl.starchasers.up.controller

import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import pl.starchasers.up.data.dto.upload.UploadHistoryEntryDTO
import pl.starchasers.up.exception.AccessDeniedException
import pl.starchasers.up.security.IsUser
import pl.starchasers.up.service.FileService
import pl.starchasers.up.service.UserService
import java.security.Principal

@RestController
@RequestMapping("/api/user")
class UserController(
val fileService: FileService,
val userService: UserService
) {

@GetMapping("/history")
@IsUser
fun listUserUploadHistory(principal: Principal, pageable: Pageable): Page<UploadHistoryEntryDTO> {
return fileService.getUploadHistory(
userService.fromPrincipal(principal) ?: throw AccessDeniedException(),
pageable
).map {
UploadHistoryEntryDTO(
it.filename.value,
it.createdDate,
it.permanent,
it.toDeleteDate,
it.size.value,
it.contentType.value,
it.key.value
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package pl.starchasers.up.data.dto.upload

import java.sql.Timestamp

data class UploadHistoryEntryDTO(
/**
* Name of the file
*/
val filename: String,
/**
* When was this file uploaded
*/
val uploadDate: Timestamp,
/**
* Whether this file will be automatically deleted
*/
val permanent: Boolean,
/**
* When will this file be automatically deleted. Null if temporary == false
*/
val deleteDate: Timestamp?,
/**
* File size in bytes
*/
val size: Long,
/**
* Mime type of this file. This string is used as content-type header when serving file to clients.
*/
val mimeType: String,
/**
* File key used in file link.
*/
val key: String
)
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,8 @@ class FileEntry(
val accessToken: FileAccessToken,

@Embedded
val size: FileSize
val size: FileSize,

@ManyToOne(fetch = FetchType.LAZY)
val owner: User?
)
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,8 @@ class User(

@Embedded
@AttributeOverride(name = "value", column = Column(name = "maxFileLifetime"))
var maxFileLifetime: Milliseconds = Milliseconds(0)
var maxFileLifetime: Milliseconds = Milliseconds(0),

@OneToMany(fetch = FetchType.LAZY, mappedBy = "owner")
val files: MutableSet<FileEntry> = mutableSetOf()
)
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package pl.starchasers.up.repository

import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import pl.starchasers.up.data.model.FileEntry
import pl.starchasers.up.data.model.User
import pl.starchasers.up.data.value.FileKey

interface FileEntryRepository : JpaRepository<FileEntry, Long> {
Expand All @@ -22,4 +25,6 @@ interface FileEntryRepository : JpaRepository<FileEntry, Long> {
"""
)
fun findExpiredFiles(): Set<FileEntry>

fun findAllByOwner(owner: User, pageable: Pageable): Page<FileEntry>
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package pl.starchasers.up.service

import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import pl.starchasers.up.data.dto.upload.FileDetailsDTO
import pl.starchasers.up.data.dto.upload.UploadCompleteResponseDTO
Expand All @@ -19,17 +21,25 @@ import javax.transaction.Transactional

interface FileService {

fun createFile(tmpFile: InputStream, filename: Filename, contentType: ContentType, size: FileSize, user: User?): UploadCompleteResponseDTO
fun createFile(
tmpFile: InputStream,
filename: Filename,
contentType: ContentType,
size: FileSize,
user: User?
): UploadCompleteResponseDTO

fun verifyFileAccess(fileEntry: FileEntry, accessToken: FileAccessToken): Boolean
fun verifyFileAccess(fileEntry: FileEntry, accessToken: FileAccessToken?, user: User?): Boolean

fun verifyFileAccess(fileKey: FileKey, accessToken: FileAccessToken): Boolean
fun verifyFileAccess(fileKey: FileKey, accessToken: FileAccessToken?, user: User?): Boolean

fun findFileEntry(fileKey: FileKey): FileEntry?

fun getFileDetails(fileKey: FileKey): FileDetailsDTO

fun deleteFile(fileEntry: FileEntry)

fun getUploadHistory(user: User, pageable: Pageable): Page<FileEntry>
}

@Service
Expand All @@ -55,7 +65,11 @@ class FileServiceImpl(
): UploadCompleteResponseDTO {
val actualContentType = when {
contentType.value.isBlank() -> ContentType("application/octet-stream")
contentType.value == "text/plain" -> ContentType("text/plain; charset=" + charsetDetectionService.detect(tmpFile))
contentType.value == "text/plain" -> ContentType(
"text/plain; charset=" + charsetDetectionService.detect(
tmpFile
)
)
else -> contentType
}
val personalLimit: FileSize = user?.maxTemporaryFileSize
Expand All @@ -78,21 +92,23 @@ class FileServiceImpl(
toDeleteDate,
false,
accessToken,
size
size,
user
)

fileEntryRepository.save(fileEntry)

return UploadCompleteResponseDTO(key.value, accessToken.value, toDeleteDate)
}

override fun verifyFileAccess(fileEntry: FileEntry, accessToken: FileAccessToken): Boolean =
fileEntry.accessToken == accessToken
override fun verifyFileAccess(fileEntry: FileEntry, accessToken: FileAccessToken?, user: User?): Boolean {
return (user != null && fileEntry.owner == user) || fileEntry.accessToken == accessToken
}

override fun verifyFileAccess(fileKey: FileKey, accessToken: FileAccessToken): Boolean =
override fun verifyFileAccess(fileKey: FileKey, accessToken: FileAccessToken?, user: User?): Boolean =
fileEntryRepository
.findExistingFileByKey(fileKey)
?.let { it.accessToken == accessToken } ?: false
?.let { verifyFileAccess(it, accessToken, user) } ?: false

override fun findFileEntry(fileKey: FileKey): FileEntry? = fileEntryRepository.findExistingFileByKey(fileKey)

Expand All @@ -114,5 +130,9 @@ class FileServiceImpl(
fileStorageService.deleteFile(fileEntry)
}

override fun getUploadHistory(user: User, pageable: Pageable): Page<FileEntry> {
return fileEntryRepository.findAllByOwner(user, pageable)
}

private fun generateFileAccessToken(): FileAccessToken = FileAccessToken(util.secureAlphanumericRandomString(128))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
start transaction;

alter table file_entry
add column owner_id bigint(20) default null,
add key `ix_file_entry__owner_id` (owner_id),
add constraint `fk_user__file_entry` foreign key (owner_id) references `user` (`id`);

commit;
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,8 @@ internal class UploadControllerTest : JpaTestBase() {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class VerifyFileAccess(
@Autowired val fileService: FileService,
@Autowired val fileEntryRepository: FileEntryRepository
@Autowired val fileEntryRepository: FileEntryRepository,
@Autowired val userService: UserService
) : MockMvcTestBase() {
private fun verifyRequestPath(key: String): Path = Path("/api/u/$key/verify")
private val content = "example content"
Expand All @@ -262,7 +263,7 @@ internal class UploadControllerTest : JpaTestBase() {
Filename("filename.txt"),
ContentType("text/plain"),
FileSize(content.byteInputStream().readAllBytes().size.toLong()),
null
userService.getUser(Username("root"))
).key

fileAccessToken = fileEntryRepository.findExistingFileByKey(FileKey(fileKey))?.accessToken?.value
Expand All @@ -285,6 +286,29 @@ internal class UploadControllerTest : JpaTestBase() {
}
}

@Test
fun `Given valid owner and no token, should return 200`() {
mockMvc.post(
path = verifyRequestPath(fileKey),
headers = HttpHeaders().contentTypeJson().authorization(getAdminAccessToken())
) {
isSuccess()
}
}

@Test
fun `Given invalid owner and valid token, should return 200`() {
mockMvc.post(
path = verifyRequestPath(fileKey),
headers = HttpHeaders().contentTypeJson(),
body = object {
val accessToken = fileAccessToken
}
) {
isSuccess()
}
}

@Test
fun `Given invalid access token, should return 403`() {
mockMvc.post(
Expand All @@ -299,13 +323,12 @@ internal class UploadControllerTest : JpaTestBase() {
}

@Test
fun `Given missing access token, should return 400`() {
fun `Given missing access token and no user, should return 403`() {
mockMvc.post(
path = verifyRequestPath(fileKey),
headers = HttpHeaders().contentTypeJson(),
body = object {}
) {
isError(HttpStatus.BAD_REQUEST)
isError(HttpStatus.FORBIDDEN)
}
}

Expand Down Expand Up @@ -374,7 +397,8 @@ internal class UploadControllerTest : JpaTestBase() {
inner class DeleteFile(
@Autowired val fileService: FileService,
@Autowired val uploadRepository: UploadRepository,
@Autowired val fileEntryRepository: FileEntryRepository
@Autowired val fileEntryRepository: FileEntryRepository,
@Autowired val userService: UserService
) : MockMvcTestBase() {

private fun getRequestPath(fileKey: String) = Path("/api/u/$fileKey")
Expand All @@ -386,13 +410,13 @@ internal class UploadControllerTest : JpaTestBase() {
Filename("file"),
ContentType("text/plain"),
FileSize(fileContent.length.toLong()),
null
userService.getUser(Username("root"))
)
}

@Test
@DocumentResponse
fun `Given valid request, should delete file`() {
fun `Given valid access token, should delete file`() {
val response = createTestFile()
mockMvc.delete(
path = getRequestPath(response.key),
Expand All @@ -408,6 +432,18 @@ internal class UploadControllerTest : JpaTestBase() {
assertNull(fileEntryRepository.findExistingFileByKey(FileKey(response.key)))
}

@Test
fun `Given valid owner, should delete file`() {
val response = createTestFile()

mockMvc.delete(
path = getRequestPath(response.key),
headers = HttpHeaders().contentTypeJson().authorization(getAdminAccessToken())
) {
isSuccess()
}
}

@Test
fun `Given wrong access token, should return 403`() {
val response = createTestFile()
Expand Down
Loading

0 comments on commit 03de689

Please sign in to comment.