backend/app/controllers/api/Search.scala (159 lines of code) (raw):

package controllers.api import java.time.{LocalDateTime, ZoneOffset} import model.annotations.{WorkspaceEntry, WorkspaceLeaf} import model.frontend._ import model.index._ import play.api.libs.json._ import play.api.mvc.RequestHeader import services.annotations.Annotations import services.index._ import services.users.UserManagement import utils.Logging import utils.attempt.{Attempt, ClientFailure, NotFoundFailure} import utils.auth.{User, UserIdentityRequest} import utils.controller.{AuthApiController, AuthControllerComponents} import services.{MetricsService} import scala.concurrent.ExecutionContext class Search(override val controllerComponents: AuthControllerComponents, userManagement: UserManagement, index: Index, annotations: Annotations, metricsService: MetricsService) extends AuthApiController with Logging { def search() = ApiAction.attempt { req: UserIdentityRequest[_] => val q = req.queryString.getOrElse("q", Seq("")).head val proposedParams = Search.buildSearchParameters(q, req) proposedParams.workspaceContext.map(wc => { logger.info(req.user.asLogMarker, "Performing workspace search") metricsService.recordSearchInFolderEvent(req.user.username) }) buildSearch(req.user, proposedParams, proposedParams.workspaceContext).flatMap { case (verifiedParams, context) => val returnEmptyResult = Search.shouldReturnEmptyResult(proposedParams, verifiedParams, context) if(returnEmptyResult) { Attempt.Right(Ok(Json.toJson(SearchResults.empty))) } else { index.query(verifiedParams, context).map { searchResults => Ok(Json.toJson(searchResults)) } } } } def chips() = ApiAction { Right( Ok( Json.toJson( Chips.all ) ) ) } private def buildSearch(user: User, proposedParams: SearchParameters, workspaceSearchParams: Option[WorkspaceSearchContextParams]): Attempt[(SearchParameters, SearchContext)] = { workspaceSearchParams match { case Some(WorkspaceSearchContextParams(workspaceId, workspaceFolderId)) => annotations.getAllWorkspacesMetadata(user.username).flatMap { workspaces => if(workspaces.exists(_.id == workspaceId)) { for { blobFilters <- SearchContext.buildBlobFiltersForWorkspaceFolder(user.username, workspaceId, workspaceFolderId, annotations) } yield { proposedParams -> WorkspaceFolderSearchContext(blobFilters) } } else { buildDefaultSearch(user, proposedParams) } } case None => buildDefaultSearch(user, proposedParams) } } private def buildDefaultSearch(user: User, proposedParams: SearchParameters): Attempt[(SearchParameters, SearchContext)] = { for { context <- SearchContext.build(user.username, userManagement, annotations) verifiedParams = Search.verifyParameters(user, proposedParams, context) } yield { verifiedParams -> context } } } object Search extends Logging { def buildSearchParameters(q: String, req: RequestHeader)(implicit ec: ExecutionContext): SearchParameters = { val page = req.queryString.getOrElse("page", Seq("1")).head.toInt val pageSize = req.queryString.getOrElse("pageSize", Seq("20")).head.toInt val sortBy = req.queryString.getOrElse("sortBy", Seq("relevance")).head match { case "relevance" => Relevance case "size-asc" => SizeAsc case "size-desc" => SizeDesc case "date-created-asc" => CreatedAtAsc case "date-created-desc" => CreatedAtDesc } val mimeFilters = req.queryString.getOrElse("mimeType[]", Seq()).toList val ingestionFilters = req.queryString.getOrElse("ingestion[]", Seq()).toList val workspaceFilters = req.queryString.getOrElse("workspace[]", Seq()).toList val createdAtFilters = req.queryString.getOrElse("createdAt[]", Seq()).toList val (start, end) = parseCreatedAt(createdAtFilters) val parsedChips = Chips.parseQueryString(q) SearchParameters(parsedChips.query, mimeFilters, ingestionFilters, workspaceFilters, start, end, page, pageSize, sortBy, parsedChips.workspace) } def verifyParameters(user: User, params: SearchParameters, context: DefaultSearchContext): SearchParameters = { val ingestionFilters = params.ingestionFilters.flatMap (verifyIngestionFilter (user, _, context.visibleCollections) ) val workspaceFilters = params.workspaceFilters.flatMap (verifyWorkspaceFilter (user, _, context.visibleWorkspaces) ) params.copy ( ingestionFilters = ingestionFilters, workspaceFilters = workspaceFilters ) } def shouldReturnEmptyResult(proposedParams: SearchParameters, verifiedParams: SearchParameters, context: SearchContext): Boolean = { context match { case WorkspaceFolderSearchContext(blobUris) => blobUris.isEmpty case DefaultSearchContext(visibleCollections, visibleWorkspaces) => val cannotSeeAnyWorkspacesRequestedAndHasNoIngestionFilters = proposedParams.workspaceFilters.nonEmpty && verifiedParams.workspaceFilters.isEmpty && verifiedParams.ingestionFilters.isEmpty val cannotSeeAnyCollectionsRequestedAndHasNoWorkspaceFilters = proposedParams.ingestionFilters.nonEmpty && verifiedParams.ingestionFilters.isEmpty && verifiedParams.workspaceFilters.isEmpty val cannotSeeAnythingAtAll = visibleWorkspaces.isEmpty && visibleCollections.isEmpty cannotSeeAnyWorkspacesRequestedAndHasNoIngestionFilters || cannotSeeAnyCollectionsRequestedAndHasNoWorkspaceFilters || cannotSeeAnythingAtAll } } // Filtering by both collection and ingestion is done using a prefix query on the `ingestion` field private def verifyIngestionFilter(user: User, filter: String, visibleCollections: Set[String]): Option[String] = { filter.split("/").toList match { case collection :: Nil if visibleCollections.contains(collection) => Some(s"${collection}/") case collection :: ingestion :: Nil if visibleCollections.contains(collection) => Some(s"${collection}/${ingestion}") case _ => logger.warn(user.asLogMarker, s"User ${user.username} requested ingestion $filter but can only see [${visibleCollections.mkString(",")}]") None } } private def verifyWorkspaceFilter(user: User, filter: String, visibleWorkspaces: List[String]): Option[String] = { if(visibleWorkspaces.contains(filter)) { Some(filter) } else { logger.warn(user.asLogMarker, s"User ${user.username} requested workspace $filter but can only see [${visibleWorkspaces.mkString(",")}]") None } } private def parseCreatedAt(filters: List[String]): (Option[Long], Option[Long]) = { filters.map { filter => filter.split("/").toList } match { case (year :: month :: Nil) :: Nil => val yearStart = LocalDateTime.of(year.toInt, month.toInt, 1, 0, 0) val monthEnd = yearStart.plusMonths(1) (Some(epochTs(yearStart)), Some(epochTs(monthEnd))) case (year :: Nil) :: Nil => val yearStart = LocalDateTime.of(year.toInt, 1, 1, 0, 0) val yearEnd = yearStart.plusMonths(12) (Some(epochTs(yearStart)), Some(epochTs(yearEnd))) case Nil => (None, None) case _ => throw new IllegalArgumentException(s"Unknown format for createdAt[] filter: ${filters.mkString(",")}") } } private def epochTs(instant: LocalDateTime): Long = { instant.toInstant(ZoneOffset.UTC).toEpochMilli } }