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.ktbackend/src/main/kotlin/com/taskowolf/attachments/domain/events/AttachmentAddedEvent.ktbackend/src/main/kotlin/com/taskowolf/attachments/domain/events/AttachmentRemovedEvent.ktbackend/src/main/kotlin/com/taskowolf/attachments/application/AttachmentService.ktbackend/src/main/kotlin/com/taskowolf/attachments/application/StorageService.ktbackend/src/main/kotlin/com/taskowolf/attachments/api/AttachmentController.ktbackend/src/main/kotlin/com/taskowolf/attachments/api/dto/AttachmentResponse.ktbackend/src/main/resources/db/migration/V9__create_attachments.sql
Extension Points¶
- S3 storage: Replace
StorageServicewith an S3-backed implementation exposing the samestore(MultipartFile),load(String), anddelete(String)methods. Wire it via@Primaryor a Spring profile-specific@Bean. The domain (AttachmentService) calls only these three methods and is not affected by the swap. Configure the active profile inapplication-s3.yml. - Storage root path: Controlled by the
taskowolf.attachment.pathproperty (default./data/attachments). Override inapplication.ymlor as an environment variable.
Common Pitfalls¶
- DO NOT return raw filesystem paths in API responses.
AttachmentResponsedeliberately omitsstoredName; clients must use the/downloadendpoint to fetch the file. - File size limits are enforced by Spring's
multipartconfiguration (spring.servlet.multipart.max-file-size,max-request-size). Do not bypass these limits by reading a rawInputStreamoutside ofMultipartFile. StorageService.store()generates a UUID-basedstoredNameto prevent filename collisions; do not usefilename(the original client name) as the storage key.StorageService.load()validates that the resolved path does not escape the configured root (path traversal guard); anystoredNamecontaining..results inNotFoundException.- Deletion is a two-step operation:
storageService.delete()removes the physical file, thenattachmentRepository.delete()removes the DB record. IfstorageService.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; mocksAttachmentRepository,StorageService,IssueService,ProjectService,DomainEventPublisher. UsesMockMultipartFilefor file inputs. Verifies: upload stores file, saves attachment with correct fields, publishesAttachmentAddedEvent; delete by uploader callsstorageService.deleteandattachmentRepository.delete; delete by project admin succeeds;ForbiddenExceptionfor non-uploader non-admin.StorageServiceTest— uses JUnit 5@TempDirfor an isolated filesystem root; no mocks. Verifies:storesaves file and returns name ending with original extension;loadreturns readableResource;loadthrowsNotFoundExceptionfor missing file;deleteremoves the file;loadrejects../etc/passwdpath traversal.