Skip to content

Commit

Permalink
Merged extended o19s-track_authenticated_user_actions
Browse files Browse the repository at this point in the history
  • Loading branch information
pbartusch committed Jan 28, 2022
2 parents 5f0030b + 011ff80 commit db2a0e5
Show file tree
Hide file tree
Showing 13 changed files with 161 additions and 85 deletions.
41 changes: 29 additions & 12 deletions app/controllers/ApiController.scala
Original file line number Diff line number Diff line change
@@ -1,26 +1,27 @@
package controllers

import java.io.{OutputStream, PipedInputStream, PipedOutputStream}

import akka.stream.scaladsl.{Source, StreamConverters}
import akka.util.ByteString

import javax.inject.Inject
import play.api.Logging
import play.api.mvc._
import play.api.libs.json._
import play.api.libs.json.Reads._

import java.nio.file.Paths
import java.time.format.DateTimeFormatter
import java.time.LocalDateTime

import scala.concurrent.{ExecutionContext, Future}
import controllers.auth.AuthActionFactory
import controllers.auth.{AuthActionFactory, UserRequest}
import models.FeatureToggleModel.FeatureToggleService
import models._
import models.config.SmuiVersion
import models.input.{InputTagId, InputValidator, ListItem, SearchInputId, SearchInputWithRules}
import models.querqy.QuerqyRulesTxtGenerator
import models.spellings.{CanonicalSpellingId, CanonicalSpellingValidator, CanonicalSpellingWithAlternatives}
import org.checkerframework.checker.units.qual.A
import services.{RulesTxtDeploymentService, RulesTxtImportService}


Expand Down Expand Up @@ -122,8 +123,11 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory,
Ok(Json.toJson(searchManagementRepository.getDetailedSearchInput(SearchInputId(searchInputId))))
}


def addNewSearchInput(solrIndexId: String) = authActionFactory.getAuthenticatedAction(Action).async { request: Request[AnyContent] =>
Future {
val userInfo: Option[String] = lookupUserInfo(request)

val body: AnyContent = request.body
val jsonBody: Option[JsValue] = body.asJson

Expand All @@ -134,7 +138,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory,

InputValidator.validateInputTerm(searchInputTerm) match {
case Nil => {
val searchInputId = searchManagementRepository.addNewSearchInput(SolrIndexId(solrIndexId), searchInputTerm, tags)
val searchInputId = searchManagementRepository.addNewSearchInput(SolrIndexId(solrIndexId), searchInputTerm, tags, userInfo)
Ok(Json.toJson(ApiResult(API_RESULT_OK, "Adding Search Input '" + searchInputTerm + "' successful.", Some(searchInputId))))
}
case errors => {
Expand All @@ -149,9 +153,12 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory,
}
}



def updateSearchInput(searchInputId: String) = authActionFactory.getAuthenticatedAction(Action) { request: Request[AnyContent] =>
val body: AnyContent = request.body
val jsonBody: Option[JsValue] = body.asJson
val userInfo: Option[String] = lookupUserInfo(request)

// Expecting json body
jsonBody.map { json =>
Expand All @@ -166,7 +173,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory,
BadRequest(Json.toJson(ApiResult(API_RESULT_FAIL, strErrMsg, None)))
case None => {
// TODO handle potential conflict between searchInputId and JSON-passed searchInput.id
searchManagementRepository.updateSearchInput(searchInput)
searchManagementRepository.updateSearchInput(searchInput, userInfo)
// TODO consider Update returning the updated SearchInput(...) instead of an ApiResult(...)
Ok(Json.toJson(ApiResult(API_RESULT_OK, "Updating Search Input successful.", Some(SearchInputId(searchInputId)))))
}
Expand All @@ -184,9 +191,10 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory,
}
}

def deleteSearchInput(searchInputId: String) = authActionFactory.getAuthenticatedAction(Action).async {
def deleteSearchInput(searchInputId: String) = authActionFactory.getAuthenticatedAction(Action).async { request: Request[AnyContent] =>
Future {
searchManagementRepository.deleteSearchInput(searchInputId)
val userInfo: Option[String] = lookupUserInfo(request)
searchManagementRepository.deleteSearchInput(searchInputId, userInfo)
Ok(Json.toJson(ApiResult(API_RESULT_OK, "Deleting Search Input successful", None)))
}
}
Expand All @@ -199,14 +207,15 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory,

def addNewSpelling(solrIndexId: String) = authActionFactory.getAuthenticatedAction(Action).async { request: Request[AnyContent] =>
Future {
val userInfo: Option[String] = lookupUserInfo(request)
val body: AnyContent = request.body
val jsonBody: Option[JsValue] = body.asJson

val optTerm = jsonBody.flatMap(json => (json \"term").asOpt[String])
optTerm.map { term =>
CanonicalSpellingValidator.validateNoEmptySpelling(term) match {
case None => {
val canonicalSpelling = searchManagementRepository.addNewCanonicalSpelling(SolrIndexId(solrIndexId), term)
val canonicalSpelling = searchManagementRepository.addNewCanonicalSpelling(SolrIndexId(solrIndexId), term, userInfo)
Ok(Json.toJson(ApiResult(API_RESULT_OK, "Successfully added Canonical Spelling '" + term + "'.", Some(canonicalSpelling.id))))
}
case Some(error) => {
Expand All @@ -227,6 +236,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory,
}

def updateSpelling(solrIndexId: String, canonicalSpellingId: String) = authActionFactory.getAuthenticatedAction(Action) { request: Request[AnyContent] =>
val userInfo: Option[String] = lookupUserInfo(request)
val body: AnyContent = request.body
val jsonBody: Option[JsValue] = body.asJson

Expand All @@ -237,7 +247,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory,
val otherSpellings = searchManagementRepository.listAllSpellingsWithAlternatives(SolrIndexId(solrIndexId)).filter(_.id != spellingWithAlternatives.id)
CanonicalSpellingValidator.validateCanonicalSpellingsAndAlternatives(spellingWithAlternatives, otherSpellings) match {
case Nil =>
searchManagementRepository.updateSpelling(spellingWithAlternatives)
searchManagementRepository.updateSpelling(spellingWithAlternatives, userInfo)
Ok(Json.toJson(ApiResult(API_RESULT_OK, "Successfully updated Canonical Spelling.", Some(CanonicalSpellingId(canonicalSpellingId)))))
case errors =>
val msgs = s"Failed to update Canonical Spelling ${spellingWithAlternatives.term}: " + errors.mkString("\n")
Expand All @@ -248,10 +258,10 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory,
BadRequest(Json.toJson(ApiResult(API_RESULT_FAIL, "Updating Canonical Spelling failed. Unexpected body data.", None)))
}
}

def deleteSpelling(canonicalSpellingId: String) = authActionFactory.getAuthenticatedAction(Action).async {
def deleteSpelling(canonicalSpellingId: String) = authActionFactory.getAuthenticatedAction(Action).async { request: Request[AnyContent] =>
Future {
searchManagementRepository.deleteSpelling(canonicalSpellingId)
val userInfo: Option[String] = lookupUserInfo(request)
searchManagementRepository.deleteSpelling(canonicalSpellingId, userInfo)
Ok(Json.toJson(ApiResult(API_RESULT_OK, "Deleting Canonical Spelling with alternatives successful.", None)))
}
}
Expand Down Expand Up @@ -377,6 +387,13 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory,
Ok(Json.toJson(ApiResult(API_RESULT_FAIL, "File rules_txt missing in request body.", None)))
}
}
private def lookupUserInfo(request: Request[AnyContent]) = {
val userInfo: Option[String] = request match {
case _: UserRequest[A] => Option(request.asInstanceOf[UserRequest[A]].username)
case _ => None
}
userInfo
}

/**
* Deployment info (raw or formatted)
Expand Down
13 changes: 10 additions & 3 deletions app/controllers/auth/BasicAuthAuthenticatedAction.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import play.api.mvc._
import scala.concurrent.{ExecutionContext, Future}
import scala.util.control.Exception.allCatch

// Wrap a standard request with the extracted username of the person making the request
case class UserRequest[A](username: String, request: Request[A]) extends WrappedRequest[A](request)

@deprecated("As of v3.14. See https://github.com/querqy/smui/pull/83#issuecomment-1023284550", "27-01-2022")
class BasicAuthAuthenticatedAction(parser: BodyParsers.Default, appConfig: Configuration)(implicit ec: ExecutionContext)
extends ActionBuilderImpl(parser) with Logging {

Expand Down Expand Up @@ -39,22 +43,25 @@ class BasicAuthAuthenticatedAction(parser: BodyParsers.Default, appConfig: Confi
override def invokeBlock[A](request: Request[A], block: Request[A] => Future[Result]): Future[Result] = {
logger.debug(s":: invokeBlock :: request.path = ${request.path}")

var extractedUsername = "" // Pulled out of the Basic Auth logic
def requestAuthenticated(request: Request[A]): Boolean = {
request.headers.get("Authorization") match {
case Some(authorization: String) =>
authorization.split(" ").drop(1).headOption.filter { encoded =>
authorization.split(" ").drop(1).headOption.exists { encoded =>
val authInfo = new String(Base64.getDecoder().decode(encoded.getBytes)).split(":").toList
allCatch.opt {
val (username, password) = (authInfo.head, authInfo(1))
extractedUsername = username
username.equals(BASIC_AUTH_USER) && password.equals(BASIC_AUTH_PASS)

} getOrElse false
}.exists(_ => true)
}
case None => false
}
}

if (requestAuthenticated(request)) {
block(request)
block(UserRequest(extractedUsername,request))
} else {
Future {
// TODO return error JSON with authorization violation details, redirect target eventually (instead of empty 401 body)
Expand Down
30 changes: 16 additions & 14 deletions app/models/SearchManagementRepository.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import models.eventhistory.{ActivityLog, ActivityLogEntry, InputEvent}
import models.reports.{ActivityReport, DeploymentLog, RulesReport}
import play.api.Logging

// TODO Make `userInfo` mandatory (for all input/spelling and deploymentLog CRUD operations), when removing unauthorized access.
@javax.inject.Singleton
class SearchManagementRepository @Inject()(dbapi: DBApi, toggleService: FeatureToggleService)(implicit ec: DatabaseExecutionContext) extends Logging {

Expand Down Expand Up @@ -92,18 +93,19 @@ class SearchManagementRepository @Inject()(dbapi: DBApi, toggleService: FeatureT
}

/**
* Canonical spellings and alternative spellings
*/
* Canonical spellings and alternative spellings
*
*/

def addNewCanonicalSpelling(solrIndexId: SolrIndexId, term: String): CanonicalSpelling =
def addNewCanonicalSpelling(solrIndexId: SolrIndexId, term: String, userInfo: Option[String]): CanonicalSpelling =
db.withConnection { implicit connection =>
val spelling = CanonicalSpelling.insert(solrIndexId, term)

// add CREATED event for spelling
if (toggleService.getToggleActivateEventHistory) {
InputEvent.createForSpelling(
spelling.id,
None, // TODO userInfo not being logged so far
userInfo,
false
)
}
Expand All @@ -116,15 +118,15 @@ class SearchManagementRepository @Inject()(dbapi: DBApi, toggleService: FeatureT
CanonicalSpellingWithAlternatives.loadById(CanonicalSpellingId(canonicalSpellingId))
}

def updateSpelling(spelling: CanonicalSpellingWithAlternatives): Unit =
def updateSpelling(spelling: CanonicalSpellingWithAlternatives, userInfo: Option[String]): Unit =
db.withTransaction { implicit connection =>
CanonicalSpellingWithAlternatives.update(spelling)

// add UPDATED event for spelling and associated alternatives
if (toggleService.getToggleActivateEventHistory) {
InputEvent.updateForSpelling(
spelling.id,
None // TODO userInfo not being logged so far
userInfo
)
}
}
Expand All @@ -139,7 +141,7 @@ class SearchManagementRepository @Inject()(dbapi: DBApi, toggleService: FeatureT
CanonicalSpellingWithAlternatives.loadAllForIndex(solrIndexId)
}

def deleteSpelling(canonicalSpellingId: String): Int =
def deleteSpelling(canonicalSpellingId: String, userInfo: Option[String]): Int =
db.withTransaction { implicit connection =>
val id = CanonicalSpellingId(canonicalSpellingId)
val count = CanonicalSpellingWithAlternatives.delete(id)
Expand All @@ -148,7 +150,7 @@ class SearchManagementRepository @Inject()(dbapi: DBApi, toggleService: FeatureT
if (toggleService.getToggleActivateEventHistory) {
InputEvent.deleteForSpelling(
id,
None // TODO userInfo not being logged so far
userInfo
)
}

Expand All @@ -162,7 +164,7 @@ class SearchManagementRepository @Inject()(dbapi: DBApi, toggleService: FeatureT
/**
* Adds new Search Input (term) to the database table. This method only focuses the term, and does not care about any synonyms.
*/
def addNewSearchInput(solrIndexId: SolrIndexId, searchInputTerm: String, tags: Seq[InputTagId]): SearchInputId = db.withConnection { implicit connection =>
def addNewSearchInput(solrIndexId: SolrIndexId, searchInputTerm: String, tags: Seq[InputTagId], userInfo: Option[String]): SearchInputId = db.withConnection { implicit connection =>

// add search input
val id = SearchInput.insert(solrIndexId, searchInputTerm).id
Expand All @@ -174,7 +176,7 @@ class SearchManagementRepository @Inject()(dbapi: DBApi, toggleService: FeatureT
if (toggleService.getToggleActivateEventHistory) {
InputEvent.createForSearchInput(
id,
None, // TODO userInfo not being logged so far
userInfo,
false
)
}
Expand All @@ -186,26 +188,26 @@ class SearchManagementRepository @Inject()(dbapi: DBApi, toggleService: FeatureT
SearchInputWithRules.loadById(searchInputId)
}

def updateSearchInput(searchInput: SearchInputWithRules): Unit = db.withTransaction { implicit connection =>
def updateSearchInput(searchInput: SearchInputWithRules, userInfo: Option[String]): Unit = db.withTransaction { implicit connection =>
SearchInputWithRules.update(searchInput)

// add UPDATED event for search input and rules
if (toggleService.getToggleActivateEventHistory) {
InputEvent.updateForSearchInput(
searchInput.id,
None // TODO userInfo not being logged so far
userInfo
)
}
}

def deleteSearchInput(searchInputId: String): Int = db.withTransaction { implicit connection =>
def deleteSearchInput(searchInputId: String, userInfo: Option[String]): Int = db.withTransaction { implicit connection =>
val id = SearchInputWithRules.delete(SearchInputId(searchInputId))

// add DELETED event for search input and rules
if (toggleService.getToggleActivateEventHistory) {
InputEvent.deleteForSearchInput(
SearchInputId(searchInputId),
None // TODO userInfo not being logged so far
userInfo
)
}

Expand Down
66 changes: 37 additions & 29 deletions app/models/config/SmuiVersion.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import play.api.Logging

import scala.io.Source
import scala.util.Try
import play.api.libs.json.{JsError, JsPath, JsSuccess, Json, Reads}
import play.api.libs.json.{JsError, JsPath, JsArray, JsSuccess, Json, Reads}

case class SmuiVersion(
major: Int,
Expand Down Expand Up @@ -48,43 +48,51 @@ object SmuiVersion extends Logging {
Try({
// request latest and previous version from hub.docker.com
// TODO confirm we're dealing with a stable DockerHub API here!
val LATEST_DOCKER_HUB_URL = "https://hub.docker.com/v2/repositories/querqy/smui/tags/?page_size=2&page=1"
val LATEST_DOCKER_HUB_URL = "https://hub.docker.com/v2/repositories/querqy/smui/tags/?page_size=4&page=1&ordering=last_updated"
Source.fromURL(LATEST_DOCKER_HUB_URL).mkString
}).toOption match {
case None => None
case Some(rawDockerHubResp) => {
// TODO make any plausibility checks (maybe, that "results"(0) contains "latest")?
def parseJsonResponse(jsonRead: Reads[String]): Option[String] = {
Json.parse(rawDockerHubResp).validate[String](jsonRead) match {
case JsSuccess(rawVer, _) => {
val _: String = rawVer
logger.info(s":: match :: rawVer = $rawVer")
Some(rawVer)

// Convert results to JSON array
val jsonReadDockerHubVersionResults = (JsPath \ "results").read[JsArray]
Json.parse(rawDockerHubResp).validate[JsArray](jsonReadDockerHubVersionResults) match {
case JsSuccess(versionResults, _) => {
logger.info(s":: Successfully parsed version number")

// Extract version numbers
val allLatestVersions = versionResults.value.map(versionEntry => {
(versionEntry \ "name").validate[String] match {
case JsSuccess(versionName, _) => Some(versionName)
case e: JsError => {
logger.error(s":: error parsing latest DockerHub version JSON for SMUI (e = $e)")
None
}
}
}).flatten

// Plausibility check
if (!allLatestVersions.contains("latest")) {
logger.error(s":: allLatestVersions does not contain a 'latest' version. This is unexpected. Please contact the author.")
}
case e: JsError => {
logger.error(s":: error parsing latest DockerHub version JSON for SMUI (e = $e)")
None

val specificVersions = allLatestVersions.filter(version => {
val patternFullVersion: scala.util.matching.Regex = """\d+.\d+.\d+""".r
patternFullVersion.findFirstMatchIn(version).isDefined
})

// Next plausibility check
if (specificVersions.size != 1) {
logger.error(s":: more or less than 1 specificVersions found (specificVersions = >>>${specificVersions}<<<) This is unexpected. Please contact the author.")
}

SmuiVersion.parse(specificVersions.head)

}
}
val jsonReadLatestVersionFromDockerHubResp1 = ((JsPath \ "results") (1) \ "name").read[String]
val rawVer1 = parseJsonResponse(jsonReadLatestVersionFromDockerHubResp1)
// TODO assume parsing works, might produce an exception
val rawVer = (if(rawVer1.get.equals("latest")) {
// hub.docker.com API does not seem to provide a stable interface to the latest version in 2nd JSON entry
val jsonReadLatestVersionFromDockerHubResp0 = ((JsPath \ "results") (0) \ "name").read[String]
val rawVer0 = parseJsonResponse(jsonReadLatestVersionFromDockerHubResp0)
rawVer0.get
} else {
rawVer1.get
})

parse(rawVer) match {
case None => {
logger.error(s":: unable to parse latest DockerHub version string for SMUI (rawVer = $rawVer)")
case e: JsError => {
logger.error(s":: error parsing latest DockerHub version JSON for SMUI (e = $e)")
None
}
case Some(version) => Some(version)
}
}
}
Expand Down
Loading

0 comments on commit db2a0e5

Please sign in to comment.