diff --git a/app-server/doky-front/webpack.config.js b/app-server/doky-front/webpack.config.js index 476b3ac0..db96bea8 100644 --- a/app-server/doky-front/webpack.config.js +++ b/app-server/doky-front/webpack.config.js @@ -27,7 +27,7 @@ module.exports = (env, argv) => { }, devServer: { historyApiFallback: true, - port: 10001 + port: 10010 }, resolve: { alias: { diff --git a/app-server/src/main/kotlin/org/hkurh/doky/documents/DocumentFacade.kt b/app-server/src/main/kotlin/org/hkurh/doky/documents/DocumentFacade.kt index feb86fc1..665c5cdc 100644 --- a/app-server/src/main/kotlin/org/hkurh/doky/documents/DocumentFacade.kt +++ b/app-server/src/main/kotlin/org/hkurh/doky/documents/DocumentFacade.kt @@ -62,4 +62,12 @@ interface DocumentFacade { */ @Throws(IOException::class) fun getFile(id: String): Resource? + + /** + * Generates a download token for the specified document ID and current user. + * + * @param id The ID of the document for which the download token is being generated. + * @return A string representing the generated download token. + */ + fun generateDownloadToken(id: String): String } diff --git a/app-server/src/main/kotlin/org/hkurh/doky/documents/DocumentService.kt b/app-server/src/main/kotlin/org/hkurh/doky/documents/DocumentService.kt index ed80733c..937e1aac 100644 --- a/app-server/src/main/kotlin/org/hkurh/doky/documents/DocumentService.kt +++ b/app-server/src/main/kotlin/org/hkurh/doky/documents/DocumentService.kt @@ -1,6 +1,7 @@ package org.hkurh.doky.documents import org.hkurh.doky.documents.db.DocumentEntity +import org.hkurh.doky.users.db.UserEntity /** * Represents a service for managing documents. @@ -36,4 +37,13 @@ interface DocumentService { * @param document The [DocumentEntity] object to be saved. */ fun save(document: DocumentEntity) + + /** + * Generates a download token for a specified document and user. + * + * @param user The [UserEntity] representing the user requesting the download token. + * @param document The [DocumentEntity] representing the document for which the token is generated. + * @return A string representing the generated download token. + */ + fun generateDownloadToken(user: UserEntity, document: DocumentEntity): String } diff --git a/app-server/src/main/kotlin/org/hkurh/doky/documents/api/DocumentApi.kt b/app-server/src/main/kotlin/org/hkurh/doky/documents/api/DocumentApi.kt index e8a7866a..9cf2211c 100644 --- a/app-server/src/main/kotlin/org/hkurh/doky/documents/api/DocumentApi.kt +++ b/app-server/src/main/kotlin/org/hkurh/doky/documents/api/DocumentApi.kt @@ -21,16 +21,30 @@ import java.io.IOException interface DocumentApi { @Operation(summary = "Upload file to document entry") @ApiResponses( - ApiResponse(responseCode = "200", description = "File is uploaded and attached to document"), - ApiResponse(responseCode = "404", description = "Document with provided id does not exist")) + ApiResponse(responseCode = "200", description = "File is uploaded and attached to document"), + ApiResponse(responseCode = "404", description = "Document with provided id does not exist") + ) fun uploadFile(@RequestBody file: MultipartFile, @PathVariable id: String): ResponseEntity<*>? + @Operation(summary = "Get token to authorize file download") + @ApiResponses( + ApiResponse(responseCode = "200", description = "Token is generated and provided"), + ApiResponse(responseCode = "404", description = "Document with provided id does not exist") + ) + fun getDownloadToken(@PathVariable id: String): ResponseEntity? + @Operation(summary = "Download file attached to document entry") @ApiResponses( - ApiResponse(responseCode = "200", description = "File is sent to client and can be downloaded", - content = [Content(mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE)], - headers = [Header(name = "attachment; filename=...")]), - ApiResponse(responseCode = "404", description = "No document with requested id, or no attached file for document")) + ApiResponse( + responseCode = "200", description = "File is sent to client and can be downloaded", + content = [Content(mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE)], + headers = [Header(name = "attachment; filename=...")] + ), + ApiResponse( + responseCode = "404", + description = "No document with requested id, or no attached file for document" + ) + ) @Throws(IOException::class) fun downloadFile(@PathVariable id: String): ResponseEntity<*>? @@ -47,9 +61,12 @@ interface DocumentApi { @Operation(summary = "Get metadata for document") @ApiResponses( - ApiResponse(responseCode = "200", description = "Document information is retrieved successfully", - content = [Content(schema = Schema(implementation = DocumentResponse::class))]), - ApiResponse(responseCode = "404", description = "No document with provided id")) + ApiResponse( + responseCode = "200", description = "Document information is retrieved successfully", + content = [Content(schema = Schema(implementation = DocumentResponse::class))] + ), + ApiResponse(responseCode = "404", description = "No document with provided id") + ) operator fun get(@PathVariable id: String): ResponseEntity<*>? @ApiResponses( diff --git a/app-server/src/main/kotlin/org/hkurh/doky/documents/api/DocumentController.kt b/app-server/src/main/kotlin/org/hkurh/doky/documents/api/DocumentController.kt index c9d95909..1aa8a52c 100644 --- a/app-server/src/main/kotlin/org/hkurh/doky/documents/api/DocumentController.kt +++ b/app-server/src/main/kotlin/org/hkurh/doky/documents/api/DocumentController.kt @@ -29,6 +29,12 @@ class DocumentController(private val documentFacade: DocumentFacade) : DocumentA return ResponseEntity.ok(null) } + @GetMapping("/{id}/download/token") + override fun getDownloadToken(@PathVariable id: String): ResponseEntity? { + val token = documentFacade.generateDownloadToken(id) + return ResponseEntity.ok(DownloadTokenResponse(token)) + } + @GetMapping("/{id}/download") @Trace(operationName = "document.download") @Throws(IOException::class) diff --git a/app-server/src/main/kotlin/org/hkurh/doky/documents/api/DownloadTokenResponse.kt b/app-server/src/main/kotlin/org/hkurh/doky/documents/api/DownloadTokenResponse.kt new file mode 100644 index 00000000..dbf63e08 --- /dev/null +++ b/app-server/src/main/kotlin/org/hkurh/doky/documents/api/DownloadTokenResponse.kt @@ -0,0 +1,8 @@ +package org.hkurh.doky.documents.api + +/** + * Represents the response containing a token used for authorizing a file download. + * + * @property token The token string used to authorize the download operation. + */ +class DownloadTokenResponse(val token: String = "") diff --git a/app-server/src/main/kotlin/org/hkurh/doky/documents/impl/DefaultDocumentFacade.kt b/app-server/src/main/kotlin/org/hkurh/doky/documents/impl/DefaultDocumentFacade.kt index 5532bb84..bed7d4e1 100644 --- a/app-server/src/main/kotlin/org/hkurh/doky/documents/impl/DefaultDocumentFacade.kt +++ b/app-server/src/main/kotlin/org/hkurh/doky/documents/impl/DefaultDocumentFacade.kt @@ -9,6 +9,7 @@ import org.hkurh.doky.documents.api.DocumentResponse import org.hkurh.doky.errorhandling.DokyNotFoundException import org.hkurh.doky.filestorage.FileStorageService import org.hkurh.doky.toDto +import org.hkurh.doky.users.UserService import org.springframework.core.io.Resource import org.springframework.core.io.UrlResource import org.springframework.stereotype.Component @@ -20,7 +21,8 @@ import java.io.IOException @Component class DefaultDocumentFacade( private val documentService: DocumentService, - private val fileStorageService: FileStorageService + private val fileStorageService: FileStorageService, + private val userService: UserService ) : DocumentFacade { override fun createDocument(name: String, description: String?): DocumentResponse? { val documentEntity = documentService.create(name, description) @@ -48,18 +50,14 @@ class DefaultDocumentFacade( @Transactional(propagation = Propagation.REQUIRED) override fun saveFile(file: MultipartFile, id: String) { - val document = documentService.find(id) - if (document != null) { - try { - val path = fileStorageService.store(file, document.filePath) - document.filePath = path - document.fileName = file.originalFilename - documentService.save(document) - } catch (e: IOException) { - throw RuntimeException(e) - } - } else { - throw DokyNotFoundException("Document with id [$id] not found") + val document = documentService.find(id) ?: throw DokyNotFoundException("Document with id [$id] not found") + try { + val path = fileStorageService.store(file, document.filePath) + document.filePath = path + document.fileName = file.originalFilename + documentService.save(document) + } catch (e: IOException) { + throw RuntimeException(e) } } @@ -83,6 +81,14 @@ class DefaultDocumentFacade( } } + override fun generateDownloadToken(id: String): String { + val user = userService.getCurrentUser() + val document = documentService.find(id) ?: throw DokyNotFoundException("Document with id [$id] not found") + LOG.debug { "Generate token for user [${user.id}] and document [$id]" } + val token = documentService.generateDownloadToken(user, document) + return token + } + companion object { private val LOG = KotlinLogging.logger {} } diff --git a/app-server/src/main/kotlin/org/hkurh/doky/documents/impl/DefaultDocumentService.kt b/app-server/src/main/kotlin/org/hkurh/doky/documents/impl/DefaultDocumentService.kt index 634171d4..6f0fb915 100644 --- a/app-server/src/main/kotlin/org/hkurh/doky/documents/impl/DefaultDocumentService.kt +++ b/app-server/src/main/kotlin/org/hkurh/doky/documents/impl/DefaultDocumentService.kt @@ -4,13 +4,19 @@ import io.github.oshai.kotlinlogging.KotlinLogging import org.hkurh.doky.documents.DocumentService import org.hkurh.doky.documents.db.DocumentEntity import org.hkurh.doky.documents.db.DocumentEntityRepository +import org.hkurh.doky.documents.db.DownloadDocumentTokenEntity +import org.hkurh.doky.documents.db.DownloadDocumentTokenEntityRepository +import org.hkurh.doky.security.JwtProvider import org.hkurh.doky.users.UserService +import org.hkurh.doky.users.db.UserEntity import org.springframework.stereotype.Service @Service class DefaultDocumentService( private val documentEntityRepository: DocumentEntityRepository, + private val downloadDocumentTokenEntityRepository: DownloadDocumentTokenEntityRepository, private val userService: UserService, + private val jwtProvider: JwtProvider ) : DocumentService { override fun create(name: String, description: String?): DocumentEntity { @@ -39,6 +45,19 @@ class DefaultDocumentService( documentEntityRepository.save(document) } + override fun generateDownloadToken(user: UserEntity, document: DocumentEntity): String { + val token = jwtProvider.generateDownloadToken(user) + val tokenEntity = + downloadDocumentTokenEntityRepository.findByUserAndDocument(user, document) ?: DownloadDocumentTokenEntity() + tokenEntity.apply { + this.user = user + this.document = document + this.token = token + } + downloadDocumentTokenEntityRepository.save(tokenEntity) + return token + } + companion object { private val LOG = KotlinLogging.logger {} } diff --git a/app-server/src/main/kotlin/org/hkurh/doky/security/JwtProvider.kt b/app-server/src/main/kotlin/org/hkurh/doky/security/JwtProvider.kt index 40167c4d..ff7eb245 100644 --- a/app-server/src/main/kotlin/org/hkurh/doky/security/JwtProvider.kt +++ b/app-server/src/main/kotlin/org/hkurh/doky/security/JwtProvider.kt @@ -3,6 +3,7 @@ package org.hkurh.doky.security import io.jsonwebtoken.Jwts import io.jsonwebtoken.SignatureAlgorithm import org.hkurh.doky.DokyApplication +import org.hkurh.doky.users.db.UserEntity import org.joda.time.DateTime import org.joda.time.DateTimeZone import org.springframework.stereotype.Component @@ -36,6 +37,27 @@ object JwtProvider { .compact() } + /** + * Generates a download token for a given user and document. The token is a JWT + * with an expiration time of 10 minutes and includes claims containing the user ID + * and document ID. + * + * @param user the user for whom the token is being generated + * @return a JWT download token as a string + */ + fun generateDownloadToken(user: UserEntity): String { + val currentTime = DateTime(DateTimeZone.getDefault()) + val expireTokenTime = currentTime.plusMinutes(10) + val claims = mapOf("user" to user.id) + return Jwts.builder() + .setId("dokyDownloadToken") + .setClaims(claims) + .setIssuedAt(currentTime.toDate()) + .setExpiration(expireTokenTime.toDate()) + .signWith(DokyApplication.SECRET_KEY_SPEC, SignatureAlgorithm.HS256) + .compact() + } + /** * Retrieves the username from the provided token. * diff --git a/app-server/src/test/kotlin/org/hkurh/doky/documents/impl/DefaultDocumentFacadeTest.kt b/app-server/src/test/kotlin/org/hkurh/doky/documents/impl/DefaultDocumentFacadeTest.kt index d643b56a..bf255761 100644 --- a/app-server/src/test/kotlin/org/hkurh/doky/documents/impl/DefaultDocumentFacadeTest.kt +++ b/app-server/src/test/kotlin/org/hkurh/doky/documents/impl/DefaultDocumentFacadeTest.kt @@ -6,6 +6,7 @@ import org.hkurh.doky.documents.api.DocumentRequest import org.hkurh.doky.documents.db.DocumentEntity import org.hkurh.doky.errorhandling.DokyNotFoundException import org.hkurh.doky.filestorage.FileStorageService +import org.hkurh.doky.users.UserService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -23,7 +24,8 @@ class DefaultDocumentFacadeTest : DokyUnitTest { private var documentService: DocumentService = mock() private var fileStorageService: FileStorageService = mock() - private var documentFacade = DefaultDocumentFacade(documentService, fileStorageService) + private var userService: UserService = mock() + private var documentFacade = DefaultDocumentFacade(documentService, fileStorageService, userService) @BeforeEach fun setUp() { diff --git a/persistence/src/main/kotlin/org/hkurh/doky/documents/db/DownloadDocumentTokenEntity.kt b/persistence/src/main/kotlin/org/hkurh/doky/documents/db/DownloadDocumentTokenEntity.kt new file mode 100644 index 00000000..a2942ce0 --- /dev/null +++ b/persistence/src/main/kotlin/org/hkurh/doky/documents/db/DownloadDocumentTokenEntity.kt @@ -0,0 +1,43 @@ +package org.hkurh.doky.documents.db + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.JoinColumn +import jakarta.persistence.OneToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import org.hkurh.doky.users.db.UserEntity + +@Entity +@Table( + name = "download_document_tokens", + indexes = [Index(name = "idx_download_document_tokens_token", columnList = "token")], + uniqueConstraints = [ + UniqueConstraint(name = "uc_download_document_tokens_token", columnNames = ["token"]), + UniqueConstraint( + name = "uc_download_document_tokens_app_user_document", + columnNames = ["app_user", "document"] + )] +) +class DownloadDocumentTokenEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false, updatable = false) + var id: Long? = null + + @OneToOne + @JoinColumn(name = "app_user", nullable = false, unique = true) + lateinit var user: UserEntity + + @OneToOne + @JoinColumn(name = "document", nullable = false, unique = true) + lateinit var document: DocumentEntity + + @Column(name = "token", nullable = false, unique = true) + lateinit var token: String +} diff --git a/persistence/src/main/kotlin/org/hkurh/doky/documents/db/DownloadDocumentTokenEntityRepository.kt b/persistence/src/main/kotlin/org/hkurh/doky/documents/db/DownloadDocumentTokenEntityRepository.kt new file mode 100644 index 00000000..2c73c991 --- /dev/null +++ b/persistence/src/main/kotlin/org/hkurh/doky/documents/db/DownloadDocumentTokenEntityRepository.kt @@ -0,0 +1,19 @@ +package org.hkurh.doky.documents.db + +import org.hkurh.doky.users.db.UserEntity +import org.springframework.data.jpa.repository.JpaSpecificationExecutor +import org.springframework.data.repository.CrudRepository + + +/** + * Repository interface for managing [DownloadDocumentTokenEntity] objects. + * Provides methods for querying and persisting [DownloadDocumentTokenEntity] instances in the database. + * Inherits functionality from [CrudRepository] for common CRUD operations and [JpaSpecificationExecutor] + * for specification-based queries. + */ +interface DownloadDocumentTokenEntityRepository : CrudRepository, + JpaSpecificationExecutor { + + fun findByUserAndDocument(user: UserEntity, document: DocumentEntity): DownloadDocumentTokenEntity? + +} diff --git a/persistence/src/main/resources/migration/mysql/V1_5__add_document_download_tokens.sql b/persistence/src/main/resources/migration/mysql/V1_5__add_document_download_tokens.sql new file mode 100644 index 00000000..ff1175da --- /dev/null +++ b/persistence/src/main/resources/migration/mysql/V1_5__add_document_download_tokens.sql @@ -0,0 +1,23 @@ +CREATE TABLE download_document_tokens +( + id BIGINT AUTO_INCREMENT NOT NULL, + app_user BIGINT NOT NULL, + document BIGINT NOT NULL, + token VARCHAR(255) NOT NULL, + CONSTRAINT pk_download_document_tokens PRIMARY KEY (id) +); + +ALTER TABLE download_document_tokens + ADD CONSTRAINT uc_download_document_tokens_app_user_document UNIQUE (app_user, document); + +ALTER TABLE download_document_tokens + ADD CONSTRAINT uc_download_document_tokens_token UNIQUE (token); + +CREATE INDEX idx_download_document_tokens_token ON download_document_tokens (token); + +ALTER TABLE download_document_tokens + ADD CONSTRAINT FK_DOWNLOAD_DOCUMENT_TOKENS_ON_APP_USER FOREIGN KEY (app_user) REFERENCES users (id); + +ALTER TABLE download_document_tokens + ADD CONSTRAINT FK_DOWNLOAD_DOCUMENT_TOKENS_ON_DOCUMENT FOREIGN KEY (document) REFERENCES documents (id); + diff --git a/persistence/src/main/resources/migration/sqlserver/V1_5__add_document_download_tokens.sql b/persistence/src/main/resources/migration/sqlserver/V1_5__add_document_download_tokens.sql new file mode 100644 index 00000000..9c6b8a89 --- /dev/null +++ b/persistence/src/main/resources/migration/sqlserver/V1_5__add_document_download_tokens.sql @@ -0,0 +1,28 @@ +CREATE TABLE download_document_tokens +( + id bigint IDENTITY (1, 1) NOT NULL, + app_user bigint NOT NULL, + document bigint NOT NULL, + token varchar(255) NOT NULL, + CONSTRAINT pk_download_document_tokens PRIMARY KEY (id) +) +GO + +ALTER TABLE download_document_tokens + ADD CONSTRAINT uc_download_document_tokens_app_user_document UNIQUE (app_user, document) +GO + +ALTER TABLE download_document_tokens + ADD CONSTRAINT uc_download_document_tokens_token UNIQUE (token) +GO + +CREATE NONCLUSTERED INDEX idx_download_document_tokens_token ON download_document_tokens (token) +GO + +ALTER TABLE download_document_tokens + ADD CONSTRAINT FK_DOWNLOAD_DOCUMENT_TOKENS_ON_APP_USER FOREIGN KEY (app_user) REFERENCES users (id) +GO + +ALTER TABLE download_document_tokens + ADD CONSTRAINT FK_DOWNLOAD_DOCUMENT_TOKENS_ON_DOCUMENT FOREIGN KEY (document) REFERENCES documents (id) +GO