Skip to content

Module: attachments

Purpose

Handles file upload and download for issues. Stores files on the local filesystem at a configurable root path. Designed with a storage layer (StorageService) that acts as the adapter between the domain and the physical store, enabling a future swap to S3 or other object storage without changing the domain or API layers.


Entities Owned

Entity Table Key Fields
Attachment attachments issueId UUID FK→issues NOT NULL, uploaderId UUID FK→users NOT NULL, filename VARCHAR(255) NOT NULL (original name from upload), storedName VARCHAR(255) NOT NULL (UUID-prefixed name used on disk), contentType VARCHAR(127) NOT NULL, size BIGINT NOT NULL

storedName is generated by StorageService.store() as {UUID}.{extension} and is never the same as filename. It is not included in API responses.


DB Schema

attachments (V9)

CREATE TABLE attachments (
    id            UUID         NOT NULL PRIMARY KEY,
    issue_id      UUID         NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
    uploader_id   UUID         NOT NULL REFERENCES users(id),
    filename      VARCHAR(255) NOT NULL,
    stored_name   VARCHAR(255) NOT NULL,
    content_type  VARCHAR(127) NOT NULL,
    size          BIGINT       NOT NULL,
    created_at    TIMESTAMP    NOT NULL,
    updated_at    TIMESTAMP    NOT NULL
);

No unique constraint on filename — duplicate original filenames are allowed; storedName (UUID-based) is unique on the filesystem.


API Endpoints

Method Path Auth Description
POST /api/v1/projects/{key}/issues/{issueKey}/attachments USER Upload file; Content-Type: multipart/form-data, form field file; returns 201 with AttachmentResponse
GET /api/v1/projects/{key}/issues/{issueKey}/attachments USER List all attachments for the issue
DELETE /api/v1/projects/{key}/issues/{issueKey}/attachments/{attachmentId} USER Delete attachment; allowed for uploader or project admin; returns 204
GET /api/v1/projects/{key}/issues/{issueKey}/attachments/{attachmentId}/download USER Serve file with Content-Disposition: attachment and correct Content-Type

AttachmentResponse fields: id, issueId, uploaderId, filename, contentType, size, createdAt. storedName is intentionally excluded.


Events Emitted

Event Published by Payload
AttachmentAddedEvent AttachmentService.upload() attachment: Attachment, issue: Issue
AttachmentRemovedEvent AttachmentService.delete() attachment: Attachment, issue: Issue

AttachmentAddedEvent is consumed by ActivityService.onAttachmentAdded() to write an ATTACHMENT_ADDED activity record. AttachmentRemovedEvent is not currently consumed; reserved for future cleanup or audit hooks.


Events Consumed

None. The attachments module does not subscribe to events from other modules.


Key Files

  • backend/src/main/kotlin/com/taskowolf/attachments/domain/Attachment.kt
  • backend/src/main/kotlin/com/taskowolf/attachments/domain/events/AttachmentAddedEvent.kt
  • backend/src/main/kotlin/com/taskowolf/attachments/domain/events/AttachmentRemovedEvent.kt
  • backend/src/main/kotlin/com/taskowolf/attachments/application/AttachmentService.kt
  • backend/src/main/kotlin/com/taskowolf/attachments/application/StorageService.kt
  • backend/src/main/kotlin/com/taskowolf/attachments/api/AttachmentController.kt
  • backend/src/main/kotlin/com/taskowolf/attachments/api/dto/AttachmentResponse.kt
  • backend/src/main/resources/db/migration/V9__create_attachments.sql

Extension Points

  • S3 storage: Replace StorageService with an S3-backed implementation exposing the same store(MultipartFile), load(String), and delete(String) methods. Wire it via @Primary or a Spring profile-specific @Bean. The domain (AttachmentService) calls only these three methods and is not affected by the swap. Configure the active profile in application-s3.yml.
  • Storage root path: Controlled by the taskowolf.attachment.path property (default ./data/attachments). Override in application.yml or as an environment variable.

Common Pitfalls

  • DO NOT return raw filesystem paths in API responses. AttachmentResponse deliberately omits storedName; clients must use the /download endpoint to fetch the file.
  • File size limits are enforced by Spring's multipart configuration (spring.servlet.multipart.max-file-size, max-request-size). Do not bypass these limits by reading a raw InputStream outside of MultipartFile.
  • StorageService.store() generates a UUID-based storedName to prevent filename collisions; do not use filename (the original client name) as the storage key.
  • StorageService.load() validates that the resolved path does not escape the configured root (path traversal guard); any storedName containing .. results in NotFoundException.
  • Deletion is a two-step operation: storageService.delete() removes the physical file, then attachmentRepository.delete() removes the DB record. If storageService.delete() throws, the DB record is not removed (transaction rolls back). Handle storage errors before calling the repository.

Example

AttachmentService.upload coordinates the storage adapter call, DB save, and event publication in a single transaction:

@Transactional
fun upload(projectKey: String, issueKey: String, file: MultipartFile, actor: User): Attachment {
    val issue = issueService.findByKey(projectKey, issueKey, actor.id)
    val storedName = storageService.store(file)
    val attachment = attachmentRepository.save(
        Attachment(
            issueId = issue.id,
            uploaderId = actor.id,
            filename = file.originalFilename ?: "unknown",
            storedName = storedName,
            contentType = file.contentType ?: "application/octet-stream",
            size = file.size
        )
    )
    eventPublisher.publish(AttachmentAddedEvent(attachment, issue))
    return attachment
}

StorageService.store generates a UUID-based name to avoid collisions and prevents path traversal on load:

fun store(file: MultipartFile): String {
    val extension = file.originalFilename
        ?.substringAfterLast('.', "")
        ?.let { if (it.isNotBlank()) ".$it" else "" }
        ?: ""
    val storedName = "${UUID.randomUUID()}$extension"
    val target = rootPath.resolve(storedName)
    file.transferTo(target)
    return storedName
}

Test Patterns

  • AttachmentServiceTest — pure unit test with MockK; mocks AttachmentRepository, StorageService, IssueService, ProjectService, DomainEventPublisher. Uses MockMultipartFile for file inputs. Verifies: upload stores file, saves attachment with correct fields, publishes AttachmentAddedEvent; delete by uploader calls storageService.delete and attachmentRepository.delete; delete by project admin succeeds; ForbiddenException for non-uploader non-admin.
  • StorageServiceTest — uses JUnit 5 @TempDir for an isolated filesystem root; no mocks. Verifies: store saves file and returns name ending with original extension; load returns readable Resource; load throws NotFoundException for missing file; delete removes the file; load rejects ../etc/passwd path traversal.