diff --git a/fiely-backend/build.gradle.kts b/fiely-backend/build.gradle.kts index e0a6318..0538cb3 100644 --- a/fiely-backend/build.gradle.kts +++ b/fiely-backend/build.gradle.kts @@ -1,6 +1,7 @@ plugins { kotlin("jvm") version "1.9.25" apply false kotlin("plugin.spring") version "1.9.25" apply false + kotlin("plugin.jpa") version "1.9.25" apply false id("org.springframework.boot") version "3.4.1" apply false id("io.spring.dependency-management") version "1.1.7" apply false } diff --git a/fiely-backend/fiely-core/build.gradle.kts b/fiely-backend/fiely-core/build.gradle.kts index b89ce3a..abdd442 100644 --- a/fiely-backend/fiely-core/build.gradle.kts +++ b/fiely-backend/fiely-core/build.gradle.kts @@ -1,6 +1,7 @@ plugins { kotlin("jvm") kotlin("plugin.spring") + kotlin("plugin.jpa") id("org.springframework.boot") id("io.spring.dependency-management") } @@ -45,15 +46,21 @@ dependencies { // --- Test plugin packaging --------------------------------------------------- // -// Build the fiely-auth-jwt plugin as a real JAR and drop it into +// Build the first-party plugins as real JARs and drop them into // `build/test-plugins/` before tests run, then expose that path to the test -// JVM via a system property. The end-to-end integration test points -// `fiely.plugins.dir` at this directory so PF4J actually discovers, resolves -// and starts the plugin — no stubs involved. +// JVM via a system property. Integration tests point `fiely.plugins.dir` at +// this directory so PF4J actually discovers, resolves and starts the plugins +// — no stubs involved. val copyTestPlugins = tasks.register("copyTestPlugins") { - val pluginJar = project(":plugins:fiely-auth-jwt").tasks.named("jar") - dependsOn(pluginJar) - from(pluginJar) + val pluginProjects = listOf( + ":plugins:fiely-auth-jwt", + ":plugins:fiely-storage-local", + ) + pluginProjects.forEach { path -> + val jar = project(path).tasks.named("jar") + dependsOn(jar) + from(jar) + } into(layout.buildDirectory.dir("test-plugins")) } diff --git a/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/auth/web/CurrentUserResolver.kt b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/auth/web/CurrentUserResolver.kt new file mode 100644 index 0000000..411634b --- /dev/null +++ b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/auth/web/CurrentUserResolver.kt @@ -0,0 +1,72 @@ +package cloud.fiely.auth.web + +import cloud.fiely.plugin.AuthProvider +import cloud.fiely.plugin.UserInfo +import jakarta.servlet.http.HttpServletRequest +import org.springframework.beans.factory.ObjectProvider +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Component +import java.util.UUID + +/** + * Extracts the authenticated principal from the inbound request. + * + * The flow mirrors `AuthController.me` — pull the `Authorization: Bearer …` + * token, iterate every registered [AuthProvider] until one recognises it — but + * also resolves the user's tenant_id from `auth_users`. Controllers that need + * "who is calling?" should depend on this component rather than re-implementing + * the token parsing. + * + * Fiely does not use Spring Security, so there is no `SecurityContext` or + * filter-based equivalent: callers pull the principal at the top of each + * handler and respond with 401 on null. + */ +@Component +class CurrentUserResolver( + private val authProviders: ObjectProvider, + private val jdbc: JdbcTemplate, +) { + /** Returns the authenticated user, or `null` if no valid token is present. */ + fun resolve(request: HttpServletRequest): AuthenticatedUser? { + val header = request.getHeader("Authorization") ?: return null + if (!header.startsWith(BEARER_PREFIX, ignoreCase = true)) return null + val token = header.substring(BEARER_PREFIX.length).trim() + if (token.isEmpty()) return null + + val user = authProviders.stream().toList() + .firstNotNullOfOrNull { it.getUserInfo(token) } + ?: return null + + val userId = runCatching { UUID.fromString(user.id) }.getOrNull() ?: return null + val tenantId = findTenantId(userId) ?: return null + + return AuthenticatedUser(info = user, userId = userId, tenantId = tenantId) + } + + private fun findTenantId(userId: UUID): UUID? { + val results = jdbc.queryForList( + "SELECT tenant_id FROM auth_users WHERE id = ?", + userId, + ) + val raw = results.firstOrNull()?.get("tenant_id") ?: return null + return when (raw) { + is UUID -> raw + is String -> runCatching { UUID.fromString(raw) }.getOrNull() + else -> null + } + } + + companion object { + private const val BEARER_PREFIX = "Bearer " + } +} + +/** + * The resolved principal for a request. [info] comes from the active + * [AuthProvider]; [tenantId] is resolved from `auth_users`. + */ +data class AuthenticatedUser( + val info: UserInfo, + val userId: UUID, + val tenantId: UUID, +) diff --git a/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/domain/FileEntity.kt b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/domain/FileEntity.kt new file mode 100644 index 0000000..43a7222 --- /dev/null +++ b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/domain/FileEntity.kt @@ -0,0 +1,76 @@ +package cloud.fiely.file.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.PrePersist +import jakarta.persistence.PreUpdate +import jakarta.persistence.Table +import java.time.OffsetDateTime +import java.util.UUID + +/** + * A node in the file tree — either a folder (`isFolder = true`, + * `storageId`/`storagePath` null) or a regular file (`isFolder = false`, + * `storageId`/`storagePath` set). The XOR invariant is enforced by the + * `files_folder_xor_blob` check constraint in V4__files.sql. + * + * Parentage is a plain self-reference — root nodes have `parentId = null`. + * Cascading delete at the DB level handles folder subtree removal; the service + * layer walks descendants first to delete blobs via the StorageProvider. + */ +@Entity +@Table(name = "files") +class FileEntity( + @Id + val id: UUID = UUID.randomUUID(), + + @Column(name = "tenant_id", nullable = false) + val tenantId: UUID, + + @Column(name = "owner_id", nullable = false) + val ownerId: UUID, + + @Column(name = "parent_id") + var parentId: UUID? = null, + + @Column(nullable = false, length = 255) + var name: String, + + @Column(name = "is_folder", nullable = false) + val isFolder: Boolean = false, + + @Column(name = "size_bytes", nullable = false) + var sizeBytes: Long = 0, + + @Column(name = "content_type", length = 255) + var contentType: String? = null, + + @Column(name = "storage_id", length = 64) + var storageId: String? = null, + + @Column(name = "storage_path") + var storagePath: String? = null, + + @Column(name = "current_version", nullable = false) + var currentVersion: Int = 1, + + @Column(name = "created_at", nullable = false) + val createdAt: OffsetDateTime = OffsetDateTime.now(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: OffsetDateTime = OffsetDateTime.now(), +) { + @PrePersist + fun onCreate() { + val now = OffsetDateTime.now() + // createdAt is val; only updatedAt needs a PrePersist touch-up to match + // the DB default. Hibernate will persist the already-set createdAt. + updatedAt = now + } + + @PreUpdate + fun onUpdate() { + updatedAt = OffsetDateTime.now() + } +} diff --git a/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/domain/FileMetadataEntity.kt b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/domain/FileMetadataEntity.kt new file mode 100644 index 0000000..d820ef3 --- /dev/null +++ b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/domain/FileMetadataEntity.kt @@ -0,0 +1,49 @@ +package cloud.fiely.file.domain + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable +import jakarta.persistence.EmbeddedId +import jakarta.persistence.Entity +import jakarta.persistence.PreUpdate +import jakarta.persistence.Table +import java.io.Serializable +import java.time.OffsetDateTime +import java.util.UUID + +/** + * Namespaced metadata document attached to a [FileEntity]. + * + * Each `(file_id, namespace)` pair holds a single JSON document. Producers + * pick their own namespace so user tags, extractor output (EXIF, PDF info), + * and AI-generated annotations don't collide. The DB stores `data` as TEXT + * for portability — see the V5 migration for the path to JSONB. + */ +@Entity +@Table(name = "file_metadata") +class FileMetadataEntity( + @EmbeddedId + val id: FileMetadataId, + + @Column(name = "data", columnDefinition = "TEXT", nullable = false) + var data: String, + + @Column(name = "created_at", nullable = false) + val createdAt: OffsetDateTime = OffsetDateTime.now(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: OffsetDateTime = OffsetDateTime.now(), +) { + @PreUpdate + fun onUpdate() { + updatedAt = OffsetDateTime.now() + } +} + +@Embeddable +data class FileMetadataId( + @Column(name = "file_id", nullable = false) + val fileId: UUID = UUID(0, 0), + + @Column(name = "namespace", nullable = false, length = 64) + val namespace: String = "", +) : Serializable diff --git a/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/domain/FileMetadataRepository.kt b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/domain/FileMetadataRepository.kt new file mode 100644 index 0000000..21afac9 --- /dev/null +++ b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/domain/FileMetadataRepository.kt @@ -0,0 +1,10 @@ +package cloud.fiely.file.domain + +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface FileMetadataRepository : JpaRepository { + fun findAllByIdFileId(fileId: UUID): List + fun deleteByIdFileIdAndIdNamespace(fileId: UUID, namespace: String): Long + fun deleteAllByIdFileIdIn(fileIds: Collection): Long +} diff --git a/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/domain/FileRepository.kt b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/domain/FileRepository.kt new file mode 100644 index 0000000..a5d2c4a --- /dev/null +++ b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/domain/FileRepository.kt @@ -0,0 +1,21 @@ +package cloud.fiely.file.domain + +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface FileRepository : JpaRepository { + fun findByIdAndOwnerId(id: UUID, ownerId: UUID): FileEntity? + + fun findAllByOwnerIdAndParentIdOrderByIsFolderDescNameAsc( + ownerId: UUID, + parentId: UUID?, + ): List + + fun findAllByOwnerIdAndParentIdIsNullOrderByIsFolderDescNameAsc( + ownerId: UUID, + ): List + + fun findAllByOwnerIdAndParentId(ownerId: UUID, parentId: UUID): List + + fun existsByOwnerIdAndParentIdAndName(ownerId: UUID, parentId: UUID?, name: String): Boolean +} diff --git a/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/service/FileMetadataService.kt b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/service/FileMetadataService.kt new file mode 100644 index 0000000..5e3141a --- /dev/null +++ b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/service/FileMetadataService.kt @@ -0,0 +1,98 @@ +package cloud.fiely.file.service + +import cloud.fiely.file.domain.FileMetadataEntity +import cloud.fiely.file.domain.FileMetadataId +import cloud.fiely.file.domain.FileMetadataRepository +import cloud.fiely.file.domain.FileRepository +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * Manages namespaced metadata documents attached to files. + * + * The namespace carves out write territories so producers don't clobber each + * other. `user` is the usual caller-facing namespace for tags/ratings/notes; + * extractors and AI plugins pick their own (typically their plugin id). + */ +@Service +class FileMetadataService( + private val files: FileRepository, + private val metadata: FileMetadataRepository, + private val objectMapper: ObjectMapper, +) { + fun list(ownerId: UUID, fileId: UUID): List { + requireOwned(ownerId, fileId) + return metadata.findAllByIdFileId(fileId).map(::toDocument) + } + + fun get(ownerId: UUID, fileId: UUID, namespace: String): MetadataDocument { + requireOwned(ownerId, fileId) + val ns = validateNamespace(namespace) + val row = metadata.findById(FileMetadataId(fileId, ns)).orElseThrow { + NotFoundException("No metadata in namespace '$ns' for this file") + } + return toDocument(row) + } + + @Transactional + fun put(ownerId: UUID, fileId: UUID, namespace: String, data: JsonNode): MetadataDocument { + requireOwned(ownerId, fileId) + val ns = validateNamespace(namespace) + val serialized = objectMapper.writeValueAsString(data) + val key = FileMetadataId(fileId, ns) + val existing = metadata.findById(key).orElse(null) + val saved = if (existing != null) { + existing.data = serialized + metadata.save(existing) + } else { + metadata.save(FileMetadataEntity(id = key, data = serialized)) + } + return toDocument(saved) + } + + @Transactional + fun delete(ownerId: UUID, fileId: UUID, namespace: String) { + requireOwned(ownerId, fileId) + val ns = validateNamespace(namespace) + val removed = metadata.deleteByIdFileIdAndIdNamespace(fileId, ns) + if (removed == 0L) throw NotFoundException("No metadata in namespace '$ns' for this file") + } + + private fun requireOwned(ownerId: UUID, fileId: UUID) { + files.findByIdAndOwnerId(fileId, ownerId) + ?: throw NotFoundException("File not found") + } + + private fun toDocument(row: FileMetadataEntity): MetadataDocument = MetadataDocument( + namespace = row.id.namespace, + data = objectMapper.readTree(row.data), + createdAt = row.createdAt.toString(), + updatedAt = row.updatedAt.toString(), + ) + + private fun validateNamespace(raw: String): String { + val ns = raw.trim() + if (ns.isEmpty()) throw BadRequestException("Namespace must not be blank") + if (ns.length > 64) throw BadRequestException("Namespace must be 64 chars or fewer") + if (!NAMESPACE_RE.matches(ns)) { + throw BadRequestException( + "Namespace may only contain letters, digits, '-', '_' and '.'", + ) + } + return ns + } + + data class MetadataDocument( + val namespace: String, + val data: JsonNode, + val createdAt: String, + val updatedAt: String, + ) + + companion object { + private val NAMESPACE_RE = Regex("""^[A-Za-z0-9][A-Za-z0-9._-]*$""") + } +} diff --git a/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/service/FileService.kt b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/service/FileService.kt new file mode 100644 index 0000000..d0fcc26 --- /dev/null +++ b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/service/FileService.kt @@ -0,0 +1,276 @@ +package cloud.fiely.file.service + +import cloud.fiely.file.domain.FileEntity +import cloud.fiely.file.domain.FileMetadataRepository +import cloud.fiely.file.domain.FileRepository +import cloud.fiely.plugin.FileReference +import cloud.fiely.plugin.StoragePath +import cloud.fiely.plugin.StorageProvider +import cloud.fiely.tenant.domain.TenantEntity +import cloud.fiely.tenant.domain.TenantRepository +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.io.InputStream +import java.util.UUID + +/** + * Orchestrates the file tree in the DB and delegates binary I/O to the + * active [StorageProvider] plugin. + * + * The StorageProvider bean is supplied lazily via [ObjectProvider] because + * PF4J injects plugin-contributed extensions *after* regular Spring beans are + * constructed — capturing a `List` at constructor time would + * race with plugin startup (same pattern as `AuthController`). + */ +@Service +class FileService( + private val files: FileRepository, + private val metadata: FileMetadataRepository, + private val tenants: TenantRepository, + private val storageProviders: ObjectProvider, + @Value("\${fiely.storage.provider:fiely-storage-local}") + private val activeStorageId: String, +) { + private val log = LoggerFactory.getLogger(FileService::class.java) + + // --- Queries ------------------------------------------------------------ + + fun list(ownerId: UUID, parentId: UUID?): List { + if (parentId != null) { + // Parent must exist and belong to the caller, else 404. + val parent = files.findByIdAndOwnerId(parentId, ownerId) + ?: throw NotFoundException("Folder not found") + if (!parent.isFolder) throw BadRequestException("Parent is not a folder") + return files.findAllByOwnerIdAndParentIdOrderByIsFolderDescNameAsc(ownerId, parentId) + } + return files.findAllByOwnerIdAndParentIdIsNullOrderByIsFolderDescNameAsc(ownerId) + } + + fun get(ownerId: UUID, id: UUID): FileEntity = + files.findByIdAndOwnerId(id, ownerId) ?: throw NotFoundException("File not found") + + // --- Mutations ---------------------------------------------------------- + + @Transactional + fun createFolder(ownerId: UUID, tenantId: UUID, parentId: UUID?, name: String): FileEntity { + val cleanName = validateName(name) + requireParentIsOwnedFolder(ownerId, parentId) + ensureUniqueName(ownerId, parentId, cleanName) + + val folder = FileEntity( + tenantId = tenantId, + ownerId = ownerId, + parentId = parentId, + name = cleanName, + isFolder = true, + ) + return files.save(folder) + } + + @Transactional + fun upload( + ownerId: UUID, + tenantId: UUID, + parentId: UUID?, + name: String, + contentType: String?, + size: Long, + content: InputStream, + ): FileEntity { + val cleanName = validateName(name) + requireParentIsOwnedFolder(ownerId, parentId) + ensureUniqueName(ownerId, parentId, cleanName) + + val tenant = tenants.findById(tenantId).orElseThrow { + NotFoundException("Tenant not found") + } + enforceUploadLimit(tenant, size) + + val provider = activeProvider() + val fileId = UUID.randomUUID() + val storagePath = StoragePath( + tenantId = tenantId.toString(), + userId = ownerId.toString(), + fileId = fileId.toString(), + version = 1, + ) + + val ref: FileReference = provider.store(storagePath, content, size) + + val entity = FileEntity( + id = fileId, + tenantId = tenantId, + ownerId = ownerId, + parentId = parentId, + name = cleanName, + isFolder = false, + sizeBytes = size, + contentType = contentType, + storageId = ref.storageId, + storagePath = ref.path, + currentVersion = 1, + ) + return files.save(entity) + } + + fun openForDownload(ownerId: UUID, id: UUID): Pair { + val file = get(ownerId, id) + if (file.isFolder) throw BadRequestException("Cannot download a folder") + val ref = FileReference( + storageId = file.storageId ?: throw IllegalStateException("File row missing storage_id"), + path = file.storagePath ?: throw IllegalStateException("File row missing storage_path"), + ) + val provider = providerFor(file.storageId!!) + ?: throw ServiceUnavailableException("Storage provider '${file.storageId}' is not available") + return file to provider.retrieve(ref) + } + + @Transactional + fun update(ownerId: UUID, id: UUID, newName: String?, newParentId: UUID?, moveToRoot: Boolean): FileEntity { + val file = get(ownerId, id) + + val targetName = newName?.let { validateName(it) } ?: file.name + val targetParent = when { + moveToRoot -> null + newParentId != null -> newParentId + else -> file.parentId + } + + if (targetParent != null) { + requireParentIsOwnedFolder(ownerId, targetParent) + if (targetParent == file.id) throw BadRequestException("Cannot move a folder into itself") + if (file.isFolder && isDescendantOf(ancestorId = file.id, candidateId = targetParent, ownerId = ownerId)) { + throw BadRequestException("Cannot move a folder into its own descendant") + } + } + + val nameOrParentChanged = targetName != file.name || targetParent != file.parentId + if (nameOrParentChanged) { + ensureUniqueName(ownerId, targetParent, targetName, excludeId = file.id) + } + + file.name = targetName + file.parentId = targetParent + return files.save(file) + } + + @Transactional + fun delete(ownerId: UUID, id: UUID) { + val root = get(ownerId, id) + + // Walk the subtree explicitly. We don't rely on DB ON DELETE CASCADE + // because the JPA entities don't declare @ManyToOne associations — + // so Hibernate's auto-generated DDL (used in H2-backed tests) carries + // no cascading FKs, even though the Flyway-managed Postgres schema + // does. Walking is also cheaper on large subtrees than a recursive + // CTE via cascade, and gives us a chance to ask every StorageProvider + // to drop its blobs before the rows vanish. + val subtree = mutableListOf() + collectAll(root, ownerId, subtree) + val blobs = subtree.filter { !it.isFolder } + + val providerCache = HashMap() + for (blob in blobs) { + val sid = blob.storageId ?: continue + val spath = blob.storagePath ?: continue + val provider = providerCache.getOrPut(sid) { providerFor(sid) } + if (provider == null) { + log.warn("Storage provider '{}' not available — leaving blob {} orphaned on delete", sid, blob.id) + continue + } + runCatching { provider.delete(FileReference(sid, spath)) } + .onFailure { log.warn("Failed to delete blob for file {}: {}", blob.id, it.message) } + } + + // Metadata FK points at files(id), so clear it before the file rows. + metadata.deleteAllByIdFileIdIn(subtree.map { it.id }) + // Delete children before parents to satisfy the self-FK. + files.deleteAll(subtree.asReversed()) + } + + // --- Internals ---------------------------------------------------------- + + private fun activeProvider(): StorageProvider { + return providerFor(activeStorageId) + ?: throw ServiceUnavailableException( + "Active storage provider '$activeStorageId' is not registered", + ) + } + + private fun providerFor(id: String): StorageProvider? = + storageProviders.stream().toList().firstOrNull { it.id == id } + + private fun enforceUploadLimit(tenant: TenantEntity, size: Long) { + if (size > tenant.maxUploadBytes) { + throw PayloadTooLargeException( + "Upload of $size bytes exceeds tenant limit of ${tenant.maxUploadBytes} bytes", + ) + } + } + + private fun requireParentIsOwnedFolder(ownerId: UUID, parentId: UUID?) { + if (parentId == null) return + val parent = files.findByIdAndOwnerId(parentId, ownerId) + ?: throw NotFoundException("Parent folder not found") + if (!parent.isFolder) throw BadRequestException("Parent is not a folder") + } + + private fun ensureUniqueName(ownerId: UUID, parentId: UUID?, name: String, excludeId: UUID? = null) { + val exists = files.existsByOwnerIdAndParentIdAndName(ownerId, parentId, name) + if (!exists) return + // If the caller is renaming/moving onto its own current location, allow it. + if (excludeId != null) { + val currentHolder = files.findAllByOwnerIdAndParentIdOrderByIsFolderDescNameAsc(ownerId, parentId) + .firstOrNull { it.name == name } + if (currentHolder?.id == excludeId) return + } + throw ConflictException("A file or folder named '$name' already exists here") + } + + private fun validateName(raw: String): String { + val trimmed = raw.trim() + if (trimmed.isEmpty()) throw BadRequestException("Name must not be blank") + if (trimmed.length > 255) throw BadRequestException("Name must be 255 chars or fewer") + if (trimmed.contains('/') || trimmed.contains('\\')) { + throw BadRequestException("Name must not contain path separators") + } + if (trimmed == "." || trimmed == "..") throw BadRequestException("Reserved name") + return trimmed + } + + private fun isDescendantOf(ancestorId: UUID, candidateId: UUID, ownerId: UUID): Boolean { + var cursor: UUID? = candidateId + val visited = HashSet() + while (cursor != null) { + if (!visited.add(cursor)) return false // cycle guard + if (cursor == ancestorId) return true + cursor = files.findByIdAndOwnerId(cursor, ownerId)?.parentId + } + return false + } + + /** + * Depth-first pre-order walk: parent appears before its children in [acc]. + * Callers doing a recursive delete should reverse the list so children go + * first and the self-FK is satisfied. + */ + private fun collectAll(node: FileEntity, ownerId: UUID, acc: MutableList) { + acc += node + if (node.isFolder) { + for (child in files.findAllByOwnerIdAndParentId(ownerId, node.id)) { + collectAll(child, ownerId, acc) + } + } + } +} + +// --- Exceptions the controller maps to HTTP statuses ------------------------ + +class NotFoundException(message: String) : RuntimeException(message) +class BadRequestException(message: String) : RuntimeException(message) +class ConflictException(message: String) : RuntimeException(message) +class PayloadTooLargeException(message: String) : RuntimeException(message) +class ServiceUnavailableException(message: String) : RuntimeException(message) diff --git a/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/web/FileController.kt b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/web/FileController.kt new file mode 100644 index 0000000..f1eb03a --- /dev/null +++ b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/web/FileController.kt @@ -0,0 +1,251 @@ +package cloud.fiely.file.web + +import cloud.fiely.auth.web.CurrentUserResolver +import cloud.fiely.auth.web.ErrorResponse +import cloud.fiely.file.service.FileService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.UUID + +/** + * REST endpoints for the file tree. + * + * Every endpoint authenticates the caller via [CurrentUserResolver] and + * scopes queries to that user's rows. There is no Spring Security filter — + * the pattern is consistent with [cloud.fiely.auth.web.AuthController]. + */ +@RestController +@RequestMapping("/api/files") +@Tag(name = "Files", description = "File tree management — upload, download, list, rename, move, delete.") +class FileController( + private val fileService: FileService, + private val currentUser: CurrentUserResolver, +) { + + // --- List & metadata ---------------------------------------------------- + + @GetMapping + @Operation( + summary = "List children of a folder (or root)", + description = "Returns immediate children ordered folders-first, then alphabetical. " + + "Omit `parentId` to list the root.", + security = [SecurityRequirement(name = "bearerAuth")], + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Children listed"), + ApiResponse(responseCode = "401", description = "Missing or invalid bearer token"), + ApiResponse(responseCode = "404", description = "Parent folder not found"), + ], + ) + fun list( + @Parameter(description = "Parent folder id, or null for root") + @RequestParam(required = false) parentId: UUID?, + request: HttpServletRequest, + ): ResponseEntity { + val user = currentUser.resolve(request) ?: return unauthorized() + val children = fileService.list(user.userId, parentId) + return ResponseEntity.ok(children.map(FileMetadataResponse::from)) + } + + @GetMapping("/{id}") + @Operation( + summary = "Get metadata for a single node", + security = [SecurityRequirement(name = "bearerAuth")], + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Metadata returned"), + ApiResponse(responseCode = "401", description = "Missing or invalid bearer token"), + ApiResponse(responseCode = "404", description = "Node not found"), + ], + ) + fun metadata(@PathVariable id: UUID, request: HttpServletRequest): ResponseEntity { + val user = currentUser.resolve(request) ?: return unauthorized() + val node = fileService.get(user.userId, id) + return ResponseEntity.ok(FileMetadataResponse.from(node)) + } + + // --- Upload / download -------------------------------------------------- + + @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @Operation( + summary = "Upload a file", + description = "Stores the uploaded bytes via the active StorageProvider and records " + + "metadata. Upload size is capped by the caller's tenant `max_upload_bytes` setting.", + security = [SecurityRequirement(name = "bearerAuth")], + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "File stored"), + ApiResponse(responseCode = "400", description = "Invalid name or parent"), + ApiResponse(responseCode = "401", description = "Missing or invalid bearer token"), + ApiResponse(responseCode = "404", description = "Parent folder not found"), + ApiResponse(responseCode = "409", description = "A node with that name already exists"), + ApiResponse(responseCode = "413", description = "Upload exceeds the tenant limit"), + ApiResponse(responseCode = "503", description = "No storage provider available"), + ], + ) + fun upload( + @RequestPart("file") file: MultipartFile, + @RequestParam(required = false) parentId: UUID?, + @RequestParam(required = false) name: String?, + request: HttpServletRequest, + ): ResponseEntity { + val user = currentUser.resolve(request) ?: return unauthorized() + val effectiveName = name?.takeIf { it.isNotBlank() } + ?: file.originalFilename?.substringAfterLast('/')?.substringAfterLast('\\') + ?: return ResponseEntity.badRequest().body(ErrorResponse("File name is required")) + + val stored = file.inputStream.use { input -> + fileService.upload( + ownerId = user.userId, + tenantId = user.tenantId, + parentId = parentId, + name = effectiveName, + contentType = file.contentType, + size = file.size, + content = input, + ) + } + return ResponseEntity.ok(FileMetadataResponse.from(stored)) + } + + @GetMapping("/{id}/content") + @Operation( + summary = "Download a file's content", + security = [SecurityRequirement(name = "bearerAuth")], + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "File bytes streamed"), + ApiResponse(responseCode = "400", description = "Target is a folder"), + ApiResponse(responseCode = "401", description = "Missing or invalid bearer token"), + ApiResponse(responseCode = "404", description = "File not found"), + ApiResponse(responseCode = "503", description = "Storage provider not available"), + ], + ) + fun download(@PathVariable id: UUID, request: HttpServletRequest): ResponseEntity { + val user = currentUser.resolve(request) + ?: return ResponseEntity.status(401).build() + val (file, stream) = fileService.openForDownload(user.userId, id) + val body = StreamingResponseBody { output -> + stream.use { it.copyTo(output) } + } + val encoded = URLEncoder.encode(file.name, StandardCharsets.UTF_8).replace("+", "%20") + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(file.contentType ?: MediaType.APPLICATION_OCTET_STREAM_VALUE)) + .contentLength(file.sizeBytes) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded") + .body(body) + } + + // --- Folders ------------------------------------------------------------ + + @PostMapping("/folder", consumes = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Create a folder", + security = [SecurityRequirement(name = "bearerAuth")], + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Folder created"), + ApiResponse(responseCode = "400", description = "Invalid name or parent"), + ApiResponse(responseCode = "401", description = "Missing or invalid bearer token"), + ApiResponse(responseCode = "404", description = "Parent folder not found"), + ApiResponse(responseCode = "409", description = "A node with that name already exists"), + ], + ) + fun createFolder( + @RequestBody body: CreateFolderRequest, + request: HttpServletRequest, + ): ResponseEntity { + val user = currentUser.resolve(request) ?: return unauthorized() + val folder = fileService.createFolder( + ownerId = user.userId, + tenantId = user.tenantId, + parentId = body.parentId, + name = body.name, + ) + return ResponseEntity.ok(FileMetadataResponse.from(folder)) + } + + // --- Rename / move / delete -------------------------------------------- + + @PatchMapping("/{id}", consumes = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Rename and/or move a node", + description = "Optional `name` renames; optional `parentId` moves. Set `moveToRoot=true` " + + "to move to the root (because `parentId=null` is ambiguous in JSON).", + security = [SecurityRequirement(name = "bearerAuth")], + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Updated"), + ApiResponse(responseCode = "400", description = "Invalid target"), + ApiResponse(responseCode = "401", description = "Missing or invalid bearer token"), + ApiResponse(responseCode = "404", description = "Node or target parent not found"), + ApiResponse(responseCode = "409", description = "Name collision at the target"), + ], + ) + fun update( + @PathVariable id: UUID, + @RequestBody body: UpdateFileRequest, + request: HttpServletRequest, + ): ResponseEntity { + val user = currentUser.resolve(request) ?: return unauthorized() + val updated = fileService.update( + ownerId = user.userId, + id = id, + newName = body.name, + newParentId = body.parentId, + moveToRoot = body.moveToRoot, + ) + return ResponseEntity.ok(FileMetadataResponse.from(updated)) + } + + @DeleteMapping("/{id}") + @Operation( + summary = "Delete a node (folders are deleted recursively)", + security = [SecurityRequirement(name = "bearerAuth")], + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "Deleted"), + ApiResponse(responseCode = "401", description = "Missing or invalid bearer token"), + ApiResponse(responseCode = "404", description = "Node not found"), + ], + ) + fun delete(@PathVariable id: UUID, request: HttpServletRequest): ResponseEntity { + val user = currentUser.resolve(request) ?: return unauthorized() + fileService.delete(user.userId, id) + return ResponseEntity.noContent().build() + } + + // --- Error mapping ------------------------------------------------------ + + private fun unauthorized(): ResponseEntity = + ResponseEntity.status(401).body(ErrorResponse("Missing or invalid bearer token")) +} diff --git a/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/web/FileDtos.kt b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/web/FileDtos.kt new file mode 100644 index 0000000..8625a30 --- /dev/null +++ b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/web/FileDtos.kt @@ -0,0 +1,51 @@ +package cloud.fiely.file.web + +import cloud.fiely.file.domain.FileEntity +import io.swagger.v3.oas.annotations.media.Schema +import java.time.OffsetDateTime +import java.util.UUID + +@Schema(description = "Metadata for a single file or folder.") +data class FileMetadataResponse( + val id: UUID, + val parentId: UUID?, + val name: String, + val isFolder: Boolean, + val sizeBytes: Long, + val contentType: String?, + val currentVersion: Int, + val createdAt: OffsetDateTime, + val updatedAt: OffsetDateTime, +) { + companion object { + fun from(file: FileEntity): FileMetadataResponse = FileMetadataResponse( + id = file.id, + parentId = file.parentId, + name = file.name, + isFolder = file.isFolder, + sizeBytes = file.sizeBytes, + contentType = file.contentType, + currentVersion = file.currentVersion, + createdAt = file.createdAt, + updatedAt = file.updatedAt, + ) + } +} + +@Schema(description = "Request to create a new folder.") +data class CreateFolderRequest( + val parentId: UUID? = null, + val name: String, +) + +@Schema(description = "Partial update — rename and/or move a node.") +data class UpdateFileRequest( + val name: String? = null, + val parentId: UUID? = null, + /** + * Discriminator because `parentId = null` is a legal target (the root). + * When `moveToRoot = true`, `parentId` is ignored and the node is moved + * to the root; otherwise `parentId` is only applied if present. + */ + val moveToRoot: Boolean = false, +) diff --git a/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/web/FileExceptionHandler.kt b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/web/FileExceptionHandler.kt new file mode 100644 index 0000000..b8f85b1 --- /dev/null +++ b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/web/FileExceptionHandler.kt @@ -0,0 +1,42 @@ +package cloud.fiely.file.web + +import cloud.fiely.auth.web.ErrorResponse +import cloud.fiely.file.service.BadRequestException +import cloud.fiely.file.service.ConflictException +import cloud.fiely.file.service.NotFoundException +import cloud.fiely.file.service.PayloadTooLargeException +import cloud.fiely.file.service.ServiceUnavailableException +import org.springframework.core.annotation.Order +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler + +/** + * Maps the service-layer exceptions thrown by the file domain to HTTP + * statuses. Scoped to `cloud.fiely.file.web` so it doesn't accidentally + * shadow behaviour for other controllers. + */ +@ControllerAdvice(basePackages = ["cloud.fiely.file.web"]) +@Order(0) +class FileExceptionHandler { + + @ExceptionHandler(NotFoundException::class) + fun onNotFound(e: NotFoundException): ResponseEntity = + ResponseEntity.status(404).body(ErrorResponse(e.message ?: "Not found")) + + @ExceptionHandler(BadRequestException::class) + fun onBadRequest(e: BadRequestException): ResponseEntity = + ResponseEntity.badRequest().body(ErrorResponse(e.message ?: "Bad request")) + + @ExceptionHandler(ConflictException::class) + fun onConflict(e: ConflictException): ResponseEntity = + ResponseEntity.status(409).body(ErrorResponse(e.message ?: "Conflict")) + + @ExceptionHandler(PayloadTooLargeException::class) + fun onTooLarge(e: PayloadTooLargeException): ResponseEntity = + ResponseEntity.status(413).body(ErrorResponse(e.message ?: "Payload too large")) + + @ExceptionHandler(ServiceUnavailableException::class) + fun onUnavailable(e: ServiceUnavailableException): ResponseEntity = + ResponseEntity.status(503).body(ErrorResponse(e.message ?: "Service unavailable")) +} diff --git a/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/web/FileMetadataController.kt b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/web/FileMetadataController.kt new file mode 100644 index 0000000..8c4f492 --- /dev/null +++ b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/file/web/FileMetadataController.kt @@ -0,0 +1,129 @@ +package cloud.fiely.file.web + +import cloud.fiely.auth.web.CurrentUserResolver +import cloud.fiely.auth.web.ErrorResponse +import cloud.fiely.file.service.FileMetadataService +import com.fasterxml.jackson.databind.JsonNode +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +/** + * Namespaced metadata endpoints attached to files. + * + * Each namespace holds a single JSON document. The `user` namespace is the + * usual home for caller-supplied tags/ratings/notes; extractor plugins and AI + * processors pick their own (typically their plugin id) so writes don't + * collide across producers. + */ +@RestController +@RequestMapping("/api/files/{fileId}/metadata") +@Tag(name = "Files", description = "Namespaced metadata attached to files.") +class FileMetadataController( + private val service: FileMetadataService, + private val currentUser: CurrentUserResolver, +) { + + @GetMapping + @Operation( + summary = "List all metadata namespaces for a file", + security = [SecurityRequirement(name = "bearerAuth")], + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Metadata listed"), + ApiResponse(responseCode = "401", description = "Missing or invalid bearer token"), + ApiResponse(responseCode = "404", description = "File not found"), + ], + ) + fun list( + @PathVariable fileId: UUID, + request: HttpServletRequest, + ): ResponseEntity { + val user = currentUser.resolve(request) ?: return unauthorized() + return ResponseEntity.ok(service.list(user.userId, fileId)) + } + + @GetMapping("/{namespace}") + @Operation( + summary = "Get the metadata document for a single namespace", + security = [SecurityRequirement(name = "bearerAuth")], + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Metadata returned"), + ApiResponse(responseCode = "401", description = "Missing or invalid bearer token"), + ApiResponse(responseCode = "404", description = "File or namespace not found"), + ], + ) + fun get( + @PathVariable fileId: UUID, + @PathVariable namespace: String, + request: HttpServletRequest, + ): ResponseEntity { + val user = currentUser.resolve(request) ?: return unauthorized() + return ResponseEntity.ok(service.get(user.userId, fileId, namespace)) + } + + @PutMapping("/{namespace}", consumes = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Upsert the metadata document for a namespace", + description = "The request body is stored verbatim as the namespace's current document.", + security = [SecurityRequirement(name = "bearerAuth")], + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Metadata stored"), + ApiResponse(responseCode = "400", description = "Invalid namespace or body"), + ApiResponse(responseCode = "401", description = "Missing or invalid bearer token"), + ApiResponse(responseCode = "404", description = "File not found"), + ], + ) + fun put( + @PathVariable fileId: UUID, + @PathVariable namespace: String, + @RequestBody body: JsonNode, + request: HttpServletRequest, + ): ResponseEntity { + val user = currentUser.resolve(request) ?: return unauthorized() + return ResponseEntity.ok(service.put(user.userId, fileId, namespace, body)) + } + + @DeleteMapping("/{namespace}") + @Operation( + summary = "Remove a namespace's metadata document", + security = [SecurityRequirement(name = "bearerAuth")], + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "Removed"), + ApiResponse(responseCode = "401", description = "Missing or invalid bearer token"), + ApiResponse(responseCode = "404", description = "File or namespace not found"), + ], + ) + fun delete( + @PathVariable fileId: UUID, + @PathVariable namespace: String, + request: HttpServletRequest, + ): ResponseEntity { + val user = currentUser.resolve(request) ?: return unauthorized() + service.delete(user.userId, fileId, namespace) + return ResponseEntity.noContent().build() + } + + private fun unauthorized(): ResponseEntity = + ResponseEntity.status(401).body(ErrorResponse("Missing or invalid bearer token")) +} diff --git a/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/tenant/domain/TenantEntity.kt b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/tenant/domain/TenantEntity.kt new file mode 100644 index 0000000..bf5499c --- /dev/null +++ b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/tenant/domain/TenantEntity.kt @@ -0,0 +1,41 @@ +package cloud.fiely.tenant.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.OffsetDateTime +import java.util.UUID + +/** + * A tenant owns users, files, and per-tenant configuration. All data in Fiely + * is scoped to a tenant; a default tenant is seeded by migration V3 for + * single-tenant deployments and integration tests. + */ +@Entity +@Table(name = "tenants") +class TenantEntity( + @Id + val id: UUID, + + @Column(nullable = false, unique = true, length = 64) + val slug: String, + + @Column(nullable = false, length = 255) + val name: String, + + /** Per-tenant cap on a single file upload, in bytes. Enforced by FileService. */ + @Column(name = "max_upload_bytes", nullable = false) + val maxUploadBytes: Long = 100L * 1024 * 1024, + + @Column(name = "created_at", nullable = false) + val createdAt: OffsetDateTime = OffsetDateTime.now(), + + @Column(name = "updated_at", nullable = false) + val updatedAt: OffsetDateTime = OffsetDateTime.now(), +) { + companion object { + /** Stable UUID of the default tenant seeded by V3__tenants.sql. */ + val DEFAULT_ID: UUID = UUID.fromString("00000000-0000-0000-0000-000000000001") + } +} diff --git a/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/tenant/domain/TenantRepository.kt b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/tenant/domain/TenantRepository.kt new file mode 100644 index 0000000..2ad3c44 --- /dev/null +++ b/fiely-backend/fiely-core/src/main/kotlin/cloud/fiely/tenant/domain/TenantRepository.kt @@ -0,0 +1,6 @@ +package cloud.fiely.tenant.domain + +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface TenantRepository : JpaRepository diff --git a/fiely-backend/fiely-core/src/main/resources/application.yml b/fiely-backend/fiely-core/src/main/resources/application.yml index 6eb4371..bc9d459 100644 --- a/fiely-backend/fiely-core/src/main/resources/application.yml +++ b/fiely-backend/fiely-core/src/main/resources/application.yml @@ -13,6 +13,13 @@ spring: enabled: true baseline-on-migrate: true locations: classpath:db/migration + servlet: + multipart: + # Transport-layer ceiling — per-tenant limits are enforced in FileService + # from the tenants.max_upload_bytes column. Raise this if you intend to + # set any tenant above 1 GiB. + max-file-size: ${FIELY_MAX_UPLOAD_SIZE:1GB} + max-request-size: ${FIELY_MAX_UPLOAD_SIZE:1GB} fiely: plugins: @@ -22,6 +29,13 @@ fiely: issuer: ${FIELY_JWT_ISSUER:fiely} access-token-ttl: ${FIELY_JWT_ACCESS_TTL:3600} refresh-token-ttl: ${FIELY_JWT_REFRESH_TTL:604800} + fiely-storage-local: + root: ${FIELY_STORAGE_LOCAL_ROOT:./data/blobs} + storage: + # Id of the StorageProvider plugin to write new uploads through. Existing + # rows keep their original storage_id so switching providers doesn't break + # downloads of previously-uploaded files. + provider: ${FIELY_STORAGE_PROVIDER:fiely-storage-local} server: port: 8080 diff --git a/fiely-backend/fiely-core/src/main/resources/db/migration/V3__tenants.sql b/fiely-backend/fiely-core/src/main/resources/db/migration/V3__tenants.sql new file mode 100644 index 0000000..db31f6f --- /dev/null +++ b/fiely-backend/fiely-core/src/main/resources/db/migration/V3__tenants.sql @@ -0,0 +1,31 @@ +-- V3__tenants.sql +-- Introduce tenants. Every auth_user belongs to exactly one tenant; per-tenant +-- settings (e.g. max_upload_bytes) live on this row. A single default tenant is +-- seeded so existing deployments keep working without manual backfill. +-- +-- The plugin-side UserRepository in fiely-auth-jwt inserts rows without setting +-- tenant_id; the column therefore carries a DB default pointing at the seeded +-- default tenant so plugin code keeps compiling. When explicit tenancy UX is +-- added, inserts will start supplying tenant_id and the default can be dropped. + +CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY, + slug VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + max_upload_bytes BIGINT NOT NULL DEFAULT 104857600, -- 100 MiB + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Seed a stable default tenant. The UUID is fixed so references from test +-- fixtures and migrations are stable across environments. +INSERT INTO tenants (id, slug, name) +VALUES ('00000000-0000-0000-0000-000000000001', 'default', 'Default') +ON CONFLICT (id) DO NOTHING; + +ALTER TABLE auth_users + ADD COLUMN IF NOT EXISTS tenant_id UUID + NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001' + REFERENCES tenants(id) ON DELETE RESTRICT; + +CREATE INDEX IF NOT EXISTS auth_users_tenant_idx ON auth_users (tenant_id); diff --git a/fiely-backend/fiely-core/src/main/resources/db/migration/V4__files.sql b/fiely-backend/fiely-core/src/main/resources/db/migration/V4__files.sql new file mode 100644 index 0000000..0335235 --- /dev/null +++ b/fiely-backend/fiely-core/src/main/resources/db/migration/V4__files.sql @@ -0,0 +1,38 @@ +-- V4__files.sql +-- Core file tree. A single table models both folders and files using a +-- self-referential parent_id. Binary content lives outside the database in a +-- StorageProvider plugin (e.g. fiely-storage-local); rows only carry the +-- metadata needed to resolve it — storage_id (which provider) and +-- storage_path (the opaque ref the provider returned on store). +-- +-- The folder-XOR-blob CHECK constraint enforces the invariant that folders +-- never carry storage refs and files always do. + +CREATE TABLE IF NOT EXISTS files ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + owner_id UUID NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, + parent_id UUID REFERENCES files(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + is_folder BOOLEAN NOT NULL DEFAULT FALSE, + size_bytes BIGINT NOT NULL DEFAULT 0, + content_type VARCHAR(255), + storage_id VARCHAR(64), + storage_path TEXT, + current_version INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT files_folder_xor_blob CHECK ( + (is_folder = TRUE AND storage_id IS NULL AND storage_path IS NULL) + OR (is_folder = FALSE AND storage_id IS NOT NULL AND storage_path IS NOT NULL) + ), + CONSTRAINT files_unique_name_per_parent UNIQUE (owner_id, parent_id, name) +); + +-- Postgres treats NULLs as distinct in UNIQUE, so the compound constraint above +-- doesn't catch sibling collisions at the root. A partial unique index does. +CREATE UNIQUE INDEX IF NOT EXISTS files_unique_root_name + ON files (owner_id, name) WHERE parent_id IS NULL; + +CREATE INDEX IF NOT EXISTS files_tenant_idx ON files (tenant_id); +CREATE INDEX IF NOT EXISTS files_owner_parent_idx ON files (owner_id, parent_id); diff --git a/fiely-backend/fiely-core/src/main/resources/db/migration/V5__file_metadata.sql b/fiely-backend/fiely-core/src/main/resources/db/migration/V5__file_metadata.sql new file mode 100644 index 0000000..05af141 --- /dev/null +++ b/fiely-backend/fiely-core/src/main/resources/db/migration/V5__file_metadata.sql @@ -0,0 +1,21 @@ +-- V5__file_metadata.sql +-- Namespaced metadata for files. Each file can carry multiple metadata +-- documents, one per namespace — `user` for caller-supplied tags/ratings, +-- `exif`/`pdf` etc. for extractors, `fiely-ai-*` for model outputs. This +-- keeps producers from stepping on each other. +-- +-- `data` is TEXT holding a JSON document. Migrating to JSONB later is a +-- one-liner (`ALTER ... TYPE JSONB USING data::jsonb`) once we need GIN +-- indexing — the test matrix currently includes H2 which doesn't speak +-- JSONB, so TEXT keeps the application portable. + +CREATE TABLE IF NOT EXISTS file_metadata ( + file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE, + namespace VARCHAR(64) NOT NULL, + data TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (file_id, namespace) +); + +CREATE INDEX IF NOT EXISTS file_metadata_namespace_idx ON file_metadata (namespace); diff --git a/fiely-backend/fiely-core/src/test/kotlin/cloud/fiely/file/web/FileControllerTest.kt b/fiely-backend/fiely-core/src/test/kotlin/cloud/fiely/file/web/FileControllerTest.kt new file mode 100644 index 0000000..da72045 --- /dev/null +++ b/fiely-backend/fiely-core/src/test/kotlin/cloud/fiely/file/web/FileControllerTest.kt @@ -0,0 +1,289 @@ +package cloud.fiely.file.web + +import cloud.fiely.auth.web.AuthenticatedUser +import cloud.fiely.auth.web.CurrentUserResolver +import cloud.fiely.file.domain.FileRepository +import cloud.fiely.plugin.FileReference +import cloud.fiely.plugin.StoragePath +import cloud.fiely.plugin.StorageProvider +import cloud.fiely.plugin.UserInfo +import cloud.fiely.tenant.domain.TenantEntity +import cloud.fiely.tenant.domain.TenantRepository +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.servlet.http.HttpServletRequest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.context.annotation.Primary +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.delete +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.multipart +import org.springframework.test.web.servlet.patch +import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.io.InputStream +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Exercises the [FileController] + [cloud.fiely.file.service.FileService] pair + * against in-memory Postgres-compatible H2. + * + * We stub [CurrentUserResolver] to dodge the `auth_users` lookup — this test + * covers file-tree semantics, not auth. The end-to-end plumbing (real plugin, + * real token) lives in [cloud.fiely.file.web.FilePluginIntegrationTest]. + * + * An in-memory [StorageProvider] stands in for the real plugin. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(FileControllerTest.FileTestConfig::class) +class FileControllerTest { + + @Autowired private lateinit var mockMvc: MockMvc + @Autowired private lateinit var objectMapper: ObjectMapper + @Autowired private lateinit var tenants: TenantRepository + @Autowired private lateinit var files: FileRepository + @Autowired private lateinit var storage: InMemoryStorage + @Autowired private lateinit var resolverStub: StubCurrentUserResolver + + private val userId = UUID.fromString("11111111-1111-1111-1111-111111111111") + private val tenantId = TenantEntity.DEFAULT_ID + + @BeforeEach + fun seed() { + storage.clear() + files.deleteAll() + tenants.deleteAll() + tenants.save( + TenantEntity( + id = tenantId, + slug = "default", + name = "Default", + maxUploadBytes = 1024, // small so we can test 413 cheaply + ), + ) + resolverStub.set( + AuthenticatedUser( + info = UserInfo( + id = userId.toString(), + username = "alice", + email = null, + displayName = null, + ), + userId = userId, + tenantId = tenantId, + ), + ) + } + + @Test + fun `upload, list, download, delete round-trip`() { + val payload = "hello fiely".toByteArray() + val uploaded = mockMvc.multipart("/api/files") { + file(MockMultipartFile("file", "hello.txt", "text/plain", payload)) + }.andExpect { + status { isOk() } + jsonPath("$.name") { value("hello.txt") } + jsonPath("$.isFolder") { value(false) } + jsonPath("$.sizeBytes") { value(payload.size) } + jsonPath("$.contentType") { value("text/plain") } + }.andReturn() + val id = objectMapper.readTree(uploaded.response.contentAsString)["id"].asText() + + mockMvc.get("/api/files").andExpect { + status { isOk() } + jsonPath("$[0].name") { value("hello.txt") } + } + + // StreamingResponseBody is async — need an asyncDispatch to drain it. + val asyncResult = mockMvc.get("/api/files/$id/content").andExpect { + status { isOk() } + header { string("Content-Disposition", "attachment; filename*=UTF-8''hello.txt") } + }.andReturn() + mockMvc.perform(asyncDispatch(asyncResult)) + .andExpect(status().isOk) + .andExpect(content().bytes(payload)) + + mockMvc.delete("/api/files/$id").andExpect { status { isNoContent() } } + mockMvc.get("/api/files").andExpect { + status { isOk() } + jsonPath("$.length()") { value(0) } + } + } + + @Test + fun `upload without a bearer token is rejected`() { + resolverStub.set(null) + mockMvc.multipart("/api/files") { + file(MockMultipartFile("file", "x.txt", "text/plain", "x".toByteArray())) + }.andExpect { status { isUnauthorized() } } + } + + @Test + fun `upload over tenant limit returns 413`() { + val big = ByteArray(2048) { 'a'.code.toByte() } // > 1024 limit + mockMvc.multipart("/api/files") { + file(MockMultipartFile("file", "big.bin", "application/octet-stream", big)) + }.andExpect { + status { isPayloadTooLarge() } + jsonPath("$.error") { value(org.hamcrest.Matchers.containsString("exceeds tenant limit")) } + } + } + + @Test + fun `upload with duplicate name in same parent returns 409`() { + val payload = "a".toByteArray() + mockMvc.multipart("/api/files") { + file(MockMultipartFile("file", "dup.txt", "text/plain", payload)) + }.andExpect { status { isOk() } } + mockMvc.multipart("/api/files") { + file(MockMultipartFile("file", "dup.txt", "text/plain", payload)) + }.andExpect { status { isConflict() } } + } + + @Test + fun `folder create and nested upload and recursive delete`() { + val folder = mockMvc.post("/api/files/folder") { + contentType = MediaType.APPLICATION_JSON + content = """{"name":"docs"}""" + }.andExpect { + status { isOk() } + jsonPath("$.isFolder") { value(true) } + jsonPath("$.name") { value("docs") } + }.andReturn() + val folderId = objectMapper.readTree(folder.response.contentAsString)["id"].asText() + + val payload = "content".toByteArray() + mockMvc.multipart("/api/files") { + file(MockMultipartFile("file", "inside.txt", "text/plain", payload)) + param("parentId", folderId) + }.andExpect { status { isOk() } } + + mockMvc.get("/api/files") { param("parentId", folderId) }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + jsonPath("$[0].name") { value("inside.txt") } + } + + // Delete the folder — the service must ask the StorageProvider to + // drop the nested blob too. + val blobsBefore = storage.size + mockMvc.delete("/api/files/$folderId").andExpect { status { isNoContent() } } + assert(storage.size == blobsBefore - 1) { "expected nested blob to be deleted from storage" } + } + + @Test + fun `rename and move via PATCH`() { + val upload = mockMvc.multipart("/api/files") { + file(MockMultipartFile("file", "old.txt", "text/plain", "a".toByteArray())) + }.andReturn() + val fileId = objectMapper.readTree(upload.response.contentAsString)["id"].asText() + + val folder = mockMvc.post("/api/files/folder") { + contentType = MediaType.APPLICATION_JSON + content = """{"name":"target"}""" + }.andReturn() + val folderId = objectMapper.readTree(folder.response.contentAsString)["id"].asText() + + mockMvc.patch("/api/files/$fileId") { + contentType = MediaType.APPLICATION_JSON + content = """{"name":"new.txt","parentId":"$folderId"}""" + }.andExpect { + status { isOk() } + jsonPath("$.name") { value("new.txt") } + jsonPath("$.parentId") { value(folderId) } + } + } + + @Test + fun `moving a folder into its own descendant is rejected`() { + val outer = create("outer") + val inner = create("inner", parentId = outer) + mockMvc.patch("/api/files/$outer") { + contentType = MediaType.APPLICATION_JSON + content = """{"parentId":"$inner"}""" + }.andExpect { status { isBadRequest() } } + } + + private fun create(name: String, parentId: String? = null): String { + val body = if (parentId != null) """{"name":"$name","parentId":"$parentId"}""" else """{"name":"$name"}""" + val r = mockMvc.post("/api/files/folder") { + contentType = MediaType.APPLICATION_JSON + content = body + }.andReturn() + return objectMapper.readTree(r.response.contentAsString)["id"].asText() + } + + // --- Fixtures ----------------------------------------------------------- + + @TestConfiguration + class FileTestConfig { + @Bean + fun inMemoryStorage(): InMemoryStorage = InMemoryStorage() + + @Bean + @Primary + fun stubCurrentUserResolver(): StubCurrentUserResolver = StubCurrentUserResolver() + } + + /** + * Minimal in-memory StorageProvider. Registered as a Spring bean so the + * FileService picks it up via `ObjectProvider`. + */ + class InMemoryStorage : StorageProvider { + override val id: String = "fiely-storage-local" + private val blobs = ConcurrentHashMap() + + val size: Int get() = blobs.size + fun clear() = blobs.clear() + + override fun store(path: StoragePath, data: InputStream, size: Long): FileReference { + val key = "${path.tenantId}/${path.fileId}/v${path.version}" + blobs[key] = data.readAllBytes() + return FileReference(storageId = id, path = key) + } + + override fun retrieve(ref: FileReference): InputStream = + (blobs[ref.path] ?: error("blob not found: ${ref.path}")).inputStream() + + override fun delete(ref: FileReference) { blobs.remove(ref.path) } + override fun exists(ref: FileReference): Boolean = blobs.containsKey(ref.path) + override fun getSize(ref: FileReference): Long = blobs[ref.path]?.size?.toLong() ?: -1 + } + + /** Stand-in for [CurrentUserResolver] that returns a pre-set principal. */ + class StubCurrentUserResolver : CurrentUserResolver( + authProviders = emptyObjectProvider(), + jdbc = org.springframework.jdbc.core.JdbcTemplate(), + ) { + @Volatile + private var principal: AuthenticatedUser? = null + fun set(p: AuthenticatedUser?) { principal = p } + override fun resolve(request: HttpServletRequest): AuthenticatedUser? = principal + + companion object { + private fun emptyObjectProvider(): org.springframework.beans.factory.ObjectProvider = + object : org.springframework.beans.factory.ObjectProvider { + override fun getObject(vararg args: Any?) = error("unused") + override fun getObject() = error("unused") + override fun getIfAvailable() = null + override fun getIfUnique() = null + override fun stream(): java.util.stream.Stream = java.util.stream.Stream.empty() + } + } + } +} diff --git a/fiely-backend/fiely-core/src/test/kotlin/cloud/fiely/file/web/FileMetadataControllerTest.kt b/fiely-backend/fiely-core/src/test/kotlin/cloud/fiely/file/web/FileMetadataControllerTest.kt new file mode 100644 index 0000000..27ce144 --- /dev/null +++ b/fiely-backend/fiely-core/src/test/kotlin/cloud/fiely/file/web/FileMetadataControllerTest.kt @@ -0,0 +1,194 @@ +package cloud.fiely.file.web + +import cloud.fiely.auth.web.AuthenticatedUser +import cloud.fiely.file.domain.FileMetadataRepository +import cloud.fiely.file.domain.FileRepository +import cloud.fiely.plugin.UserInfo +import cloud.fiely.tenant.domain.TenantEntity +import cloud.fiely.tenant.domain.TenantRepository +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.delete +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.multipart +import org.springframework.test.web.servlet.put +import java.util.UUID + +/** + * Exercises [FileMetadataController] end-to-end via H2 + stubbed + * AuthProvider/StorageProvider. Reuses the same fixtures as + * [FileControllerTest] via `@Import`. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(FileControllerTest.FileTestConfig::class) +class FileMetadataControllerTest { + + @Autowired private lateinit var mockMvc: MockMvc + @Autowired private lateinit var objectMapper: ObjectMapper + @Autowired private lateinit var tenants: TenantRepository + @Autowired private lateinit var files: FileRepository + @Autowired private lateinit var metadata: FileMetadataRepository + @Autowired private lateinit var storage: FileControllerTest.InMemoryStorage + @Autowired private lateinit var resolverStub: FileControllerTest.StubCurrentUserResolver + + private val userId = UUID.fromString("22222222-2222-2222-2222-222222222222") + private val tenantId = TenantEntity.DEFAULT_ID + + @BeforeEach + fun seed() { + storage.clear() + metadata.deleteAll() + files.deleteAll() + tenants.deleteAll() + tenants.save( + TenantEntity( + id = tenantId, + slug = "default", + name = "Default", + maxUploadBytes = 4096, + ), + ) + resolverStub.set( + AuthenticatedUser( + info = UserInfo( + id = userId.toString(), + username = "alice", + email = null, + displayName = null, + ), + userId = userId, + tenantId = tenantId, + ), + ) + } + + @Test + fun `put, get, list, delete round-trip`() { + val fileId = uploadFile() + + // PUT user-namespace metadata + mockMvc.put("/api/files/$fileId/metadata/user") { + contentType = MediaType.APPLICATION_JSON + content = """{"tags":["draft","resume"],"rating":5}""" + }.andExpect { + status { isOk() } + jsonPath("$.namespace") { value("user") } + jsonPath("$.data.tags[0]") { value("draft") } + jsonPath("$.data.rating") { value(5) } + } + + // GET single namespace + mockMvc.get("/api/files/$fileId/metadata/user").andExpect { + status { isOk() } + jsonPath("$.data.rating") { value(5) } + } + + // Upsert replaces the document wholesale. + mockMvc.put("/api/files/$fileId/metadata/user") { + contentType = MediaType.APPLICATION_JSON + content = """{"tags":["final"]}""" + }.andExpect { + status { isOk() } + jsonPath("$.data.tags[0]") { value("final") } + jsonPath("$.data.rating") { doesNotExist() } + } + + // Second namespace coexists. + mockMvc.put("/api/files/$fileId/metadata/exif") { + contentType = MediaType.APPLICATION_JSON + content = """{"camera":"Pixel 9","iso":100}""" + }.andExpect { status { isOk() } } + + mockMvc.get("/api/files/$fileId/metadata").andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + } + + // DELETE just the user namespace — exif survives. + mockMvc.delete("/api/files/$fileId/metadata/user").andExpect { + status { isNoContent() } + } + mockMvc.get("/api/files/$fileId/metadata").andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + jsonPath("$[0].namespace") { value("exif") } + } + } + + @Test + fun `metadata is cascade-deleted with the file`() { + val fileId = uploadFile() + mockMvc.put("/api/files/$fileId/metadata/user") { + contentType = MediaType.APPLICATION_JSON + content = """{"tags":["x"]}""" + }.andExpect { status { isOk() } } + + mockMvc.delete("/api/files/$fileId").andExpect { status { isNoContent() } } + + // Reaching into the repo because the HTTP endpoint for the file now + // 404s and would shadow a cascade-failure bug. + val remaining = metadata.findAllByIdFileId(UUID.fromString(fileId)) + assert(remaining.isEmpty()) { "metadata should cascade-delete with its file, found $remaining" } + } + + @Test + fun `unauthenticated requests are rejected`() { + val fileId = uploadFile() + resolverStub.set(null) + mockMvc.get("/api/files/$fileId/metadata").andExpect { status { isUnauthorized() } } + mockMvc.put("/api/files/$fileId/metadata/user") { + contentType = MediaType.APPLICATION_JSON + content = "{}" + }.andExpect { status { isUnauthorized() } } + } + + @Test + fun `put on a file owned by someone else returns 404`() { + val fileId = uploadFile() + resolverStub.set( + AuthenticatedUser( + info = UserInfo(id = UUID.randomUUID().toString(), username = "bob", email = null, displayName = null), + userId = UUID.randomUUID(), + tenantId = tenantId, + ), + ) + mockMvc.put("/api/files/$fileId/metadata/user") { + contentType = MediaType.APPLICATION_JSON + content = """{"x":1}""" + }.andExpect { status { isNotFound() } } + } + + @Test + fun `invalid namespace is rejected`() { + val fileId = uploadFile() + mockMvc.put("/api/files/$fileId/metadata/has spaces") { + contentType = MediaType.APPLICATION_JSON + content = "{}" + }.andExpect { status { isBadRequest() } } + } + + @Test + fun `get on a missing namespace returns 404`() { + val fileId = uploadFile() + mockMvc.get("/api/files/$fileId/metadata/ghost").andExpect { status { isNotFound() } } + } + + // Small helper — uploads a one-byte file and returns its id. + private fun uploadFile(): String { + val res = mockMvc.multipart("/api/files") { + file(MockMultipartFile("file", "note.txt", "text/plain", "x".toByteArray())) + }.andReturn() + return objectMapper.readTree(res.response.contentAsString)["id"].asText() + } +} diff --git a/fiely-backend/fiely-core/src/test/kotlin/cloud/fiely/file/web/FilePluginIntegrationTest.kt b/fiely-backend/fiely-core/src/test/kotlin/cloud/fiely/file/web/FilePluginIntegrationTest.kt new file mode 100644 index 0000000..e845ce1 --- /dev/null +++ b/fiely-backend/fiely-core/src/test/kotlin/cloud/fiely/file/web/FilePluginIntegrationTest.kt @@ -0,0 +1,209 @@ +package cloud.fiely.file.web + +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ConditionEvaluationResult +import org.junit.jupiter.api.extension.ExecutionCondition +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.api.io.TempDir +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.delete +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.multipart +import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.testcontainers.DockerClientFactory +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import java.nio.file.Files +import java.nio.file.Path +import java.security.SecureRandom +import java.util.Base64 +import java.util.UUID +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec +import javax.sql.DataSource + +/** + * End-to-end test of the file pipeline — exercises the real stack: + * - Postgres 16 via Testcontainers + * - `fiely-auth-jwt` and `fiely-storage-local` plugins loaded as real JARs by + * PF4J from the `copyTestPlugins`-built directory + * - Core migrations V1..V4 (tenants + files) + * - A real JWT issued by `/api/auth/login`, then used to drive the + * `/api/files` endpoints end-to-end + * - Blobs materialised on the filesystem under a @TempDir + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@Testcontainers +@ExtendWith(FilePluginIntegrationTest.RequireDocker::class) +class FilePluginIntegrationTest { + + class RequireDocker : ExecutionCondition { + override fun evaluateExecutionCondition(context: ExtensionContext): ConditionEvaluationResult = + if (DockerClientFactory.instance().isDockerAvailable) { + ConditionEvaluationResult.enabled("Docker available") + } else { + ConditionEvaluationResult.disabled( + "Docker is not available — skipping end-to-end integration test.", + ) + } + } + + @Autowired private lateinit var mockMvc: MockMvc + @Autowired private lateinit var objectMapper: ObjectMapper + @Autowired private lateinit var dataSource: DataSource + + @BeforeEach + fun seedUser() { + val hash = encodePbkdf2(PASSWORD, iterations = 1_000) + dataSource.connection.use { c -> + // V4 files table FKs auth_users so clear files first. + c.prepareStatement("DELETE FROM files").execute() + c.prepareStatement("DELETE FROM auth_users").execute() + c.prepareStatement( + """ + INSERT INTO auth_users + (id, username, email, display_name, password_hash, roles, is_enabled) + VALUES + (?::uuid, ?, ?, ?, ?, ?, TRUE) + """.trimIndent() + ).use { stmt -> + stmt.setString(1, UUID.randomUUID().toString()) + stmt.setString(2, USERNAME) + stmt.setString(3, "alice@example.com") + stmt.setString(4, "Alice") + stmt.setString(5, hash) + stmt.setArray(6, c.createArrayOf("text", arrayOf("user"))) + stmt.executeUpdate() + } + } + } + + @Test + fun `login, upload, list, download, delete - end to end`() { + val token = login() + val payload = "fiely end-to-end test content".toByteArray() + + // Upload + val uploadRes = mockMvc.multipart("/api/files") { + file(MockMultipartFile("file", "greeting.txt", "text/plain", payload)) + header("Authorization", "Bearer $token") + }.andExpect { + status { isOk() } + jsonPath("$.name") { value("greeting.txt") } + jsonPath("$.sizeBytes") { value(payload.size) } + }.andReturn() + val fileId = objectMapper.readTree(uploadRes.response.contentAsString)["id"].asText() + + // Blob exists on disk under the storage root. + val blobsUnderRoot = Files.walk(blobRoot).use { s -> + s.filter { Files.isRegularFile(it) }.count() + } + assert(blobsUnderRoot >= 1) { "expected at least one blob under $blobRoot, found $blobsUnderRoot" } + + // List + mockMvc.get("/api/files") { + header("Authorization", "Bearer $token") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + jsonPath("$[0].id") { value(fileId) } + } + + // Download (StreamingResponseBody needs an asyncDispatch) + val asyncResult = mockMvc.get("/api/files/$fileId/content") { + header("Authorization", "Bearer $token") + }.andExpect { status { isOk() } }.andReturn() + mockMvc.perform(asyncDispatch(asyncResult)) + .andExpect(status().isOk) + .andExpect(content().bytes(payload)) + + // Delete + mockMvc.delete("/api/files/$fileId") { + header("Authorization", "Bearer $token") + }.andExpect { status { isNoContent() } } + + val afterDelete = Files.walk(blobRoot).use { s -> + s.filter { Files.isRegularFile(it) }.count() + } + assert(afterDelete == 0L) { "expected all blobs pruned after delete, found $afterDelete" } + } + + @Test + fun `unauthenticated requests are rejected`() { + mockMvc.get("/api/files").andExpect { status { isUnauthorized() } } + } + + private fun login(): String { + val response = mockMvc.post("/api/auth/login") { + contentType = MediaType.APPLICATION_JSON + content = """{"username":"$USERNAME","password":"$PASSWORD"}""" + }.andExpect { + status { isOk() } + }.andReturn() + return objectMapper.readTree(response.response.contentAsString)["token"]["accessToken"].asText() + } + + companion object { + private const val USERNAME = "alice" + private const val PASSWORD = "hunter2" + + @Container + @JvmStatic + val postgres: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:16-alpine") + .withDatabaseName("fiely_test") + .withUsername("fiely") + .withPassword("fiely") + + @TempDir + @JvmStatic + lateinit var blobRoot: Path + + @JvmStatic + @DynamicPropertySource + fun properties(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url") { postgres.jdbcUrl } + registry.add("spring.datasource.username") { postgres.username } + registry.add("spring.datasource.password") { postgres.password } + + val pluginsDir = System.getProperty("fiely.test.plugins.dir") + ?: error( + "System property `fiely.test.plugins.dir` is not set — " + + "run tests via Gradle so the `copyTestPlugins` task prepares it.", + ) + registry.add("fiely.plugins.dir") { pluginsDir } + + registry.add("fiely.plugins.fiely-auth-jwt.secret") { + "integration-test-secret-must-be-at-least-32-chars" + } + registry.add("fiely.plugins.fiely-auth-jwt.issuer") { "fiely-test" } + registry.add("fiely.plugins.fiely-storage-local.root") { blobRoot.toString() } + } + + /** Matches `PasswordHasher.hash` in the jwt plugin — see note in AuthPluginIntegrationTest. */ + private fun encodePbkdf2(password: String, iterations: Int): String { + val salt = ByteArray(16).also { SecureRandom().nextBytes(it) } + val spec = PBEKeySpec(password.toCharArray(), salt, iterations, 256) + val hash = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + .generateSecret(spec).encoded + val b64 = Base64.getEncoder() + return "pbkdf2\$sha256\$$iterations\$${b64.encodeToString(salt)}\$${b64.encodeToString(hash)}" + } + } +} diff --git a/fiely-backend/plugins/fiely-storage-local/build.gradle.kts b/fiely-backend/plugins/fiely-storage-local/build.gradle.kts index b5db53d..5d2b183 100644 --- a/fiely-backend/plugins/fiely-storage-local/build.gradle.kts +++ b/fiely-backend/plugins/fiely-storage-local/build.gradle.kts @@ -6,4 +6,26 @@ description = "Fiely plugin — local filesystem storage" dependencies { compileOnly(project(":fiely-plugin-api")) + compileOnly("org.pf4j:pf4j:3.12.0") + compileOnly("org.slf4j:slf4j-api:2.0.16") + + testImplementation(project(":fiely-plugin-api")) + testImplementation("org.pf4j:pf4j:3.12.0") + testImplementation("org.slf4j:slf4j-api:2.0.16") + testImplementation("org.junit.jupiter:junit-jupiter:5.10.3") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +// Configure the plugin JAR manifest with PF4J metadata so the plugin +// manager can identify and load it. +tasks.jar { + manifest { + attributes( + "Plugin-Id" to "fiely-storage-local", + "Plugin-Version" to project.version.toString(), + "Plugin-Provider" to "Fiely", + "Plugin-Class" to "cloud.fiely.plugin.storage.local.StorageLocalPlugin", + "Plugin-Description" to "Filesystem-backed storage provider (built-in default)", + ) + } } diff --git a/fiely-backend/plugins/fiely-storage-local/src/main/kotlin/cloud/fiely/plugin/storage/local/LocalStorageProvider.kt b/fiely-backend/plugins/fiely-storage-local/src/main/kotlin/cloud/fiely/plugin/storage/local/LocalStorageProvider.kt new file mode 100644 index 0000000..c5d04df --- /dev/null +++ b/fiely-backend/plugins/fiely-storage-local/src/main/kotlin/cloud/fiely/plugin/storage/local/LocalStorageProvider.kt @@ -0,0 +1,139 @@ +package cloud.fiely.plugin.storage.local + +import cloud.fiely.plugin.FileReference +import cloud.fiely.plugin.PluginServices +import cloud.fiely.plugin.StoragePath +import cloud.fiely.plugin.StorageProvider +import org.pf4j.Extension +import org.slf4j.LoggerFactory +import java.io.InputStream +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption + +/** + * Filesystem-backed [StorageProvider]. Blobs are stored under a configurable + * root directory using a UUID-sharded layout: + * + * /////v + * + * where `xx`/`yy` are the first two hex pairs of `fileId`. The sharding + * avoids giant flat directories on common filesystems and aligns 1:1 with + * the [StoragePath] contract. + * + * Durability: [store] writes to a sibling `.tmp` file first and then renames + * with ATOMIC_MOVE (falling back to plain move) so a crash mid-write never + * leaves a half-written blob visible under its final path. + * + * Stream ownership: [retrieve] returns a fresh [InputStream] that the caller + * owns and must close. The provider keeps no state that would be disturbed by + * the caller holding the stream for an arbitrary amount of time. + */ +@Extension +class LocalStorageProvider : StorageProvider { + + private val log = LoggerFactory.getLogger(LocalStorageProvider::class.java) + + override val id: String = "fiely-storage-local" + + private val root: Path by lazy { resolveRoot() } + + override fun store(path: StoragePath, data: InputStream, size: Long): FileReference { + val target = resolve(path) + Files.createDirectories(target.parent) + + val tmp = target.resolveSibling(target.fileName.toString() + ".tmp") + try { + Files.newOutputStream(tmp).use { out -> + data.copyTo(out) + } + try { + Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (_: UnsupportedOperationException) { + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING) + } + } catch (e: Throwable) { + runCatching { Files.deleteIfExists(tmp) } + throw e + } + + val relative = root.relativize(target).toString().replace('\\', '/') + log.debug("Stored blob at {}", target) + return FileReference(storageId = id, path = relative) + } + + override fun retrieve(ref: FileReference): InputStream { + val target = root.resolve(ref.path).normalize() + requireUnderRoot(target) + return Files.newInputStream(target) + } + + override fun delete(ref: FileReference) { + val target = root.resolve(ref.path).normalize() + requireUnderRoot(target) + Files.deleteIfExists(target) + + // Best-effort prune of now-empty parent directories (up to the tenant + // root). Swallow errors — a non-empty directory or permissions issue + // is not a delete failure. + var dir: Path? = target.parent + while (dir != null && dir != root && dir.startsWith(root)) { + try { + Files.delete(dir) + } catch (_: Throwable) { + break + } + dir = dir.parent + } + } + + override fun exists(ref: FileReference): Boolean { + val target = root.resolve(ref.path).normalize() + if (!target.startsWith(root)) return false + return Files.exists(target) + } + + override fun getSize(ref: FileReference): Long { + val target = root.resolve(ref.path).normalize() + requireUnderRoot(target) + return Files.size(target) + } + + // --- Internals ---------------------------------------------------------- + + private fun resolveRoot(): Path { + val configured = PluginServices.configFor("fiely-storage-local")["root"] as? String + val path = Paths.get(configured ?: "./data/blobs").toAbsolutePath().normalize() + Files.createDirectories(path) + log.info("LocalStorageProvider root: {}", path) + return path + } + + private fun resolve(path: StoragePath): Path { + val fileId = path.fileId + val shard1 = fileId.take(2) + val shard2 = fileId.drop(2).take(2) + return root + .resolve(sanitise(path.tenantId)) + .resolve(shard1) + .resolve(shard2) + .resolve(sanitise(fileId)) + .resolve("v${path.version}") + .normalize() + } + + private fun requireUnderRoot(candidate: Path) { + if (!candidate.startsWith(root)) { + throw SecurityException("Resolved blob path escapes the storage root") + } + } + + /** Defensive: prevent `..`/`/` sneaking into path segments via injected ids. */ + private fun sanitise(segment: String): String { + if (segment.contains('/') || segment.contains('\\') || segment == ".." || segment == ".") { + throw IllegalArgumentException("Illegal path segment: $segment") + } + return segment + } +} diff --git a/fiely-backend/plugins/fiely-storage-local/src/main/kotlin/cloud/fiely/plugin/storage/local/StorageLocalPlugin.kt b/fiely-backend/plugins/fiely-storage-local/src/main/kotlin/cloud/fiely/plugin/storage/local/StorageLocalPlugin.kt new file mode 100644 index 0000000..6f30959 --- /dev/null +++ b/fiely-backend/plugins/fiely-storage-local/src/main/kotlin/cloud/fiely/plugin/storage/local/StorageLocalPlugin.kt @@ -0,0 +1,27 @@ +package cloud.fiely.plugin.storage.local + +import org.pf4j.Plugin +import org.pf4j.PluginWrapper +import org.slf4j.LoggerFactory + +/** + * PF4J plugin entrypoint for the built-in filesystem storage provider. + * + * The plugin exposes a single extension — [LocalStorageProvider] — that writes + * uploaded blobs under a configurable root directory. + * + * Configuration is read from `fiely.plugins.fiely-storage-local.*`: + * - `root` — root directory for blob storage (defaults to `./data/blobs`) + */ +class StorageLocalPlugin(wrapper: PluginWrapper) : Plugin(wrapper) { + + private val log = LoggerFactory.getLogger(StorageLocalPlugin::class.java) + + override fun start() { + log.info("fiely-storage-local plugin started (v{})", wrapper.descriptor.version) + } + + override fun stop() { + log.info("fiely-storage-local plugin stopped") + } +} diff --git a/fiely-backend/plugins/fiely-storage-local/src/main/resources/plugin.properties b/fiely-backend/plugins/fiely-storage-local/src/main/resources/plugin.properties new file mode 100644 index 0000000..2dd03b2 --- /dev/null +++ b/fiely-backend/plugins/fiely-storage-local/src/main/resources/plugin.properties @@ -0,0 +1,6 @@ +plugin.id=fiely-storage-local +plugin.class=cloud.fiely.plugin.storage.local.StorageLocalPlugin +plugin.version=0.0.1 +plugin.provider=Fiely +plugin.description=Filesystem-backed storage provider (built-in default). +plugin.dependencies= diff --git a/fiely-backend/plugins/fiely-storage-local/src/test/kotlin/cloud/fiely/plugin/storage/local/LocalStorageProviderTest.kt b/fiely-backend/plugins/fiely-storage-local/src/test/kotlin/cloud/fiely/plugin/storage/local/LocalStorageProviderTest.kt new file mode 100644 index 0000000..9806cb3 --- /dev/null +++ b/fiely-backend/plugins/fiely-storage-local/src/test/kotlin/cloud/fiely/plugin/storage/local/LocalStorageProviderTest.kt @@ -0,0 +1,115 @@ +package cloud.fiely.plugin.storage.local + +import cloud.fiely.plugin.PluginServices +import cloud.fiely.plugin.StoragePath +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path + +/** + * Exercises [LocalStorageProvider] directly (no PF4J, no Spring). A per-test + * temp dir becomes the blob root. [PluginServices] is the production config + * entry point so we initialise it with a temp root here. + */ +class LocalStorageProviderTest { + + @TempDir + lateinit var tempRoot: Path + + private lateinit var provider: LocalStorageProvider + + @BeforeEach + fun init() { + PluginServices.initialise( + // A DataSource is required by the API but unused by this provider. + dataSource = NoopDataSource, + config = mapOf("fiely.plugins.fiely-storage-local.root" to tempRoot.toString()), + ) + provider = LocalStorageProvider() + } + + @Test + fun `store writes bytes under a sharded path and retrieve returns them verbatim`() { + val payload = "hello fiely".toByteArray() + val path = StoragePath( + tenantId = "tenant-a", + userId = "user-1", + fileId = "abcdef1234567890", + version = 1, + ) + + val ref = provider.store(path, payload.inputStream(), payload.size.toLong()) + assertEquals("fiely-storage-local", ref.storageId) + + // Sharded layout: /tenant-a/ab/cd/abcdef1234567890/v1 + val expected = tempRoot.resolve("tenant-a/ab/cd/abcdef1234567890/v1") + assertTrue(Files.exists(expected), "expected blob at $expected") + assertArrayEquals(payload, Files.readAllBytes(expected)) + + val retrieved = provider.retrieve(ref).use { it.readAllBytes() } + assertArrayEquals(payload, retrieved) + assertEquals(payload.size.toLong(), provider.getSize(ref)) + assertTrue(provider.exists(ref)) + } + + @Test + fun `delete removes the blob and prunes empty parent directories`() { + val payload = byteArrayOf(1, 2, 3, 4) + val path = StoragePath("t", "u", "deadbeef00000000", 1) + val ref = provider.store(path, payload.inputStream(), payload.size.toLong()) + + provider.delete(ref) + assertFalse(provider.exists(ref)) + + // Parent dirs up to the tenant should have been pruned. The tenant + // dir itself is empty so it goes too; the root stays. + val tenantDir = tempRoot.resolve("t") + assertFalse(Files.exists(tenantDir), "empty tenant dir should be pruned") + assertTrue(Files.exists(tempRoot), "root dir must remain") + } + + @Test + fun `path-escape attempts are rejected`() { + val badPath = StoragePath("t", "u", "../etc", 1) + assertThrows(IllegalArgumentException::class.java) { + provider.store(badPath, "oops".byteInputStream(), 4) + } + } + + @Test + fun `partial write leaves no visible blob`() { + val path = StoragePath("t", "u", "11223344aabbccdd", 1) + val exploding = object : java.io.InputStream() { + var read = 0 + override fun read(): Int { + if (read++ > 3) throw java.io.IOException("boom") + return 0x41 + } + } + assertThrows(java.io.IOException::class.java) { + provider.store(path, exploding, 1024) + } + val target = tempRoot.resolve("t/11/22/11223344aabbccdd/v1") + assertFalse(Files.exists(target), "partial writes must not materialise at the final path") + } +} + +/** A trivial DataSource stand-in — LocalStorageProvider never touches the DB. */ +private object NoopDataSource : javax.sql.DataSource { + override fun getConnection(): java.sql.Connection = error("unused in test") + override fun getConnection(u: String?, p: String?): java.sql.Connection = error("unused in test") + override fun getLogWriter(): java.io.PrintWriter? = null + override fun setLogWriter(out: java.io.PrintWriter?) {} + override fun setLoginTimeout(seconds: Int) {} + override fun getLoginTimeout(): Int = 0 + override fun getParentLogger(): java.util.logging.Logger = java.util.logging.Logger.getGlobal() + override fun unwrap(iface: Class?): T = error("unused") + override fun isWrapperFor(iface: Class<*>?): Boolean = false +} diff --git a/fiely-frontend/index.html b/fiely-frontend/index.html index d7b98d8..ea8f55b 100644 --- a/fiely-frontend/index.html +++ b/fiely-frontend/index.html @@ -15,7 +15,7 @@ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> - Sign in · Fiely + Fiely
diff --git a/fiely-frontend/package-lock.json b/fiely-frontend/package-lock.json index b56bf85..23e9415 100644 --- a/fiely-frontend/package-lock.json +++ b/fiely-frontend/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "lucide-react": "^0.454.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^7.14.2" }, "devDependencies": { "@types/node": "^25.6.0", @@ -1490,6 +1491,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2256,6 +2270,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", + "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", + "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -2400,6 +2452,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/fiely-frontend/package.json b/fiely-frontend/package.json index 341e35e..924e688 100644 --- a/fiely-frontend/package.json +++ b/fiely-frontend/package.json @@ -13,7 +13,8 @@ "dependencies": { "lucide-react": "^0.454.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^7.14.2" }, "devDependencies": { "@types/node": "^25.6.0", diff --git a/fiely-frontend/src/App.tsx b/fiely-frontend/src/App.tsx index ea8a284..7400a14 100644 --- a/fiely-frontend/src/App.tsx +++ b/fiely-frontend/src/App.tsx @@ -1,14 +1,46 @@ +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; +import { AuthProvider, useAuth } from './auth'; import Login from './components/Login'; +import AppShell from './components/AppShell'; +import FileBrowser from './components/FileBrowser'; +import { Loader2 } from 'lucide-react'; -/** - * Root of the unauthenticated app shell. For now there's only one view: - * the login page. Once session handling lands this becomes a router that - * redirects authenticated users to `/files`. - */ export default function App() { return (
- + + + + } /> + }> + } /> + + } /> + + + +
+ ); +} + +function RequireAuth({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth(); + if (loading) return ; + if (!user) return ; + return <>{children}; +} + +function PublicOnly({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth(); + if (loading) return ; + if (user) return ; + return <>{children}; +} + +function LoadingScreen() { + return ( +
+
); } diff --git a/fiely-frontend/src/api.ts b/fiely-frontend/src/api.ts new file mode 100644 index 0000000..57d93a8 --- /dev/null +++ b/fiely-frontend/src/api.ts @@ -0,0 +1,30 @@ +const TOKEN_KEY = 'fiely.accessToken'; + +function getToken(): string | null { + return ( + window.localStorage.getItem(TOKEN_KEY) ?? + window.sessionStorage.getItem(TOKEN_KEY) + ); +} + +export function clearTokens() { + for (const s of [window.localStorage, window.sessionStorage]) { + s.removeItem('fiely.accessToken'); + s.removeItem('fiely.refreshToken'); + } +} + +export async function apiFetch( + path: string, + init?: RequestInit, +): Promise { + const token = getToken(); + const headers = new Headers(init?.headers); + if (token) headers.set('Authorization', `Bearer ${token}`); + const res = await fetch(path, { ...init, headers }); + if (res.status === 401 && !path.includes('/api/auth/')) { + clearTokens(); + window.location.href = '/login'; + } + return res; +} diff --git a/fiely-frontend/src/auth.tsx b/fiely-frontend/src/auth.tsx new file mode 100644 index 0000000..79ac7bd --- /dev/null +++ b/fiely-frontend/src/auth.tsx @@ -0,0 +1,79 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useState, + type ReactNode, +} from 'react'; +import { apiFetch, clearTokens } from './api'; + +export interface User { + id: string; + username: string; + email?: string; + displayName?: string; + roles?: string[]; +} + +interface AuthState { + user: User | null; + loading: boolean; + logout: () => void; + refreshUser: () => Promise; +} + +const AuthContext = createContext({ + user: null, + loading: true, + logout: () => {}, + refreshUser: async () => {}, +}); + +export function useAuth() { + return useContext(AuthContext); +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchUser = useCallback(async () => { + try { + const res = await apiFetch('/api/auth/me'); + if (res.ok) { + setUser(await res.json()); + } else { + setUser(null); + } + } catch { + setUser(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + const token = + window.localStorage.getItem('fiely.accessToken') ?? + window.sessionStorage.getItem('fiely.accessToken'); + if (!token) { + setLoading(false); + return; + } + fetchUser(); + }, [fetchUser]); + + const logout = useCallback(() => { + clearTokens(); + setUser(null); + }, []); + + return ( + + {children} + + ); +} diff --git a/fiely-frontend/src/components/AppShell.tsx b/fiely-frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..b1ae7eb --- /dev/null +++ b/fiely-frontend/src/components/AppShell.tsx @@ -0,0 +1,53 @@ +import { Outlet, useNavigate } from 'react-router-dom'; +import { useAuth } from '../auth'; +import Logo from './Logo'; +import { LogOut } from 'lucide-react'; + +export default function AppShell() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + const initial = (user?.displayName ?? user?.username ?? '?')[0].toUpperCase(); + + return ( +
+
+
+ + + Fiely + +
+ +
+
+
+ {initial} +
+ + {user?.displayName ?? user?.username} + +
+
+ +
+
+ +
+ +
+
+ ); +} diff --git a/fiely-frontend/src/components/ConfirmDialog.tsx b/fiely-frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..ff2fe9c --- /dev/null +++ b/fiely-frontend/src/components/ConfirmDialog.tsx @@ -0,0 +1,53 @@ +import Modal from './Modal'; +import { AlertTriangle } from 'lucide-react'; + +interface Props { + open: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + message: string; + confirmLabel?: string; + destructive?: boolean; +} + +export default function ConfirmDialog({ + open, + onClose, + onConfirm, + title, + message, + confirmLabel = 'Confirm', + destructive = false, +}: Props) { + return ( + +
+ {destructive && ( +
+ +
+ )} +

{message}

+
+
+ + +
+
+ ); +} diff --git a/fiely-frontend/src/components/FileBrowser.tsx b/fiely-frontend/src/components/FileBrowser.tsx new file mode 100644 index 0000000..2a41abf --- /dev/null +++ b/fiely-frontend/src/components/FileBrowser.tsx @@ -0,0 +1,559 @@ +import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { apiFetch } from '../api'; +import type { FileNode } from '../types'; +import Modal from './Modal'; +import ConfirmDialog from './ConfirmDialog'; +import { + ChevronRight, + Download, + File, + Folder, + FolderPlus, + Home, + Loader2, + Pencil, + Trash2, + Upload, + UploadCloud, +} from 'lucide-react'; + +interface BreadcrumbEntry { + id: string; + name: string; +} + +export default function FileBrowser() { + const { folderId } = useParams<{ folderId?: string }>(); + const navigate = useNavigate(); + + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [trail, setTrail] = useState([]); + const [uploading, setUploading] = useState(false); + const [dragging, setDragging] = useState(false); + + // Modal state + const [folderModalOpen, setFolderModalOpen] = useState(false); + const [folderName, setFolderName] = useState(''); + const [renameTarget, setRenameTarget] = useState(null); + const [renameName, setRenameName] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); + + const fileInputRef = useRef(null); + const dragCounter = useRef(0); + + const fetchFiles = useCallback(async () => { + setLoading(true); + setError(null); + try { + const url = folderId ? `/api/files?parentId=${folderId}` : '/api/files'; + const res = await apiFetch(url); + if (!res.ok) throw new Error('Failed to load files'); + setFiles(await res.json()); + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, [folderId]); + + useEffect(() => { + fetchFiles(); + }, [fetchFiles]); + + // Breadcrumb resolution on deep-link. + useEffect(() => { + if (!folderId) { + setTrail([]); + return; + } + const idx = trail.findIndex((b) => b.id === folderId); + if (idx >= 0) { + setTrail((prev) => prev.slice(0, idx + 1)); + return; + } + (async () => { + const chain: BreadcrumbEntry[] = []; + let cursor: string | null = folderId; + while (cursor) { + const res = await apiFetch(`/api/files/${cursor}`); + if (!res.ok) break; + const node: FileNode = await res.json(); + chain.unshift({ id: node.id, name: node.name }); + cursor = node.parentId; + } + setTrail(chain); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [folderId]); + + // --- Actions --------------------------------------------------------------- + + function openFolder(folder: FileNode) { + setTrail((prev) => [...prev, { id: folder.id, name: folder.name }]); + navigate(`/files/${folder.id}`); + } + + function navigateToBreadcrumb(index: number) { + if (index < 0) { + setTrail([]); + navigate('/files'); + } else { + setTrail((prev) => prev.slice(0, index + 1)); + navigate(`/files/${trail[index].id}`); + } + } + + async function handleUpload(fileList: FileList | null) { + if (!fileList?.length) return; + setUploading(true); + try { + for (const file of Array.from(fileList)) { + const form = new FormData(); + form.append('file', file); + if (folderId) form.append('parentId', folderId); + const res = await apiFetch('/api/files', { method: 'POST', body: form }); + if (!res.ok) { + const body = await res.json().catch(() => null); + alert(`Failed to upload ${file.name}: ${body?.error ?? res.status}`); + } + } + await fetchFiles(); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + } + + async function submitCreateFolder() { + if (!folderName.trim()) return; + const res = await apiFetch('/api/files/folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: folderName.trim(), parentId: folderId ?? null }), + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + alert(body?.error ?? 'Failed to create folder'); + return; + } + setFolderModalOpen(false); + setFolderName(''); + await fetchFiles(); + } + + async function submitRename() { + if (!renameTarget || !renameName.trim() || renameName.trim() === renameTarget.name) return; + const res = await apiFetch(`/api/files/${renameTarget.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: renameName.trim() }), + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + alert(body?.error ?? 'Rename failed'); + return; + } + setRenameTarget(null); + await fetchFiles(); + } + + async function confirmDelete() { + if (!deleteTarget) return; + const res = await apiFetch(`/api/files/${deleteTarget.id}`, { method: 'DELETE' }); + if (!res.ok) { + const body = await res.json().catch(() => null); + alert(body?.error ?? 'Delete failed'); + } + setDeleteTarget(null); + await fetchFiles(); + } + + async function downloadFile(node: FileNode) { + const res = await apiFetch(`/api/files/${node.id}/content`); + if (!res.ok) { + alert('Download failed'); + return; + } + const blob = await res.blob(); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = node.name; + a.click(); + URL.revokeObjectURL(a.href); + } + + // --- Drag & Drop ----------------------------------------------------------- + + function onDragEnter(e: DragEvent) { + e.preventDefault(); + dragCounter.current++; + if (e.dataTransfer.types.includes('Files')) setDragging(true); + } + + function onDragLeave(e: DragEvent) { + e.preventDefault(); + dragCounter.current--; + if (dragCounter.current === 0) setDragging(false); + } + + function onDrop(e: DragEvent) { + e.preventDefault(); + dragCounter.current = 0; + setDragging(false); + handleUpload(e.dataTransfer.files); + } + + // --- Render ---------------------------------------------------------------- + + return ( +
e.preventDefault()} + onDragLeave={onDragLeave} + onDrop={onDrop} + > + {/* Drag overlay */} + {dragging && ( +
+
+ + Drop files to upload +
+
+ )} + + {/* Toolbar */} +
+ + +
+ + +
+
+ + {/* Content */} + {loading ? ( +
+ +
+ ) : error ? ( +
+ {error} +
+ ) : files.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + {files.map((node) => ( + { + setRenameTarget(n); + setRenameName(n.name); + }} + onDelete={setDeleteTarget} + onDownload={downloadFile} + /> + ))} + +
NameSizeModified +
+
+ )} + + {/* Create folder modal */} + setFolderModalOpen(false)} + title="New folder" + > +
{ + e.preventDefault(); + submitCreateFolder(); + }} + > + setFolderName(e.target.value)} + placeholder="Folder name" + className="block w-full rounded-lg border border-ink-200 bg-white px-3 py-2.5 text-sm text-ink-900 shadow-sm placeholder:text-ink-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 dark:border-white/10 dark:bg-ink-900/60 dark:text-ink-50" + /> +
+ + +
+
+
+ + {/* Rename modal */} + setRenameTarget(null)} + title="Rename" + > +
{ + e.preventDefault(); + submitRename(); + }} + > + setRenameName(e.target.value)} + placeholder="New name" + className="block w-full rounded-lg border border-ink-200 bg-white px-3 py-2.5 text-sm text-ink-900 shadow-sm placeholder:text-ink-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 dark:border-white/10 dark:bg-ink-900/60 dark:text-ink-50" + /> +
+ + +
+
+
+ + {/* Delete confirm */} + setDeleteTarget(null)} + onConfirm={confirmDelete} + title={`Delete ${deleteTarget?.isFolder ? 'folder' : 'file'}`} + message={`Are you sure you want to delete "${deleteTarget?.name ?? ''}"?${deleteTarget?.isFolder ? ' All contents will be permanently removed.' : ''}`} + confirmLabel="Delete" + destructive + /> +
+ ); +} + +// --- Sub-components --------------------------------------------------------- + +function Breadcrumbs({ + trail, + onNavigate, +}: { + trail: BreadcrumbEntry[]; + onNavigate: (index: number) => void; +}) { + return ( + + ); +} + +function FileRow({ + node, + onOpen, + onRename, + onDelete, + onDownload, +}: { + node: FileNode; + onOpen: (f: FileNode) => void; + onRename: (f: FileNode) => void; + onDelete: (f: FileNode) => void; + onDownload: (f: FileNode) => void; +}) { + return ( + + +
node.isFolder && onOpen(node)} + > + {node.isFolder ? ( + + ) : ( + + )} + + {node.name} + +
+ + + {node.isFolder ? '—' : formatBytes(node.sizeBytes)} + + + {formatRelativeTime(node.updatedAt)} + + +
+ {!node.isFolder && ( + onDownload(node)}> + + + )} + onRename(node)}> + + + onDelete(node)} + className="text-red-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950" + > + + +
+ + + ); +} + +function IconButton({ + title, + onClick, + className = '', + children, +}: { + title: string; + onClick: () => void; + className?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function EmptyState() { + return ( +
+
+ +
+
+

No files yet

+

+ Upload files or drag and drop them here. +

+
+
+ ); +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1, + ); + const value = bytes / Math.pow(1024, i); + return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`; +} + +function formatRelativeTime(iso: string): string { + const date = new Date(iso); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 7) return `${diffDay}d ago`; + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, + }); +} diff --git a/fiely-frontend/src/components/Login.tsx b/fiely-frontend/src/components/Login.tsx index cfb2f6f..f457aac 100644 --- a/fiely-frontend/src/components/Login.tsx +++ b/fiely-frontend/src/components/Login.tsx @@ -1,5 +1,7 @@ import { useState, type FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; import { ArrowRight, Eye, EyeOff, Loader2, ShieldCheck } from 'lucide-react'; +import { useAuth } from '../auth'; import Logo from './Logo'; import LegalFooter from './LegalFooter'; @@ -7,6 +9,8 @@ type FormState = { username: string; password: string; remember: boolean }; type Status = 'idle' | 'submitting'; export default function Login() { + const navigate = useNavigate(); + const { refreshUser } = useAuth(); const [form, setForm] = useState({ username: '', password: '', @@ -44,7 +48,8 @@ export default function Login() { store.setItem('fiely.refreshToken', body.token.refreshToken); } } - window.location.href = '/'; + await refreshUser(); + navigate('/files', { replace: true }); } catch (err) { setError(err instanceof Error ? err.message : 'Something went wrong'); setStatus('idle'); diff --git a/fiely-frontend/src/components/Modal.tsx b/fiely-frontend/src/components/Modal.tsx new file mode 100644 index 0000000..fb87fea --- /dev/null +++ b/fiely-frontend/src/components/Modal.tsx @@ -0,0 +1,49 @@ +import { useEffect, useRef, type ReactNode } from 'react'; +import { X } from 'lucide-react'; + +interface Props { + open: boolean; + onClose: () => void; + title: string; + children: ReactNode; +} + +export default function Modal({ open, onClose, title, children }: Props) { + const overlayRef = useRef(null); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [open, onClose]); + + if (!open) return null; + + return ( +
{ + if (e.target === overlayRef.current) onClose(); + }} + > +
+
+

+ {title} +

+ +
+ {children} +
+
+ ); +} diff --git a/fiely-frontend/src/types.ts b/fiely-frontend/src/types.ts new file mode 100644 index 0000000..b0871bf --- /dev/null +++ b/fiely-frontend/src/types.ts @@ -0,0 +1,11 @@ +export interface FileNode { + id: string; + parentId: string | null; + name: string; + isFolder: boolean; + sizeBytes: number; + contentType: string | null; + currentVersion: number; + createdAt: string; + updatedAt: string; +}