This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-7937-b01b11f8182313b9f3bff877c661f7ae0f0b5a1c in repository https://gitbox.apache.org/repos/asf/texera.git
commit 1996448b1c5f4e0906cc077f927a90648e28a1af Author: Tanishq Gandhi <[email protected]> AuthorDate: Thu Aug 27 19:50:49 2026 +0000 feat(file-service): complete the model backend for sharing and the hub (#7937) ### What changes were proposed in this PR? #7922 gave models the file API the management UI needs. This PR adds the last backend pieces, so model sharing and the hub can be built as frontend-only work. **Cover images** — `POST /{mid}/update/cover`, `GET /{mid}/cover` (307 redirect), `GET /{mid}/cover-url` (JSON, since `<img src>` cannot attach the Authorization header on a private model). The `cover_image` column already existed. The resource-agnostic halves move into a shared `CoverImageUtils` and `DatasetResource` is refactored onto it, net **−55 lines** there. The image extension allowlist is a security control, not a convention — a cover is handed to the browser as a presigned URL, so an active document (`.svg`, `.html`) would be a stored-XSS vector, and a duplicated allowlist drifts. A cover path is validated to be `<version>/<file>` and opened defensively. `FileResolver` needs five path segments and throws an `IOException` that file-service registers no mapper for, so a bare file name or a deleted version would otherwise surface as an opaque 500 — and on the read path, one bad write would 500 every render of every card showing that resource. The write path answers 400; the reads treat an unresolvable cover as absent (`{"url": null}` / 404) so the card falls back to its default. **Anonymous version browsing** — `/{mid}/publicVersion/list` and `/{mid}/publicVersion/{mvid}/rootFileNodes`. Without these a logged-out visitor on a public model page gets metadata and then an error. **Framework/format editing** — `POST /update/framework`, `POST /update/format`, plus `other` in both whitelists. The labels were create-only, so a mislabelled model had to be recreated. Both endpoints share `normalizeLabel` with `createModel`, so the edit path cannot reject a value the create path accepted: blank resets the framework to the default and clears the format, and values are trimmed the same way in both. **Routing** — `/api/model` and `/api/access/model` in nginx, the k8s gateway and the frontend dev proxy. Nothing reaches the resource in a real deployment without them. The nginx location carries a trailing slash for symmetry with `/api/access/dataset/`; the LLM `/api/models` route is an exact-match location, which outranks any prefix location, so the two are unambiguous either way. One behavior change on the dataset side: covers now get a path-length check. `dataset.cover_image` is `varchar(246)` via `sql/updates/18.sql`, so an over-long path was a jOOQ-wrapped 500 and is now a 400. The dataset cover endpoints also pick up the unresolvable-path handling above, which they lacked before this branch. ### Any related issues, documentation, discussions? - Part of #6501 - **Reviewable on its own.** #7922 has merged and `main` has been merged in, so the diff is this PR's work alone. No migration, no schema change. - Independent of #7930 (models in the hub and unified search). That PR is amber-side — the dashboard search builders, `EntityTables`/`EntityType`, and `sql/42.sql`; the `ModelResource` it touches is the amber dashboard one, not file-service's. Neither PR calls the other, and this branch compiles and passes its tests on `main` with none of #7930 present. They can merge in either order; whichever lands second will hit a one-line conflict in `ModelResourcePermissionsSpec`'s `publicEndpointMethods` set. ### How was this PR tested? New `CoverImageUtilsSpec` pins the allowlist, `.svg`/`.html` rejection, path traversal, absolute paths, the `<version>/<file>` shape and both column-width limits. New `ModelHubApiSpec` drives the covers end to end through LakeFS/MinIO and covers anonymous browsing, including the negative cases: private model anonymous and no-grant, unknown model and version id, a model unpublished after the fact, a bare filename and a nonexistent version rejected at write, a stored cover that stops resolving, and agreement between the public and authenticated file-tree endpoints. `ModelApiForUiSpec` covers the framework/format updates, including trimming, blank-resets-framework and blank-clears-format. `ModelResourcePermissionsSpec` needed the four new `@PermitAll` endpoint names — the guardrail caught them, as intended. The dataset suites pass unmodified, which is what confirms the shared `CoverImageUtils` did not shift dataset behavior. 300 tests across the eight affected suites: sbt "FileService/testOnly *CoverImageUtilsSpec *ModelHubApiSpec *ModelApiForUiSpec *ModelResourcePermissionsSpec *ModelResourceSpec *DatasetResourceSpec *DatasetResourcePermissionsSpec *DatasetAccessResourceSpec" sbt scalafmtCheckAll "scalafixAll --check" ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 5) --------- Co-authored-by: ali <[email protected]> Co-authored-by: Claude Opus 4.8 <[email protected]> --- bin/k8s/templates/base/gateway/gateway-routes.yaml | 8 + bin/single-node/nginx.conf | 15 + .../texera/service/resource/DatasetResource.scala | 154 +++----- .../texera/service/resource/ModelResource.scala | 244 ++++++++++++- .../texera/service/util/CoverImageUtils.scala | 134 +++++++ .../service/resource/DatasetResourceSpec.scala | 15 +- .../service/resource/ModelApiForUiSpec.scala | 124 +++++++ .../texera/service/resource/ModelHubApiSpec.scala | 398 +++++++++++++++++++++ .../resource/ModelResourcePermissionsSpec.scala | 10 +- .../texera/service/util/CoverImageUtilsSpec.scala | 158 ++++++++ frontend/proxy.config.json | 10 + 11 files changed, 1134 insertions(+), 136 deletions(-) diff --git a/bin/k8s/templates/base/gateway/gateway-routes.yaml b/bin/k8s/templates/base/gateway/gateway-routes.yaml index 935d38bb07..f07a3157a4 100644 --- a/bin/k8s/templates/base/gateway/gateway-routes.yaml +++ b/bin/k8s/templates/base/gateway/gateway-routes.yaml @@ -49,6 +49,14 @@ spec: - path: type: PathPrefix value: /api/access/dataset + # PathPrefix matches whole path segments, so /api/model and the /api/models + # LLM route below stay distinct. + - path: + type: PathPrefix + value: /api/model + - path: + type: PathPrefix + value: /api/access/model backendRefs: - name: file-service-svc port: 9092 diff --git a/bin/single-node/nginx.conf b/bin/single-node/nginx.conf index 0f091a5cb4..688e256d46 100644 --- a/bin/single-node/nginx.conf +++ b/bin/single-node/nginx.conf @@ -45,6 +45,21 @@ http { proxy_set_header X-Real-IP $remote_addr; } + # Trailing slash for symmetry with /api/access/dataset/ above. The LLM route + # is an exact match (location = /api/models), which outranks any prefix + # location, so /api/models is unambiguous either way. + location /api/model/ { + proxy_pass http://file-service:9092; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /api/access/model/ { + proxy_pass http://file-service:9092; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location /api/config { proxy_pass http://config-service:9094; proxy_set_header Host $host; diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala index f8bc532909..6c15196a0b 100644 --- a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala +++ b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala @@ -26,9 +26,8 @@ import jakarta.ws.rs._ import jakarta.ws.rs.core._ import org.apache.texera.common.config.StorageConfig import org.apache.texera.common.util.EmailUtil -import org.apache.texera.amber.core.storage.model.OnVersionedFileResource import org.apache.texera.amber.core.storage.util.LakeFSStorageClient -import org.apache.texera.amber.core.storage.{DocumentFactory, FileResolver, ResourceType} +import org.apache.texera.amber.core.storage.ResourceType import org.apache.texera.auth.SessionUser import org.apache.texera.dao.SiteSettings import org.apache.texera.dao.SqlServer @@ -52,6 +51,8 @@ import org.apache.texera.service.`type`.{Diff, ExistingUploadFilesRequest, LakeF import org.apache.texera.service.resource.DatasetAccessResource._ import org.apache.texera.service.resource.ResourceTables.{Dataset => DATASET_RESOURCE} import org.apache.texera.service.resource.DatasetResource.{context, _} +import org.apache.texera.service.util.CoverImageUtils +import org.apache.texera.service.util.CoverImageUtils.CoverImageRequest import org.apache.texera.service.util.S3StorageClient import org.jooq.impl.DSL import org.jooq.{DSLContext, EnumType} @@ -62,7 +63,6 @@ import java.util import java.util.Optional import scala.jdk.CollectionConverters._ import scala.jdk.OptionConverters._ -import org.apache.commons.io.FilenameUtils import org.apache.texera.service.util.LakeFSExceptionHandler.withLakeFSErrorHandling object DatasetResource { @@ -72,15 +72,6 @@ object DatasetResource { .getInstance() .createDSLContext() - // Builds a resource logical path (/<resourceType>/ownerEmail/resourceName/relativePath). - private def logicalPath( - resourceType: ResourceType.Value, - ownerEmail: String, - resourceName: String, - relativePath: String - ): String = - s"$resourceType/$ownerEmail/$resourceName/$relativePath" - private def singleFileUploadMaxBytes(defaultMiB: Long = 20L): Long = SiteSettings.getLong("single_file_upload_max_size_mib", defaultMiB) * 1024L * 1024L @@ -271,8 +262,6 @@ object DatasetResource { fileNodes: List[LakeFSFileNode], size: Long ) - - case class CoverImageRequest(coverImage: String) } @Produces(Array(MediaType.APPLICATION_JSON, "image/jpeg", "application/pdf")) @@ -282,26 +271,39 @@ class DatasetResource extends LazyLogging { private val ERR_DATASET_VERSION_NOT_FOUND_MESSAGE = "The version of the dataset not found" private val EXPIRATION_MINUTES = 5 - private val COVER_IMAGE_SIZE_LIMIT_BYTES: Long = 10 * 1024 * 1024 // 10 MB - private val ALLOWED_IMAGE_EXTENSIONS: Set[String] = Set(".jpg", ".jpeg", ".png", ".gif", ".webp") + // varchar(246) on DBs upgraded through sql/updates/18.sql, narrower than model's. + private val COVER_IMAGE_MAX_PATH_LENGTH = 246 private val resourceType = ResourceType.Dataset /** - * Helper function to get the dataset from DB with additional information including user access privilege and owner email + * The single read rule for a dataset: anonymous callers get public datasets only, a + * signed-in caller goes through userHasReadAccess. Shared with getDashboardDataset + * so the cover endpoints cannot drift from it. */ - private def getDashboardDataset( + private def requireReadAccess( ctx: DSLContext, did: Integer, requesterUid: Option[Integer] - ): DashboardDataset = { - val targetDataset = getDatasetByID(ctx, did) - - if (requesterUid.isEmpty && !targetDataset.getIsPublic) { + ): Dataset = { + val dataset = getDatasetByID(ctx, did) + if (requesterUid.isEmpty && !dataset.getIsPublic) { throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE) } else if (requesterUid.exists(uid => !userHasReadAccess(ctx, did, uid))) { throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE) } + dataset + } + + /** + * Helper function to get the dataset from DB with additional information including user access privilege and owner email + */ + private def getDashboardDataset( + ctx: DSLContext, + did: Integer, + requesterUid: Option[Integer] + ): DashboardDataset = { + val targetDataset = requireReadAccess(ctx, did, requesterUid) val userAccessPrivilege = requesterUid .map(uid => getDatasetUserAccessPrivilege(ctx, did, uid)) @@ -1188,39 +1190,16 @@ class DatasetResource extends LazyLogging { throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE) } - if (request.coverImage == null || request.coverImage.trim.isEmpty) { - throw new BadRequestException("Cover image path is required") - } - - val normalized = ResourceNaming.validateAndNormalizeFilePathOrThrow(request.coverImage) - - val extension = FilenameUtils.getExtension(normalized) - if (extension == null || !ALLOWED_IMAGE_EXTENSIONS.contains(s".$extension".toLowerCase)) { - throw new BadRequestException("Invalid file type") - } - - val owner = getOwner(ctx, did) - val document = DocumentFactory - .openReadonlyDocument( - FileResolver.resolve( - logicalPath(resourceType, owner.getEmail, dataset.getName, normalized) - ) - ) - .asInstanceOf[OnVersionedFileResource] - - val fileSize = withLakeFSErrorHandling(s"reading the size of cover image '$normalized'") { - LakeFSStorageClient.getFileSize( - document.getRepositoryName(), - document.getVersionHash(), - document.getFileRelativePath() - ) - } + val normalized = + CoverImageUtils.validatePathOrThrow(request.coverImage, COVER_IMAGE_MAX_PATH_LENGTH) - if (fileSize > COVER_IMAGE_SIZE_LIMIT_BYTES) { - throw new BadRequestException( - s"Cover image must be less than ${COVER_IMAGE_SIZE_LIMIT_BYTES / (1024 * 1024)} MB" - ) - } + val document = CoverImageUtils.openCoverOrBadRequest( + resourceType, + getOwner(ctx, did).getEmail, + dataset.getName, + normalized + ) + CoverImageUtils.requireWithinSizeLimit(CoverImageUtils.fileSizeOf(document, normalized)) dataset.setCoverImage(normalized) new DatasetDao(ctx.configuration()).update(dataset) @@ -1243,39 +1222,19 @@ class DatasetResource extends LazyLogging { @Auth sessionUser: Optional[SessionUser] ): Response = { withTransaction(context) { ctx => - val dataset = getDatasetByID(ctx, did) - - val requesterUid = if (sessionUser.isPresent) Some(sessionUser.get().getUid) else None - - if (requesterUid.isEmpty && !dataset.getIsPublic) { - throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE) - } else if (requesterUid.exists(uid => !userHasReadAccess(ctx, did, uid))) { - throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE) - } + val dataset = requireReadAccess(ctx, did, sessionUser.toScala.map(_.getUid)) val coverImage = Option(dataset.getCoverImage).getOrElse( throw new NotFoundException("No cover image") ) - val owner = getOwner(ctx, did) - val fullPath = - logicalPath(resourceType, owner.getEmail, dataset.getName, coverImage) - - val document = DocumentFactory - .openReadonlyDocument(FileResolver.resolve(fullPath)) - .asInstanceOf[OnVersionedFileResource] + val document = CoverImageUtils + .openCover(resourceType, getOwner(ctx, did).getEmail, dataset.getName, coverImage) + .getOrElse(throw new NotFoundException("No cover image")) - val presignedUrl = withLakeFSErrorHandling( - s"generating a presigned URL for cover image '$coverImage'" - ) { - LakeFSStorageClient.getFilePresignedUrl( - document.getRepositoryName(), - document.getVersionHash(), - document.getFileRelativePath() - ) - } - - Response.temporaryRedirect(new URI(presignedUrl)).build() + Response + .temporaryRedirect(new URI(CoverImageUtils.presignedUrl(document, coverImage))) + .build() } } @@ -1293,39 +1252,16 @@ class DatasetResource extends LazyLogging { @Auth sessionUser: Optional[SessionUser] ): Response = { withTransaction(context) { ctx => - val dataset = getDatasetByID(ctx, did) - - val requesterUid = if (sessionUser.isPresent) Some(sessionUser.get().getUid) else None - - if (requesterUid.isEmpty && !dataset.getIsPublic) { - throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE) - } else if (requesterUid.exists(uid => !userHasReadAccess(ctx, did, uid))) { - throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE) - } + val dataset = requireReadAccess(ctx, did, sessionUser.toScala.map(_.getUid)) Option(dataset.getCoverImage) match { case None => Response.ok(Map("url" -> null)).build() case Some(coverImage) => - val owner = getOwner(ctx, did) - val fullPath = - logicalPath(resourceType, owner.getEmail, dataset.getName, coverImage) - - val document = DocumentFactory - .openReadonlyDocument(FileResolver.resolve(fullPath)) - .asInstanceOf[OnVersionedFileResource] - - val presignedUrl = withLakeFSErrorHandling( - s"generating a presigned URL for cover image '$coverImage'" - ) { - LakeFSStorageClient.getFilePresignedUrl( - document.getRepositoryName(), - document.getVersionHash(), - document.getFileRelativePath() - ) - } - - Response.ok(Map("url" -> presignedUrl)).build() + val url = CoverImageUtils + .openCover(resourceType, getOwner(ctx, did).getEmail, dataset.getName, coverImage) + .map(CoverImageUtils.presignedUrl(_, coverImage)) + Response.ok(Map("url" -> url.orNull)).build() } } } diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala b/file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala index 7c323928c8..1ef4fddfc7 100644 --- a/file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala +++ b/file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala @@ -40,11 +40,14 @@ import org.apache.texera.service.`type`.{Diff, ExistingUploadFilesRequest, LakeF import org.apache.texera.service.resource.ResourceTables.{Model => MODEL_RESOURCE} import org.apache.texera.service.resource.ModelAccessResource._ import org.apache.texera.service.resource.ModelResource.{context, _} +import org.apache.texera.service.util.CoverImageUtils +import org.apache.texera.service.util.CoverImageUtils.CoverImageRequest import org.apache.texera.service.util.S3StorageClient import org.apache.texera.service.util.LakeFSExceptionHandler.withLakeFSErrorHandling import org.jooq.{DSLContext, EnumType} import java.io.InputStream +import java.net.URI import java.util.Optional import scala.jdk.CollectionConverters._ import scala.jdk.OptionConverters._ @@ -55,13 +58,22 @@ object ModelResource { // Callers may omit the framework; it is a display label, not a validation gate for files. val DEFAULT_FRAMEWORK = "pytorch" - // Recognised values for the `framework` and `format` labels. They are metadata, not file - // checks -- a loader dispatches on them, so an unknown value is rejected at creation - // rather than surfacing later as an unloadable model. - val SUPPORTED_FRAMEWORKS: Set[String] = Set("pytorch", "tensorflow", "onnx", "sklearn") + // Metadata, not file checks: a loader dispatches on these, so an unknown value is + // rejected up front. "other" is in both sets so an unnamed one never blocks an upload. + val SUPPORTED_FRAMEWORKS: Set[String] = + Set("pytorch", "tensorflow", "onnx", "sklearn", "other") val SUPPORTED_FORMATS: Set[String] = - Set("torchscript", "state-dict", "safetensors", "onnx", "savedmodel", "joblib", "pickle") + Set( + "torchscript", + "state-dict", + "safetensors", + "onnx", + "savedmodel", + "joblib", + "pickle", + "other" + ) private def validateLabel(field: String, value: String, allowed: Set[String]): Unit = { if (!allowed.contains(value)) { @@ -71,6 +83,20 @@ object ModelResource { } } + /** + * Trims a framework/format label, treats blank as absent, validates the rest. + * Shared by create and update so the edit form cannot reject what create accepted. + */ + private def normalizeLabel( + field: String, + value: String, + allowed: Set[String] + ): Option[String] = { + val normalized = Option(value).map(_.trim).filter(_.nonEmpty) + normalized.foreach(validateLabel(field, _, allowed)) + normalized + } + // Matches model_version.name VARCHAR(128). private val MAX_VERSION_NAME_LENGTH = 128 @@ -147,6 +173,10 @@ object ModelResource { case class ModelNameModification(mid: Integer, name: String) + case class ModelFrameworkModification(mid: Integer, framework: String) + + case class ModelFormatModification(mid: Integer, format: String) + case class DashboardModelVersion( modelVersion: ModelVersion, fileNodes: List[LakeFSFileNode] @@ -173,13 +203,7 @@ class ModelResource extends LazyLogging { mid: Integer, requesterUid: Option[Integer] ): DashboardModel = { - val targetModel = getModelByID(ctx, mid) - - if (requesterUid.isEmpty && !targetModel.getIsPublic) { - throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE) - } else if (requesterUid.exists(uid => !userHasReadAccess(ctx, mid, uid))) { - throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE) - } + val targetModel = requireReadAccess(ctx, mid, requesterUid) val userAccessPrivilege = requesterUid .map(uid => getModelUserAccessPrivilege(ctx, mid, uid)) @@ -226,11 +250,9 @@ class ModelResource extends LazyLogging { model.setIsPublic(isModelPublic) model.setIsDownloadable(isModelDownloadable) model.setOwnerUid(uid) - val framework = - Option(request.framework).map(_.trim).filter(_.nonEmpty).getOrElse(DEFAULT_FRAMEWORK) - validateLabel("framework", framework, SUPPORTED_FRAMEWORKS) - val format = Option(request.format).map(_.trim).filter(_.nonEmpty) - format.foreach(validateLabel("format", _, SUPPORTED_FORMATS)) + val framework = normalizeLabel("framework", request.framework, SUPPORTED_FRAMEWORKS) + .getOrElse(DEFAULT_FRAMEWORK) + val format = normalizeLabel("format", request.format, SUPPORTED_FORMATS) model.setFramework(framework) model.setFormat(format.orNull) @@ -339,6 +361,53 @@ class ModelResource extends LazyLogging { } } + @POST + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + @RolesAllowed(Array("REGULAR", "ADMIN")) + @Path("/update/framework") + def updateModelFramework( + modificator: ModelFrameworkModification, + @Auth sessionUser: SessionUser + ): Response = { + withTransaction(context) { ctx => + val modelDao = new ModelDao(ctx.configuration()) + val model = getModelByID(ctx, modificator.mid) + if (!userHasWriteAccess(ctx, modificator.mid, sessionUser.getUid)) { + throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE) + } + + model.setFramework( + normalizeLabel("framework", modificator.framework, SUPPORTED_FRAMEWORKS) + .getOrElse(DEFAULT_FRAMEWORK) + ) + modelDao.update(model) + Response.ok().build() + } + } + + @POST + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + @RolesAllowed(Array("REGULAR", "ADMIN")) + @Path("/update/format") + def updateModelFormat( + modificator: ModelFormatModification, + @Auth sessionUser: SessionUser + ): Response = { + withTransaction(context) { ctx => + val modelDao = new ModelDao(ctx.configuration()) + val model = getModelByID(ctx, modificator.mid) + if (!userHasWriteAccess(ctx, modificator.mid, sessionUser.getUid)) { + throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE) + } + + model.setFormat(normalizeLabel("format", modificator.format, SUPPORTED_FORMATS).orNull) + modelDao.update(model) + Response.ok().build() + } + } + @POST @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON)) @@ -776,6 +845,22 @@ class ModelResource extends LazyLogging { }) } + /** Version list of a public model, for a logged-out visitor on a hub model page. */ + @GET + @PermitAll + @Path("/{mid}/publicVersion/list") + def getPublicModelVersionList( + @PathParam("mid") mid: Integer + ): List[ModelVersion] = { + withTransaction(context)(ctx => { + val model = getModelByID(ctx, mid) + if (!isModelPublic(ctx, model.getMid)) { + throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE) + } + fetchModelVersions(ctx, model.getMid) + }) + } + @GET @RolesAllowed(Array("REGULAR", "ADMIN")) @Path("/{mid}/version/latest") @@ -807,6 +892,17 @@ class ModelResource extends LazyLogging { withTransaction(context)(ctx => fetchModelVersionRootFileNodes(ctx, mid, mvid, Some(uid))) } + /** Anonymous half of the endpoint above. */ + @GET + @PermitAll + @Path("/{mid}/publicVersion/{mvid}/rootFileNodes") + def retrievePublicModelVersionRootFileNodes( + @PathParam("mid") mid: Integer, + @PathParam("mvid") mvid: Integer + ): ModelVersionRootFileNodesResponse = { + withTransaction(context)(ctx => fetchModelVersionRootFileNodes(ctx, mid, mvid, None)) + } + // =========================================================================== // File upload (one-shot + session-based multipart) // =========================================================================== @@ -911,10 +1007,124 @@ class ModelResource extends LazyLogging { ) } + // =========================================================================== + // Cover image + // =========================================================================== + + /** Points the model card at a committed image inside the model, "<version>/<file>". */ + @POST + @RolesAllowed(Array("REGULAR", "ADMIN")) + @Path("/{mid}/update/cover") + @Consumes(Array(MediaType.APPLICATION_JSON)) + def updateModelCoverImage( + @PathParam("mid") mid: Integer, + request: CoverImageRequest, + @Auth sessionUser: SessionUser + ): Response = { + withTransaction(context) { ctx => + val model = getModelByID(ctx, mid) + if (!userHasWriteAccess(ctx, mid, sessionUser.getUid)) { + throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE) + } + + val normalized = + CoverImageUtils.validatePathOrThrow(request.coverImage, CoverImageUtils.MAX_PATH_LENGTH) + + val document = CoverImageUtils.openCoverOrBadRequest( + ResourceType.Model, + getOwner(ctx, mid).getEmail, + model.getName, + normalized + ) + CoverImageUtils.requireWithinSizeLimit(CoverImageUtils.fileSizeOf(document, normalized)) + + model.setCoverImage(normalized) + new ModelDao(ctx.configuration()).update(model) + Response.ok(Map("coverImage" -> normalized)).build() + } + } + + /** 307 redirect to the cover's presigned S3 URL. */ + @GET + @PermitAll + @Path("/{mid}/cover") + def getModelCover( + @PathParam("mid") mid: Integer, + @Auth sessionUser: Optional[SessionUser] + ): Response = { + withTransaction(context) { ctx => + val model = requireCoverReadAccess(ctx, mid, sessionUser) + val coverImage = Option(model.getCoverImage).getOrElse( + throw new NotFoundException("No cover image") + ) + + val document = CoverImageUtils + .openCover(ResourceType.Model, getOwner(ctx, mid).getEmail, model.getName, coverImage) + .getOrElse(throw new NotFoundException("No cover image")) + + Response + .temporaryRedirect(new URI(CoverImageUtils.presignedUrl(document, coverImage))) + .build() + } + } + + /** + * Presigned cover URL as JSON. Needed for private models because `<img src>` + * cannot attach the Authorization header that GET /{mid}/cover requires. + */ + @GET + @PermitAll + @Path("/{mid}/cover-url") + @Produces(Array(MediaType.APPLICATION_JSON)) + def getModelCoverUrl( + @PathParam("mid") mid: Integer, + @Auth sessionUser: Optional[SessionUser] + ): Response = { + withTransaction(context) { ctx => + val model = requireCoverReadAccess(ctx, mid, sessionUser) + + Option(model.getCoverImage) match { + case None => Response.ok(Map("url" -> null)).build() + case Some(coverImage) => + val url = CoverImageUtils + .openCover(ResourceType.Model, getOwner(ctx, mid).getEmail, model.getName, coverImage) + .map(CoverImageUtils.presignedUrl(_, coverImage)) + Response.ok(Map("url" -> url.orNull)).build() + } + } + } + // =========================================================================== // Private helpers // =========================================================================== + /** A cover is readable by anyone for a public model, and by read-grantees otherwise. */ + private def requireCoverReadAccess( + ctx: DSLContext, + mid: Integer, + sessionUser: Optional[SessionUser] + ): Model = + requireReadAccess(ctx, mid, sessionUser.toScala.map(_.getUid)) + + /** + * The single read rule for a model: anonymous callers get public models only, a + * signed-in caller goes through userHasReadAccess. Shared with getDashboardModel + * so the cover endpoints cannot drift from it. + */ + private def requireReadAccess( + ctx: DSLContext, + mid: Integer, + requesterUid: Option[Integer] + ): Model = { + val model = getModelByID(ctx, mid) + if (requesterUid.isEmpty && !model.getIsPublic) { + throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE) + } else if (requesterUid.exists(uid => !userHasReadAccess(ctx, mid, uid))) { + throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE) + } + model + } + private def fetchModelVersions(ctx: DSLContext, mid: Integer): List[ModelVersion] = { ctx .selectFrom(MODEL_VERSION) diff --git a/file-service/src/main/scala/org/apache/texera/service/util/CoverImageUtils.scala b/file-service/src/main/scala/org/apache/texera/service/util/CoverImageUtils.scala new file mode 100644 index 0000000000..8ac29173c3 --- /dev/null +++ b/file-service/src/main/scala/org/apache/texera/service/util/CoverImageUtils.scala @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.service.util + +import jakarta.ws.rs.BadRequestException +import org.apache.commons.io.FilenameUtils +import org.apache.commons.vfs2.FileNotFoundException +import org.apache.texera.amber.core.storage.model.OnVersionedFileResource +import org.apache.texera.amber.core.storage.util.LakeFSStorageClient +import org.apache.texera.amber.core.storage.{DocumentFactory, FileResolver, ResourceType} +import org.apache.texera.service.resource.ResourceNaming +import org.apache.texera.service.util.LakeFSExceptionHandler.withLakeFSErrorHandling + +/** + * Resource-agnostic halves of the cover-image endpoints, shared by datasets and models. + * The extension allowlist is a security control: a cover is served to the browser as a + * presigned URL, so a duplicated allowlist that drifts is a real risk. + */ +object CoverImageUtils { + + /** Committed image, relative to the resource root, e.g. "v1 - init/cover.jpg". */ + case class CoverImageRequest(coverImage: String) + + val SIZE_LIMIT_BYTES: Long = 10 * 1024 * 1024 // 10 MB + + /** varchar(255) on `model`; `dataset` passes its own narrower limit. */ + val MAX_PATH_LENGTH: Int = 255 + + private val ALLOWED_EXTENSIONS: Set[String] = Set(".jpg", ".jpeg", ".png", ".gif", ".webp") + + /** Normalizes a cover path relative to the resource root and enforces the image allowlist. */ + def validatePathOrThrow(coverImage: String, maxPathLength: Int): String = { + if (coverImage == null || coverImage.trim.isEmpty) { + throw new BadRequestException("Cover image path is required") + } + + val normalized = ResourceNaming.validateAndNormalizeFilePathOrThrow(coverImage) + + val extension = FilenameUtils.getExtension(normalized) + if (extension == null || !ALLOWED_EXTENSIONS.contains(s".$extension".toLowerCase)) { + throw new BadRequestException("Invalid file type") + } + + // FileResolver needs <version>/<file>; a bare name builds a path it cannot parse. + if (normalized.split("/").length < 2) { + throw new BadRequestException( + "Cover image path must be relative to the resource root, as '<version>/<file>'" + ) + } + + // Guard the column width here so an over-long path is a 400, not a jOOQ-wrapped 500. + if (normalized.length > maxPathLength) { + throw new BadRequestException(s"Cover image path must be at most $maxPathLength characters") + } + normalized + } + + /** + * Opens the committed image at the cover path, or None if it no longer resolves. + * An Option because FileResolver's IOException has no mapper and would otherwise be + * an opaque 500 on every card render; callers decide what an unresolvable cover means. + */ + def openCover( + resourceType: ResourceType.Value, + ownerEmail: String, + resourceName: String, + normalized: String + ): Option[OnVersionedFileResource] = + try { + Some( + DocumentFactory + .openReadonlyDocument( + FileResolver.resolve(s"$resourceType/$ownerEmail/$resourceName/$normalized") + ) + .asInstanceOf[OnVersionedFileResource] + ) + } catch { + case _: FileNotFoundException => None + } + + /** Write-path reading of an unresolvable cover: the client sent a bad path. */ + def openCoverOrBadRequest( + resourceType: ResourceType.Value, + ownerEmail: String, + resourceName: String, + normalized: String + ): OnVersionedFileResource = + openCover(resourceType, ownerEmail, resourceName, normalized).getOrElse( + throw new BadRequestException(s"No committed file at cover image path '$normalized'") + ) + + def requireWithinSizeLimit(fileSize: Long): Unit = + if (fileSize > SIZE_LIMIT_BYTES) { + throw new BadRequestException( + s"Cover image must be less than ${SIZE_LIMIT_BYTES / (1024 * 1024)} MB" + ) + } + + def fileSizeOf(document: OnVersionedFileResource, normalized: String): Long = + withLakeFSErrorHandling(s"reading the size of cover image '$normalized'") { + LakeFSStorageClient.getFileSize( + document.getRepositoryName(), + document.getVersionHash(), + document.getFileRelativePath() + ) + } + + /** Presigned S3 URL for an already-authorized cover image. */ + def presignedUrl(document: OnVersionedFileResource, normalized: String): String = + withLakeFSErrorHandling(s"generating a presigned URL for cover image '$normalized'") { + LakeFSStorageClient.getFilePresignedUrl( + document.getRepositoryName(), + document.getVersionHash(), + document.getFileRelativePath() + ) + } +} diff --git a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala index fd1a525331..e0931570fc 100644 --- a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala +++ b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala @@ -44,6 +44,7 @@ import org.apache.texera.dao.jooq.generated.tables.pojos.{ } import org.apache.texera.service.MockLakeFS import org.apache.texera.service.`type`.{ExistingUploadFile, ExistingUploadFilesRequest} +import org.apache.texera.service.util.CoverImageUtils import org.apache.texera.service.util.S3StorageClient import org.jooq.SQLDialect import org.jooq.impl.DSL @@ -3405,7 +3406,7 @@ class DatasetResourceSpec ) maliciousPaths.foreach { path => - val request = DatasetResource.CoverImageRequest(path) + val request = CoverImageUtils.CoverImageRequest(path) assertThrows[BadRequestException] { datasetResource.updateDatasetCoverImage( @@ -3424,7 +3425,7 @@ class DatasetResourceSpec ) absolutePaths.foreach { path => - val request = DatasetResource.CoverImageRequest(path) + val request = CoverImageUtils.CoverImageRequest(path) assertThrows[BadRequestException] { datasetResource.updateDatasetCoverImage( @@ -3444,7 +3445,7 @@ class DatasetResourceSpec ) invalidPaths.foreach { path => - val request = DatasetResource.CoverImageRequest(path) + val request = CoverImageUtils.CoverImageRequest(path) assertThrows[BadRequestException] { datasetResource.updateDatasetCoverImage( @@ -3460,7 +3461,7 @@ class DatasetResourceSpec assertThrows[BadRequestException] { datasetResource.updateDatasetCoverImage( baseDataset.getDid, - DatasetResource.CoverImageRequest(""), + CoverImageUtils.CoverImageRequest(""), sessionUser ) } @@ -3468,14 +3469,14 @@ class DatasetResourceSpec assertThrows[BadRequestException] { datasetResource.updateDatasetCoverImage( baseDataset.getDid, - DatasetResource.CoverImageRequest(null), + CoverImageUtils.CoverImageRequest(null), sessionUser ) } } it should "reject when user lacks WRITE access" in { - val request = DatasetResource.CoverImageRequest("v1/cover.jpg") + val request = CoverImageUtils.CoverImageRequest("v1/cover.jpg") assertThrows[ForbiddenException] { datasetResource.updateDatasetCoverImage( @@ -3489,7 +3490,7 @@ class DatasetResourceSpec it should "set cover image successfully" in { testDatasetVersion - val request = DatasetResource.CoverImageRequest(testCoverImagePath) + val request = CoverImageUtils.CoverImageRequest(testCoverImagePath) val response = datasetResource.updateDatasetCoverImage( baseDataset.getDid, request, diff --git a/file-service/src/test/scala/org/apache/texera/service/resource/ModelApiForUiSpec.scala b/file-service/src/test/scala/org/apache/texera/service/resource/ModelApiForUiSpec.scala index cf55ca09fa..13742b0014 100644 --- a/file-service/src/test/scala/org/apache/texera/service/resource/ModelApiForUiSpec.scala +++ b/file-service/src/test/scala/org/apache/texera/service/resource/ModelApiForUiSpec.scala @@ -350,6 +350,130 @@ class ModelApiForUiSpec newModel(format = null).model.getFormat shouldBe null } + it should "accept \"other\" for a framework or format we have no name for" in { + newModel(framework = "other", format = "other").model.getFramework shouldEqual "other" + } + + "updateModelFramework" should "relabel an existing model" in { + val mid = newModel(framework = "pytorch").model.getMid + + modelResource + .updateModelFramework(ModelResource.ModelFrameworkModification(mid, "onnx"), sessionUser) + .getStatus shouldEqual 200 + + modelResource.getModel(mid, sessionUser).model.getFramework shouldEqual "onnx" + } + + it should "reject a framework outside the supported set" in { + val mid = newModel().model.getMid + + val ex = intercept[BadRequestException] { + modelResource.updateModelFramework( + ModelResource.ModelFrameworkModification(mid, "caffe"), + sessionUser + ) + } + ex.getMessage should include("Unsupported framework 'caffe'") + modelResource.getModel(mid, sessionUser).model.getFramework shouldEqual "pytorch" + } + + it should "reject a caller without write access" in { + val mid = newModel(isPublic = true).model.getMid + + assertThrows[ForbiddenException] { + modelResource.updateModelFramework( + ModelResource.ModelFrameworkModification(mid, "onnx"), + strangerSession + ) + } + } + + it should "reject an unknown model id" in { + assertThrows[NotFoundException] { + modelResource.updateModelFramework( + ModelResource.ModelFrameworkModification(999999, "onnx"), + sessionUser + ) + } + } + + "updateModelFormat" should "set a format on a model created without one" in { + val mid = newModel(format = null).model.getMid + + modelResource + .updateModelFormat(ModelResource.ModelFormatModification(mid, "safetensors"), sessionUser) + .getStatus shouldEqual 200 + + modelResource.getModel(mid, sessionUser).model.getFormat shouldEqual "safetensors" + } + + it should "reject a format outside the supported set" in { + val mid = newModel(format = "pickle").model.getMid + + assertThrows[BadRequestException] { + modelResource.updateModelFormat( + ModelResource.ModelFormatModification(mid, "gguf"), + sessionUser + ) + } + modelResource.getModel(mid, sessionUser).model.getFormat shouldEqual "pickle" + } + + it should "reject a caller without write access" in { + val mid = newModel(isPublic = true).model.getMid + + assertThrows[ForbiddenException] { + modelResource.updateModelFormat( + ModelResource.ModelFormatModification(mid, "onnx"), + strangerSession + ) + } + } + + "the label endpoints" should "trim a framework the way createModel does" in { + val mid = newModel(framework = "pytorch").model.getMid + + modelResource + .updateModelFramework(ModelResource.ModelFrameworkModification(mid, "onnx "), sessionUser) + .getStatus shouldEqual 200 + + modelResource.getModel(mid, sessionUser).model.getFramework shouldEqual "onnx" + } + + it should "treat a blank framework as a reset to the default, not as invalid" in { + val mid = newModel(framework = "onnx").model.getMid + + Seq(null, "", " ").foreach { blank => + modelResource + .updateModelFramework(ModelResource.ModelFrameworkModification(mid, blank), sessionUser) + .getStatus shouldEqual 200 + modelResource.getModel(mid, sessionUser).model.getFramework shouldEqual + ModelResource.DEFAULT_FRAMEWORK + } + } + + // A mislabelled model shouldn't need recreating, so a format must be clearable. + it should "clear the format when given a blank value" in { + val mid = newModel(format = "pickle").model.getMid + + Seq(null, "", " ").foreach { blank => + modelResource + .updateModelFormat(ModelResource.ModelFormatModification(mid, blank), sessionUser) + .getStatus shouldEqual 200 + modelResource.getModel(mid, sessionUser).model.getFormat shouldBe null + } + } + + it should "trim a format the way createModel does" in { + val mid = newModel(format = null).model.getMid + + modelResource + .updateModelFormat(ModelResource.ModelFormatModification(mid, " safetensors "), sessionUser) + .getStatus shouldEqual 200 + + modelResource.getModel(mid, sessionUser).model.getFormat shouldEqual "safetensors" + } + // =========================================================================== // listing sizes // =========================================================================== diff --git a/file-service/src/test/scala/org/apache/texera/service/resource/ModelHubApiSpec.scala b/file-service/src/test/scala/org/apache/texera/service/resource/ModelHubApiSpec.scala new file mode 100644 index 0000000000..8e999f045f --- /dev/null +++ b/file-service/src/test/scala/org/apache/texera/service/resource/ModelHubApiSpec.scala @@ -0,0 +1,398 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.service.resource + +import jakarta.ws.rs._ +import jakarta.ws.rs.core.Response +import org.apache.texera.auth.SessionUser +import org.apache.texera.dao.MockTexeraDB +import org.apache.texera.dao.jooq.generated.enums.{PrivilegeEnum, UserRoleEnum} +import org.apache.texera.dao.jooq.generated.tables.daos.{ModelDao, ModelUserAccessDao, UserDao} +import org.apache.texera.dao.jooq.generated.tables.pojos.{ModelUserAccess, ModelVersion, User} +import org.apache.texera.service.MockLakeFS +import org.apache.texera.service.util.CoverImageUtils +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.{BeforeAndAfterAll, OptionValues} + +import java.io.ByteArrayInputStream +import java.util.Optional + +// What a model page needs beyond the owner's dashboard: the cover image a card +// renders, and the anonymous halves of version browsing. +class ModelHubApiSpec + extends AnyFlatSpec + with Matchers + with OptionValues + with MockTexeraDB + with MockLakeFS + with BeforeAndAfterAll + with ResourceTestHelpers { + + private def mkUser(name: String): User = { + val user = new User + user.setName(name) + user.setEmail(s"[email protected]") + user.setRole(UserRoleEnum.ADMIN) + user + } + + private val ownerUser: User = mkUser("model_hub_owner") + private val readerUser: User = mkUser("model_hub_reader") + private val strangerUser: User = mkUser("model_hub_stranger") + + lazy val modelResource = new ModelResource() + lazy val modelDao = new ModelDao(getDSLContext.configuration()) + lazy val ownerSession = new SessionUser(ownerUser) + lazy val readerSession = new SessionUser(readerUser) + lazy val strangerSession = new SessionUser(strangerUser) + + override protected def beforeAll(): Unit = { + super.beforeAll() + initializeDBAndReplaceDSLContext() + val userDao = new UserDao(getDSLContext.configuration()) + Seq(ownerUser, readerUser, strangerUser).foreach(userDao.insert) + } + + override protected def afterAll(): Unit = { + try shutdownDB() + finally super.afterAll() + } + + // ---------- helpers ---------- + private def newModel(isPublic: Boolean): ModelResource.DashboardModel = + modelResource.createModel( + ModelResource.CreateModelRequest( + modelName = uniqueName("hub-model"), + modelDescription = "for model hub api tests", + isModelPublic = isPublic, + isModelDownloadable = true, + framework = "pytorch", + format = null + ), + ownerSession + ) + + private def upload(mid: Integer, path: String, bytes: Array[Byte]): Unit = + modelResource + .uploadOneFileToModel( + mid, + urlEnc(path), + "upload", + new ByteArrayInputStream(bytes), + mkHeaders(bytes.length.toLong), + ownerSession + ) + .getStatus shouldEqual 200 + + /** Commits `cover.jpg` plus a weights file, returning the generated version. */ + private def commitVersionWithCover(mid: Integer): ModelVersion = { + upload(mid, "cover.jpg", Array.fill[Byte](512)(0xff.toByte)) + upload(mid, "model.pt", Array.fill[Byte](64)(0x1)) + modelResource.createModelVersion("init", mid, ownerSession).modelVersion + } + + private def grantRead(mid: Integer, uid: Integer): Unit = + new ModelUserAccessDao(getDSLContext.configuration()) + .insert(new ModelUserAccess(mid, uid, PrivilegeEnum.READ)) + + private def urlOf(response: Response): Option[String] = + Option( + response.getEntity.asInstanceOf[Map[String, String]]("url") + ) + + private def setPublic(mid: Integer, isPublic: Boolean): Unit = { + val model = modelDao.fetchOneByMid(mid) + model.setIsPublic(isPublic) + modelDao.update(model) + } + + // =========================================================================== + // cover image: setting it + // =========================================================================== + "updateModelCoverImage" should "store the normalized path and echo it back" in { + val mid = newModel(isPublic = false).model.getMid + val version = commitVersionWithCover(mid) + val coverPath = s"${version.getName}/cover.jpg" + + val response = + modelResource.updateModelCoverImage( + mid, + CoverImageUtils.CoverImageRequest(coverPath), + ownerSession + ) + + response.getStatus shouldEqual 200 + response.getEntity.asInstanceOf[Map[String, String]]("coverImage") shouldEqual coverPath + modelDao.fetchOneByMid(mid).getCoverImage shouldEqual coverPath + } + + it should "reject a non-image extension" in { + val mid = newModel(isPublic = false).model.getMid + val version = commitVersionWithCover(mid) + + assertThrows[BadRequestException] { + modelResource.updateModelCoverImage( + mid, + CoverImageUtils.CoverImageRequest(s"${version.getName}/model.pt"), + ownerSession + ) + } + } + + it should "reject an empty path" in { + val mid = newModel(isPublic = false).model.getMid + + assertThrows[BadRequestException] { + modelResource.updateModelCoverImage(mid, CoverImageUtils.CoverImageRequest(""), ownerSession) + } + } + + it should "reject a bare file name, which builds a path FileResolver cannot parse" in { + val mid = newModel(isPublic = false).model.getMid + commitVersionWithCover(mid) + + assertThrows[BadRequestException] { + modelResource.updateModelCoverImage( + mid, + CoverImageUtils.CoverImageRequest("cover.jpg"), + ownerSession + ) + } + } + + it should "reject a well-formed path pointing at a version that does not exist" in { + val mid = newModel(isPublic = false).model.getMid + commitVersionWithCover(mid) + + assertThrows[BadRequestException] { + modelResource.updateModelCoverImage( + mid, + CoverImageUtils.CoverImageRequest("v9 - nope/cover.jpg"), + ownerSession + ) + } + } + + it should "reject a caller with only read access" in { + val mid = newModel(isPublic = false).model.getMid + val version = commitVersionWithCover(mid) + grantRead(mid, readerUser.getUid) + + assertThrows[ForbiddenException] { + modelResource.updateModelCoverImage( + mid, + CoverImageUtils.CoverImageRequest(s"${version.getName}/cover.jpg"), + readerSession + ) + } + } + + // =========================================================================== + // cover image: reading it + // =========================================================================== + "getModelCover" should "redirect a logged-out visitor to the presigned URL of a public model" in { + val mid = newModel(isPublic = true).model.getMid + val version = commitVersionWithCover(mid) + modelResource.updateModelCoverImage( + mid, + CoverImageUtils.CoverImageRequest(s"${version.getName}/cover.jpg"), + ownerSession + ) + + val response = modelResource.getModelCover(mid, Optional.empty()) + + response.getStatus shouldEqual 307 + response.getHeaderString("Location") should not be null + } + + it should "return 404 when no cover image is set" in { + val mid = newModel(isPublic = true).model.getMid + + assertThrows[NotFoundException] { + modelResource.getModelCover(mid, Optional.of(ownerSession)) + } + } + + it should "forbid a logged-out visitor on a private model" in { + val mid = newModel(isPublic = false).model.getMid + + assertThrows[ForbiddenException] { + modelResource.getModelCover(mid, Optional.empty()) + } + } + + it should "forbid a signed-in user with no grant on a private model" in { + val mid = newModel(isPublic = false).model.getMid + + assertThrows[ForbiddenException] { + modelResource.getModelCover(mid, Optional.of(strangerSession)) + } + } + + "getModelCoverUrl" should "return the presigned URL to the owner of a private model" in { + val mid = newModel(isPublic = false).model.getMid + val version = commitVersionWithCover(mid) + modelResource.updateModelCoverImage( + mid, + CoverImageUtils.CoverImageRequest(s"${version.getName}/cover.jpg"), + ownerSession + ) + + val response = modelResource.getModelCoverUrl(mid, Optional.of(ownerSession)) + + response.getStatus shouldEqual 200 + urlOf(response) shouldBe defined + } + + it should "return the presigned URL to a read-grantee of a private model" in { + val mid = newModel(isPublic = false).model.getMid + val version = commitVersionWithCover(mid) + modelResource.updateModelCoverImage( + mid, + CoverImageUtils.CoverImageRequest(s"${version.getName}/cover.jpg"), + ownerSession + ) + grantRead(mid, readerUser.getUid) + + urlOf(modelResource.getModelCoverUrl(mid, Optional.of(readerSession))) shouldBe defined + } + + // Used to be an unmapped IOException: a 500 on every card render, not one bad request. + it should "report no cover, not fail, when the stored path no longer resolves" in { + val mid = newModel(isPublic = true).model.getMid + val version = commitVersionWithCover(mid) + modelResource.updateModelCoverImage( + mid, + CoverImageUtils.CoverImageRequest(s"${version.getName}/cover.jpg"), + ownerSession + ) + + // Point the stored cover at a version that never existed. + val model = modelDao.fetchOneByMid(mid) + model.setCoverImage("v9 - deleted/cover.jpg") + modelDao.update(model) + + val response = modelResource.getModelCoverUrl(mid, Optional.of(ownerSession)) + response.getStatus shouldEqual 200 + urlOf(response) shouldBe empty + + assertThrows[NotFoundException] { + modelResource.getModelCover(mid, Optional.of(ownerSession)) + } + } + + it should "return a null url rather than 404 when no cover image is set" in { + val mid = newModel(isPublic = true).model.getMid + + val response = modelResource.getModelCoverUrl(mid, Optional.of(ownerSession)) + + response.getStatus shouldEqual 200 + urlOf(response) shouldBe empty + } + + it should "forbid a signed-in user with no grant on a private model" in { + val mid = newModel(isPublic = false).model.getMid + + assertThrows[ForbiddenException] { + modelResource.getModelCoverUrl(mid, Optional.of(strangerSession)) + } + } + + // =========================================================================== + // anonymous version browsing + // =========================================================================== + "getPublicModelVersionList" should "list versions of a public model without authentication" in { + val mid = newModel(isPublic = true).model.getMid + val version = commitVersionWithCover(mid) + + modelResource.getPublicModelVersionList(mid).map(_.getMvid) shouldEqual List(version.getMvid) + } + + it should "return an empty list for a public model with no versions" in { + modelResource.getPublicModelVersionList(newModel(isPublic = true).model.getMid) shouldBe empty + } + + it should "forbid listing versions of a private model" in { + val mid = newModel(isPublic = false).model.getMid + + assertThrows[ForbiddenException] { + modelResource.getPublicModelVersionList(mid) + } + } + + it should "reject an unknown model id" in { + assertThrows[NotFoundException] { + modelResource.getPublicModelVersionList(999999) + } + } + + it should "stop listing once the model is unpublished" in { + val mid = newModel(isPublic = true).model.getMid + commitVersionWithCover(mid) + modelResource.getPublicModelVersionList(mid) should have size 1 + + setPublic(mid, isPublic = false) + + assertThrows[ForbiddenException] { + modelResource.getPublicModelVersionList(mid) + } + } + + "retrievePublicModelVersionRootFileNodes" should "return the file tree of a public model version" in { + val mid = newModel(isPublic = true).model.getMid + val version = commitVersionWithCover(mid) + + val response = modelResource.retrievePublicModelVersionRootFileNodes(mid, version.getMvid) + + response.fileNodes.map(_.getName) should contain allOf ("cover.jpg", "model.pt") + response.size shouldEqual 512 + 64 + } + + it should "forbid the file tree of a private model version" in { + val mid = newModel(isPublic = false).model.getMid + val version = commitVersionWithCover(mid) + + assertThrows[ForbiddenException] { + modelResource.retrievePublicModelVersionRootFileNodes(mid, version.getMvid) + } + } + + it should "reject an unknown version id on a public model" in { + val mid = newModel(isPublic = true).model.getMid + + assertThrows[NotFoundException] { + modelResource.retrievePublicModelVersionRootFileNodes(mid, 999999) + } + } + + it should "agree with the authenticated endpoint on the same version" in { + val mid = newModel(isPublic = true).model.getMid + val version = commitVersionWithCover(mid) + + val anonymous = modelResource.retrievePublicModelVersionRootFileNodes(mid, version.getMvid) + val authenticated = + modelResource.retrieveModelVersionRootFileNodes(mid, version.getMvid, ownerSession) + + anonymous.fileNodes.map(_.getName).sorted shouldEqual + authenticated.fileNodes.map(_.getName).sorted + anonymous.size shouldEqual authenticated.size + } +} diff --git a/file-service/src/test/scala/org/apache/texera/service/resource/ModelResourcePermissionsSpec.scala b/file-service/src/test/scala/org/apache/texera/service/resource/ModelResourcePermissionsSpec.scala index 01851aa64b..b225c076d7 100644 --- a/file-service/src/test/scala/org/apache/texera/service/resource/ModelResourcePermissionsSpec.scala +++ b/file-service/src/test/scala/org/apache/texera/service/resource/ModelResourcePermissionsSpec.scala @@ -43,12 +43,16 @@ class ModelResourcePermissionsSpec extends AnyFlatSpec with Matchers { private def rolesOf(m: Method): Option[RolesAllowed] = Option(m.getAnnotation(classOf[RolesAllowed])) - // Anonymous-readable, mirroring the dataset side: the public model read plus the two - // presign routes a public model's files are fetched through. + // Anonymous-readable, mirroring the dataset side. The cover reads do their own + // public/grant check, so a private model's cover is still refused. private val publicEndpointMethods: Set[String] = Set( "getPublicModel", "getPublicPresignedUrl", - "getPublicPresignedUrlWithS3" + "getPublicPresignedUrlWithS3", + "getPublicModelVersionList", + "retrievePublicModelVersionRootFileNodes", + "getModelCover", + "getModelCoverUrl" ) "ModelResource" should "expose HTTP endpoints (sanity check for the reflection scan)" in { diff --git a/file-service/src/test/scala/org/apache/texera/service/util/CoverImageUtilsSpec.scala b/file-service/src/test/scala/org/apache/texera/service/util/CoverImageUtilsSpec.scala new file mode 100644 index 0000000000..bc17afa06b --- /dev/null +++ b/file-service/src/test/scala/org/apache/texera/service/util/CoverImageUtilsSpec.scala @@ -0,0 +1,158 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.service.util + +import jakarta.ws.rs.BadRequestException +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +/** + * The extension allowlist is a security control: a cover path is served to the browser + * as a presigned URL, so an active document (.svg, .html) is a stored-XSS vector. + */ +class CoverImageUtilsSpec extends AnyFlatSpec with Matchers { + + private val maxLen = CoverImageUtils.MAX_PATH_LENGTH + + // -- accepted paths --------------------------------------------------------- + + "validatePathOrThrow" should "accept each allowed image extension" in { + Seq("jpg", "jpeg", "png", "gif", "webp").foreach { ext => + CoverImageUtils.validatePathOrThrow(s"v1/cover.$ext", maxLen) shouldBe s"v1/cover.$ext" + } + } + + it should "accept uppercase and mixed-case extensions" in { + CoverImageUtils.validatePathOrThrow("v1/COVER.JPG", maxLen) shouldBe "v1/COVER.JPG" + CoverImageUtils.validatePathOrThrow("v1/Cover.PnG", maxLen) shouldBe "v1/Cover.PnG" + } + + it should "accept the generated version-name form, which contains spaces" in { + CoverImageUtils.validatePathOrThrow("v1 - init/cover.jpg", maxLen) shouldBe + "v1 - init/cover.jpg" + } + + it should "accept unicode in the file name" in { + CoverImageUtils.validatePathOrThrow("v1/カバー画像.jpg", maxLen) shouldBe "v1/カバー画像.jpg" + } + + it should "accept a double extension whose last segment is allowed" in { + CoverImageUtils.validatePathOrThrow("v1/cover.png.jpg", maxLen) shouldBe "v1/cover.png.jpg" + } + + it should "normalize redundant path segments" in { + CoverImageUtils.validatePathOrThrow("v1/./cover.jpg", maxLen) shouldBe "v1/cover.jpg" + CoverImageUtils.validatePathOrThrow("v1/sub/../cover.jpg", maxLen) shouldBe "v1/cover.jpg" + } + + // -- rejected paths --------------------------------------------------------- + + it should "reject path traversal above the root" in { + Seq("../../../etc/passwd", "v1/../../secret.jpg", "../escape.jpg").foreach { p => + a[BadRequestException] should be thrownBy CoverImageUtils.validatePathOrThrow(p, maxLen) + } + } + + it should "reject absolute paths" in { + Seq("/etc/passwd.jpg", "C:/x.jpg").foreach { p => + a[BadRequestException] should be thrownBy CoverImageUtils.validatePathOrThrow(p, maxLen) + } + } + + it should "reject null, empty and whitespace-only paths" in { + Seq(null, "", " ").foreach { p => + a[BadRequestException] should be thrownBy CoverImageUtils.validatePathOrThrow(p, maxLen) + } + } + + it should "reject a non-image extension" in { + Seq("v1/cover.js", "v1/cover.pdf", "v1/cover.csv", "v1/weights.safetensors").foreach { p => + a[BadRequestException] should be thrownBy CoverImageUtils.validatePathOrThrow(p, maxLen) + } + } + + it should "reject .svg and .html, which the browser would execute" in { + Seq("v1/cover.svg", "v1/cover.html", "v1/cover.htm").foreach { p => + a[BadRequestException] should be thrownBy CoverImageUtils.validatePathOrThrow(p, maxLen) + } + } + + it should "reject a path with no extension or a trailing dot" in { + Seq("v1/cover", "v1/cover.").foreach { p => + a[BadRequestException] should be thrownBy CoverImageUtils.validatePathOrThrow(p, maxLen) + } + } + + it should "reject a double extension whose last segment is not allowed" in { + a[BadRequestException] should be thrownBy + CoverImageUtils.validatePathOrThrow("v1/cover.jpg.js", maxLen) + } + + it should "reject a bare file name with no version segment" in { + Seq("cover.jpg", "./cover.png", "sub/../cover.gif").foreach { p => + withClue(s"$p: ") { + a[BadRequestException] should be thrownBy CoverImageUtils.validatePathOrThrow(p, maxLen) + } + } + } + + it should "accept a path nested deeper than <version>/<file>" in { + CoverImageUtils.validatePathOrThrow("v1/img/sub/cover.jpg", maxLen) shouldBe + "v1/img/sub/cover.jpg" + } + + // -- length boundary -------------------------------------------------------- + + it should "accept a path exactly at the column length and reject one over it" in { + val suffix = "/c.jpg" + val atLimit = "v" + "a" * (255 - suffix.length - 1) + suffix + atLimit.length shouldBe 255 + CoverImageUtils.validatePathOrThrow(atLimit, 255) shouldBe atLimit + + val overLimit = atLimit + "x" + a[BadRequestException] should be thrownBy CoverImageUtils.validatePathOrThrow(overLimit, 255) + } + + it should "honour the narrower limit datasets pass" in { + // dataset.cover_image is varchar(246), so the caller's limit must win. + val suffix = "/c.jpg" + val at246 = "v" + "a" * (246 - suffix.length - 1) + suffix + CoverImageUtils.validatePathOrThrow(at246, 246) shouldBe at246 + a[BadRequestException] should be thrownBy CoverImageUtils.validatePathOrThrow(at246, 245) + } + + // -- size limit ------------------------------------------------------------- + + "requireWithinSizeLimit" should "accept a file at exactly the limit" in { + noException should be thrownBy + CoverImageUtils.requireWithinSizeLimit(CoverImageUtils.SIZE_LIMIT_BYTES) + } + + it should "reject a file one byte over the limit, naming the limit in MB" in { + val ex = intercept[BadRequestException] { + CoverImageUtils.requireWithinSizeLimit(CoverImageUtils.SIZE_LIMIT_BYTES + 1) + } + ex.getMessage should include("10 MB") + } + + it should "accept a zero-byte file" in { + noException should be thrownBy CoverImageUtils.requireWithinSizeLimit(0L) + } +} diff --git a/frontend/proxy.config.json b/frontend/proxy.config.json index c26a239b17..bc33371071 100755 --- a/frontend/proxy.config.json +++ b/frontend/proxy.config.json @@ -40,6 +40,16 @@ "secure": false, "changeOrigin": true }, + "/api/model/**": { + "target": "http://localhost:9092", + "secure": false, + "changeOrigin": true + }, + "/api/access/model/**": { + "target": "http://localhost:9092", + "secure": false, + "changeOrigin": true + }, "/api/access/computing-unit/**": { "target": "http://localhost:8888", "secure": false,
