diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 83d9428f60..a4dd3e6ebd 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -12,6 +12,8 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Added +- Added social media link previews for links to datasets and annotations (only if they are public or if the links contain sharing tokens). [#7331](https://github.com/scalableminds/webknossos/pull/7331) + ### Changed ### Fixed diff --git a/app/controllers/DatasetController.scala b/app/controllers/DatasetController.scala index 7a58a52223..f8cbc2fc74 100755 --- a/app/controllers/DatasetController.scala +++ b/app/controllers/DatasetController.scala @@ -98,10 +98,13 @@ class DatasetController @Inject()(userService: UserService, dataLayerName: String, w: Option[Int], h: Option[Int], - mappingName: Option[String]): Action[AnyContent] = + mappingName: Option[String], + sharingToken: Option[String]): Action[AnyContent] = sil.UserAwareAction.async { implicit request => + val ctx = URLSharing.fallbackTokenAccessContext(sharingToken) for { - _ <- datasetDAO.findOneByNameAndOrganizationName(dataSetName, organizationName) ?~> notFoundMessage(dataSetName) ~> NOT_FOUND // To check Access Rights + _ <- datasetDAO.findOneByNameAndOrganizationName(dataSetName, organizationName)(ctx) ?~> notFoundMessage( + dataSetName) ~> NOT_FOUND // To check Access Rights image <- thumbnailService.getThumbnailWithCache(organizationName, dataSetName, dataLayerName, w, h, mappingName) } yield { addRemoteOriginHeaders(Ok(image)).as(jpegMimeType).withHeaders(CACHE_CONTROL -> "public, max-age=86400") diff --git a/app/controllers/WkorgProxyController.scala b/app/controllers/WkorgProxyController.scala index b3239f89c5..c1e573794e 100644 --- a/app/controllers/WkorgProxyController.scala +++ b/app/controllers/WkorgProxyController.scala @@ -6,6 +6,7 @@ import com.mohiva.play.silhouette.api.actions.UserAwareRequest import com.scalableminds.util.accesscontext.GlobalAccessContext import com.scalableminds.util.tools.Fox import models.user.{MultiUserDAO, Theme} +import oxalis.opengraph.OpenGraphService import oxalis.security.WkEnv import play.api.libs.ws.WSClient import play.api.mvc.{Action, AnyContent} @@ -14,8 +15,11 @@ import utils.WkConf import scala.concurrent.ExecutionContext import scala.util.matching.Regex -class WkorgProxyController @Inject()(ws: WSClient, conf: WkConf, sil: Silhouette[WkEnv], multiUserDAO: MultiUserDAO)( - implicit ec: ExecutionContext) +class WkorgProxyController @Inject()(ws: WSClient, + conf: WkConf, + sil: Silhouette[WkEnv], + multiUserDAO: MultiUserDAO, + openGraphService: OpenGraphService)(implicit ec: ExecutionContext) extends Controller { def proxyPageOrMainView: Action[AnyContent] = sil.UserAwareAction.async { implicit request => @@ -25,7 +29,19 @@ class WkorgProxyController @Inject()(ws: WSClient, conf: WkConf, sil: Silhouette for { multiUserOpt <- Fox.runOptional(request.identity)(user => multiUserDAO.findOne(user._multiUser)(GlobalAccessContext)) - } yield Ok(views.html.main(conf, multiUserOpt.map(_.selectedTheme).getOrElse(Theme.auto).toString)) + openGraphTags <- openGraphService.getOpenGraphTags( + request.path, + request.getQueryString("sharingToken").orElse(request.getQueryString("token"))) + } yield + Ok( + views.html.main( + conf, + multiUserOpt.map(_.selectedTheme).getOrElse(Theme.auto).toString, + openGraphTags.title, + openGraphTags.description, + openGraphTags.image + ) + ) } } diff --git a/app/models/annotation/Annotation.scala b/app/models/annotation/Annotation.scala index 92e06edeaf..bc34c1707c 100755 --- a/app/models/annotation/Annotation.scala +++ b/app/models/annotation/Annotation.scala @@ -43,6 +43,8 @@ case class Annotation( isDeleted: Boolean = false ) extends FoxImplicits { + def nameOpt: Option[String] = if (name.isEmpty) None else Some(name) + lazy val id: String = _id.toString def tracingType: TracingType.Value = { diff --git a/app/models/binary/Dataset.scala b/app/models/binary/Dataset.scala index 4e0fb4a807..ebc2fb5d10 100755 --- a/app/models/binary/Dataset.scala +++ b/app/models/binary/Dataset.scala @@ -83,7 +83,7 @@ object DatasetCompactInfo { implicit val jsonFormat: Format[DatasetCompactInfo] = Json.format[DatasetCompactInfo] } -class DatasetDAO @Inject()(sqlClient: SqlClient, datasetDataLayerDAO: DatasetLayerDAO, organizationDAO: OrganizationDAO)( +class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDAO, organizationDAO: OrganizationDAO)( implicit ec: ExecutionContext) extends SQLDAO[Dataset, DatasetsRow, Datasets](sqlClient) { protected val collection = Datasets @@ -535,7 +535,7 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetDataLayerDAO: DatasetLay scale = ${source.scaleOpt}, status = ${source.statusOpt.getOrElse("").take(1024)} where _id = $id""".asUpdate) - _ <- datasetDataLayerDAO.updateLayers(id, source) + _ <- datasetLayerDAO.updateLayers(id, source) } yield () def deactivateUnreported(existingDataSetIds: List[ObjectId], diff --git a/app/oxalis/opengraph/OpenGraphService.scala b/app/oxalis/opengraph/OpenGraphService.scala new file mode 100644 index 0000000000..1f8767fb57 --- /dev/null +++ b/app/oxalis/opengraph/OpenGraphService.scala @@ -0,0 +1,156 @@ +package oxalis.opengraph + +import akka.http.scaladsl.model.Uri +import com.google.inject.Inject +import com.scalableminds.util.accesscontext.DBAccessContext +import com.scalableminds.util.enumeration.ExtendedEnumeration +import com.scalableminds.util.tools.Fox +import com.scalableminds.webknossos.datastore.models.datasource.{Category, DataLayerLike} +import models.annotation.AnnotationDAO +import models.binary.{Dataset, DatasetDAO, DatasetLayerDAO} +import models.organization.{Organization, OrganizationDAO} +import models.shortlinks.ShortLinkDAO +import net.liftweb.common.Full +import oxalis.security.URLSharing +import java.net.URLDecoder +import utils.{ObjectId, WkConf} + +import scala.concurrent.ExecutionContext + +case class OpenGraphTags( + title: Option[String], + description: Option[String], + image: Option[String] +) + +object OpenGraphPageType extends ExtendedEnumeration { + val dataset, annotation, workflow, unknown = Value +} + +class OpenGraphService @Inject()(datasetDAO: DatasetDAO, + organizationDAO: OrganizationDAO, + datasetLayerDAO: DatasetLayerDAO, + annotationDAO: AnnotationDAO, + shortLinkDAO: ShortLinkDAO, + conf: WkConf) { + + private val thumbnailWidth = 1000 + private val thumbnailHeight = 300 + + // This should match the frontend-side routes, not api routes, since those are the links people send around + private val shortLinkRouteRegex = "^/links/(.*)".r + private val datasetRoute1Regex = "^/datasets/([^/^#]+)/([^/^#]+)/view".r + private val datasetRoute2Regex = "^/datasets/([^/^#]+)/([^/^#]+)".r + private val workflowRouteRegex = "^/workflows/([^/^#]+)".r + private val annotationRouteRegex = "^/annotations/([^/^#]+)".r + + def getOpenGraphTags(uriPath: String, sharingToken: Option[String])(implicit ec: ExecutionContext, + ctx: DBAccessContext): Fox[OpenGraphTags] = + for { + (uriPathResolved, sharingTokenResolved) <- resolveShortLinkIfNeeded(uriPath, sharingToken) + ctxWithToken = URLSharing.fallbackTokenAccessContext(sharingTokenResolved) + pageType = detectPageType(uriPathResolved) + tagsFox = pageType match { + case OpenGraphPageType.dataset => datasetOpenGraphTags(uriPathResolved, sharingTokenResolved)(ec, ctxWithToken) + case OpenGraphPageType.annotation => + annotationOpenGraphTags(uriPathResolved, sharingTokenResolved)(ec, ctxWithToken) + case OpenGraphPageType.workflow => + Fox.successful(defaultTags(OpenGraphPageType.workflow)) // No sharing token mechanism for workflows yet + case OpenGraphPageType.unknown => Fox.successful(defaultTags()) + } + // In error case (probably no access permissions), fall back to default, so the html template does not break + tagsBox <- tagsFox.futureBox + tags = tagsBox match { + case Full(tags) => tags + case _ => defaultTags(pageType) + } + } yield tags + + private def resolveShortLinkIfNeeded(uriPath: String, sharingToken: Option[String])( + implicit ec: ExecutionContext): Fox[(String, Option[String])] = + uriPath match { + case shortLinkRouteRegex(key) => + for { + shortLink <- shortLinkDAO.findOneByKey(key) + asUri: Uri = Uri(URLDecoder.decode(shortLink.longLink, "UTF-8")) + } yield (asUri.path.toString, asUri.query().get("token").orElse(asUri.query().get("sharingToken"))) + case _ => Fox.successful(uriPath, sharingToken) + } + + private def detectPageType(uriPath: String) = + uriPath match { + case datasetRoute1Regex(_, _) | datasetRoute2Regex(_, _) => OpenGraphPageType.dataset + case annotationRouteRegex(_) => OpenGraphPageType.annotation + case workflowRouteRegex(_) => OpenGraphPageType.workflow + case _ => OpenGraphPageType.unknown + } + + private def datasetOpenGraphTags(uriPath: String, token: Option[String])(implicit ec: ExecutionContext, + ctx: DBAccessContext): Fox[OpenGraphTags] = + uriPath match { + case datasetRoute1Regex(organizationName, datasetName) => + datasetOpenGraphTagsWithOrganizationName(organizationName, datasetName, token) + case datasetRoute2Regex(organizationName, datasetName) => + datasetOpenGraphTagsWithOrganizationName(organizationName, datasetName, token) + case _ => Fox.failure("not a matching uri") + } + + private def datasetOpenGraphTagsWithOrganizationName(organizationName: String, + datasetName: String, + token: Option[String])(implicit ctx: DBAccessContext) = + for { + dataset <- datasetDAO.findOneByNameAndOrganizationName(datasetName, organizationName) + layers <- datasetLayerDAO.findAllForDataset(dataset._id) + layerOpt = layers.find(_.category == Category.color) + organization <- organizationDAO.findOne(dataset._organization) + } yield + OpenGraphTags( + Some(s"${dataset.displayName.getOrElse(datasetName)} | WEBKNOSSOS"), + Some("View this dataset in WEBKNOSSOS"), + thumbnailUri(dataset, layerOpt, organization, token) + ) + + private def annotationOpenGraphTags(uriPath: String, token: Option[String])( + implicit ec: ExecutionContext, + ctx: DBAccessContext): Fox[OpenGraphTags] = + uriPath match { + case annotationRouteRegex(annotationId) => + for { + annotationIdValidated <- ObjectId.fromString(annotationId) + annotation <- annotationDAO.findOne(annotationIdValidated) + dataset: Dataset <- datasetDAO.findOne(annotation._dataSet) + organization <- organizationDAO.findOne(dataset._organization) + layers <- datasetLayerDAO.findAllForDataset(dataset._id) + layerOpt = layers.find(_.category == Category.color) + } yield + OpenGraphTags( + Some(s"${annotation.nameOpt.orElse(dataset.displayName).getOrElse(dataset.name)} | WEBKNOSSOS"), + Some(s"View this annotation on dataset ${dataset.displayName.getOrElse(dataset.name)} in WEBKNOSSOS"), + thumbnailUri(dataset, layerOpt, organization, token) + ) + case _ => Fox.failure("not a matching uri") + } + + private def thumbnailUri(dataset: Dataset, + layerOpt: Option[DataLayerLike], + organization: Organization, + token: Option[String]): Option[String] = + layerOpt.map { layer => + val tokenParam = token.map(t => s"&sharingToken=$t").getOrElse("") + s"${conf.Http.uri}/api/datasets/${organization.name}/${dataset.name}/layers/${layer.name}/thumbnail?w=$thumbnailWidth&h=$thumbnailHeight$tokenParam" + } + + private def defaultTags(pageType: OpenGraphPageType.Value = OpenGraphPageType.unknown): OpenGraphTags = { + val description = pageType match { + case OpenGraphPageType.dataset => Some("View this dataset in WEBKNOSSOS") + case OpenGraphPageType.annotation => Some("View this annotation in WEBKNOSSOS") + case OpenGraphPageType.workflow => Some("View this voxelytics workflow report in WEBKNOSSOS") + case _ => None // most clients will fall back to , see template + } + OpenGraphTags( + Some("WEBKNOSSOS"), + description, + None + ) + } +} diff --git a/app/views/main.scala.html b/app/views/main.scala.html index 8bb1c26a14..7402f02cc6 100755 --- a/app/views/main.scala.html +++ b/app/views/main.scala.html @@ -1,10 +1,13 @@ -@( conf: utils.WkConf, selectedTheme: String ) +@( conf: utils.WkConf, selectedTheme: String, openGraphTitle: Option[String], openGraphDescription: Option[String], openGraphImage: Option[String] )