Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions fiely-backend/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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
}
Expand Down
21 changes: 14 additions & 7 deletions fiely-backend/fiely-core/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
kotlin("jvm")
kotlin("plugin.spring")
kotlin("plugin.jpa")
id("org.springframework.boot")
id("io.spring.dependency-management")
}
Expand Down Expand Up @@ -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<Copy>("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"))
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<AuthProvider>,
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,
)
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package cloud.fiely.file.domain

import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID

interface FileMetadataRepository : JpaRepository<FileMetadataEntity, FileMetadataId> {
fun findAllByIdFileId(fileId: UUID): List<FileMetadataEntity>
fun deleteByIdFileIdAndIdNamespace(fileId: UUID, namespace: String): Long
fun deleteAllByIdFileIdIn(fileIds: Collection<UUID>): Long
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package cloud.fiely.file.domain

import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID

interface FileRepository : JpaRepository<FileEntity, UUID> {
fun findByIdAndOwnerId(id: UUID, ownerId: UUID): FileEntity?

fun findAllByOwnerIdAndParentIdOrderByIsFolderDescNameAsc(
ownerId: UUID,
parentId: UUID?,
): List<FileEntity>

fun findAllByOwnerIdAndParentIdIsNullOrderByIsFolderDescNameAsc(
ownerId: UUID,
): List<FileEntity>

fun findAllByOwnerIdAndParentId(ownerId: UUID, parentId: UUID): List<FileEntity>

fun existsByOwnerIdAndParentIdAndName(ownerId: UUID, parentId: UUID?, name: String): Boolean
}
Original file line number Diff line number Diff line change
@@ -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<MetadataDocument> {
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._-]*$""")
}
}
Loading
Loading