Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Link Previews #7331

Merged
merged 21 commits into from
Oct 2, 2023
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions app/controllers/DatasetController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
22 changes: 19 additions & 3 deletions app/controllers/WkorgProxyController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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 =>
Expand All @@ -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
)
)
}
}

Expand Down
2 changes: 2 additions & 0 deletions app/models/annotation/Annotation.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
4 changes: 2 additions & 2 deletions app/models/binary/Dataset.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down
152 changes: 152 additions & 0 deletions app/oxalis/opengraph/OpenGraphService.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package oxalis.opengraph
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be a lot of effort to rename oxalis --> webknossos?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, I’ll do that in a separate PR. Truth be told, the package structure doesn’t make that much sense even with the renaming, but it would certainly be an easy improvement


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) {

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 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

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(s"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=1000&h=300$tokenParam"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the size could live in a constant somewhere

}

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 <meta name="description">, see template
}
OpenGraphTags(
Some("WEBKNOSSOS"),
description,
None
)
}
}
5 changes: 4 additions & 1 deletion app/views/main.scala.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
@( conf: utils.WkConf, selectedTheme: String )
@( conf: utils.WkConf, selectedTheme: String, openGraphTitle: Option[String], openGraphDescription: Option[String], openGraphImage: Option[String] )
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<meta name="commit-hash" content="@(webknossos.BuildInfo.commitHash)" />
<title>@(conf.WebKnossos.tabTitle)</title>
@openGraphTitle.map { ogt =><meta property="og:title" content="@ogt" /> }
@openGraphDescription.map { ogd =><meta property="og:description" content="@ogd" /> }
@openGraphImage.map { ogi =><meta property="og:image" content="@ogi" /> }
@if(conf.Features.isWkorgInstance){
<meta
name="description"
Expand Down
2 changes: 1 addition & 1 deletion conf/webknossos.latest.routes
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ GET /datasets/:organizationName/:dataSetName/accessList
GET /datasets/:organizationName/:dataSetName/sharingToken controllers.DatasetController.getSharingToken(organizationName: String, dataSetName: String)
DELETE /datasets/:organizationName/:dataSetName/sharingToken controllers.DatasetController.deleteSharingToken(organizationName: String, dataSetName: String)
PATCH /datasets/:organizationName/:dataSetName/teams controllers.DatasetController.updateTeams(organizationName: String, dataSetName: String)
GET /datasets/:organizationName/:dataSetName/layers/:layer/thumbnail controllers.DatasetController.thumbnail(organizationName: String, dataSetName: String, layer: String, w: Option[Int], h: Option[Int], mappingName: Option[String])
GET /datasets/:organizationName/:dataSetName/layers/:layer/thumbnail controllers.DatasetController.thumbnail(organizationName: String, dataSetName: String, layer: String, w: Option[Int], h: Option[Int], mappingName: Option[String], sharingToken: Option[String])
POST /datasets/:organizationName/:dataSetName/layers/:layer/segmentAnythingEmbedding controllers.DatasetController.segmentAnythingEmbedding(organizationName: String, dataSetName: String, layer: String, intensityMin: Option[Float], intensityMax: Option[Float])
PUT /datasets/:organizationName/:dataSetName/clearThumbnailCache controllers.DatasetController.removeFromThumbnailCache(organizationName: String, dataSetName: String)
GET /datasets/:organizationName/:dataSetName/isValidNewName controllers.DatasetController.isValidNewName(organizationName: String, dataSetName: String)
Expand Down