Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ object WorkflowAccessResource {
}
}

/**
* Whether the user was granted access to the workflow itself, rather than merely being able to
* read it because it is public. Granted access sees the author's working copy; public access is
* held at the pinned copy while one is pinned.
*/
def hasGrantedAccess(wid: Integer, uid: Integer): Boolean = {
!getPrivilege(wid, uid).eq(PrivilegeEnum.NONE)
}

def isPublic(wid: Integer): Boolean = {
context
.select(WORKFLOW.IS_PUBLIC)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,15 +199,61 @@ object WorkflowPublishService extends LazyLogging {
PublishStatus(
isPublished = workflow.getIsPublic,
isPinned = pinned,
// Literally "what the public sees is not what you have". Every field the pin freezes counts:
// a rename the public cannot see is held back exactly as an edit to the graph is. Compared on
// values rather than on a version id, so that an edit and its undo report nothing held back.
hasUnpublishedChanges = pinned && (
!sameContent(workflow.getPublishedContent, workflow.getContent) ||
workflow.getPublishedName != workflow.getName ||
workflow.getPublishedDescription != workflow.getDescription ||
workflow.getPublishedDefaultView != workflow.getDefaultView
// Literally "what the public sees is not what you have": whatever [[publicCopyOf]] freezes is
// what this compares, on values rather than version ids, so an edit and its undo cancel out.
hasUnpublishedChanges = differs(publicCopyOf(workflow), workingCopyOf(workflow))
)
}

/**
* Every field of the copy, so that a rename the public cannot see is held back exactly as an edit
* to the graph is. Content is compared as a tree: a restore can rearrange whitespace, and calling
* that drift alarms nobody.
*/
private def differs(public: PublicCopy, working: PublicCopy): Boolean =
public.name != working.name ||
public.description != working.description ||
public.defaultView != working.defaultView ||
!sameContent(public.content, working.content)

/**
* Everything about a workflow that is on public show, carried together so that a caller cannot
* serve the frozen graph under the author's live title, or open the author's chosen view on a
* copy that does not contain it.
*/
case class PublicCopy(
name: String,
description: String,
content: String,
defaultView: DefaultViewEnum
)

/** What every public surface must serve, as a group so no field is the one that gets forgotten. */
def publicCopyOf(workflow: Workflow): PublicCopy =
if (workflow.getPublishedContent == null) workingCopyOf(workflow)
else
PublicCopy(
workflow.getPublishedName,
workflow.getPublishedDescription,
workflow.getPublishedContent,
workflow.getPublishedDefaultView
)

/** The author's own copy, in the same shape. */
private def workingCopyOf(workflow: Workflow): PublicCopy =
PublicCopy(
workflow.getName,
workflow.getDescription,
workflow.getContent,
workflow.getDefaultView
)

/** As [[publicCopyOf]], for callers holding only a wid. 404s unless the workflow is public. */
def publicCopyOf(wid: Integer): PublicCopy = {
val workflow = requireWorkflow(wid)
if (!workflow.getIsPublic) {
throw new NotFoundException(s"Workflow $wid is not public")
}
publicCopyOf(workflow)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,7 @@ object WorkflowResource {
private def insertWorkflow(workflow: Workflow, user: User): Unit = {
// A workflow is born with nothing pinned. The endpoint takes a whole Workflow, so without this
// a request body could seed a published copy of its own choosing.
workflow.setPublishedVersionId(null)
workflow.setPublishedContent(null)
workflow.setPublishedName(null)
workflow.setPublishedDescription(null)
workflow.setPublishedDefaultView(null)
clearPublishState(workflow)
workflowDao.insert(workflow)
workflowOfUserDao.insert(new WorkflowOfUser(user.getUid, workflow.getWid))
workflowUserAccessDao.insert(
Expand Down Expand Up @@ -144,11 +140,19 @@ object WorkflowResource {

case class WorkflowIDs(wids: List[Integer])

/** Clears every column that describes a pinned public copy. */
private def clearPublishState(workflow: Workflow): Unit = {
workflow.setPublishedVersionId(null)
workflow.setPublishedContent(null)
workflow.setPublishedName(null)
workflow.setPublishedDescription(null)
workflow.setPublishedDefaultView(null)
}

/**
* A workflow POJO for the copy-producing paths (clone, duplicate, restore-a-version).
*
* Built with setters rather than the positional constructor, so that adding a column cannot
* silently shift a null into the wrong field -- as adding the published-copy columns would.
* A workflow POJO for the copy-producing paths (clone, duplicate, restore-a-version). Copies start
* unpublished, and setters rather than the positional constructor keep a new column from silently
* shifting a null into the wrong field.
*/
def newUnpublishedWorkflow(
name: String,
Expand All @@ -162,9 +166,46 @@ object WorkflowResource {
workflow.setContent(content)
workflow.setIsPublic(false)
workflow.setDefaultView(defaultView)
clearPublishState(workflow)
workflow
}

/**
* The copy a viewer may see, every field at once: a user granted access to the workflow itself,
* as its owner or through a share, sees the author's working copy; everyone else is here only
* because the workflow is public, and gets the public copy. Taken as a group so a copy cannot end
* up carrying the published graph under a title the author never published.
*/
private def copyVisibleTo(workflow: Workflow, uid: Integer): WorkflowPublishService.PublicCopy =
if (uid != null && WorkflowAccessResource.hasGrantedAccess(workflow.getWid, uid)) {
WorkflowPublishService.PublicCopy(
workflow.getName,
workflow.getDescription,
workflow.getContent,
workflow.getDefaultView
)
} else {
WorkflowPublishService.publicCopyOf(workflow)
}

/**
* One column as the public sees it: the frozen value while a pin is in place, the live one
* otherwise. Keyed on the pin rather than on the frozen value being non-null, so a pinned
* workflow can never fall through to what the author is editing.
*/
private def publicField(
wid: Integer,
frozen: TableField[_, String],
live: TableField[_, String]
) =
Option(
context
.select(WORKFLOW.PUBLISHED_CONTENT, frozen, live)
.from(WORKFLOW)
.where(WORKFLOW.WID.eq(wid))
.fetchOne()
).map(row => if (row.value1() != null) row.value2() else row.value3()).orNull

/**
* Writes one field of a workflow, and only that field. The endpoints take a whole `Workflow`
* from the client, of which exactly two things are used: which workflow, and the new value.
Expand Down Expand Up @@ -381,16 +422,20 @@ class WorkflowResource extends LazyLogging {
): WorkflowWithPrivilege = {
if (WorkflowAccessResource.hasReadAccess(wid, user.getUid)) {
val workflow = workflowDao.fetchOneByWid(wid)
// A user who only reaches this workflow because it is public is served the pinned copy, not
// the author's in-progress edits -- all of it, so the graph cannot arrive under a title the
// author has not published, or in a view the frozen graph does not support.
val visible = copyVisibleTo(workflow, user.getUid)
WorkflowWithPrivilege(
workflow.getName,
workflow.getDescription,
visible.name,
visible.description,
workflow.getWid,
workflow.getContent,
visible.content,
workflow.getCreationTime,
workflow.getLastModifiedTime,
workflow.getIsPublic,
!WorkflowAccessResource.hasWriteAccess(wid, user.getUid),
workflow.getDefaultView
visible.defaultView
)
} else {
throw new ForbiddenException("No sufficient access privilege.")
Expand Down Expand Up @@ -491,13 +536,16 @@ class WorkflowResource extends LazyLogging {
context.transaction { txConfig =>
for (wid <- workflowIDs.wids) {
val oldWorkflow: Workflow = workflowDao.fetchOneByWid(wid)
// Reached only because it is public? Then the copy is of the published version, title and
// description included.
val source = copyVisibleTo(oldWorkflow, user.getUid)
val newWorkflow = createWorkflow(
newUnpublishedWorkflow(
oldWorkflow.getName + "_copy",
oldWorkflow.getDescription,
assignNewOperatorIds(oldWorkflow.getContent),
// the default view is part of the workflow, so a copy keeps it
oldWorkflow.getDefaultView
source.name + "_copy",
source.description,
assignNewOperatorIds(source.content),
// the default view is part of the copy being taken, so it comes from the same source
source.defaultView
),
sessionUser
)
Expand Down Expand Up @@ -526,13 +574,16 @@ class WorkflowResource extends LazyLogging {
throw new ForbiddenException("No sufficient access privilege.")
}
val oldWorkflow: Workflow = workflowDao.fetchOneByWid(wid)
// The hub shows the public copy, so Clone copies that -- for the author too, who already has
// their latest in the editor. For a private workflow this is the author's own copy.
val source = WorkflowPublishService.publicCopyOf(oldWorkflow)
val newWorkflow: DashboardWorkflow = createWorkflow(
newUnpublishedWorkflow(
oldWorkflow.getName + "_clone",
oldWorkflow.getDescription,
assignNewOperatorIds(oldWorkflow.getContent),
// a biologist's path is hub -> clone -> use, so the clone must stay usable
oldWorkflow.getDefaultView
source.name + "_clone",
source.description,
assignNewOperatorIds(source.content),
// a biologist's path is hub -> clone -> use, so the clone keeps the view of what they saw
source.defaultView
),
sessionUser
)
Expand Down Expand Up @@ -894,15 +945,10 @@ class WorkflowResource extends LazyLogging {
@GET
@Path("/workflow_name")
def getWorkflowName(@QueryParam("wid") wid: Integer): String = {
context
.select(
WORKFLOW.NAME
)
.from(WORKFLOW)
.where(WORKFLOW.WID.eq(wid))
.fetchOneInto(classOf[String])
publicField(wid, WORKFLOW.PUBLISHED_NAME, WORKFLOW.NAME)
}

/** The hub's public view of a workflow: the pinned version if one is pinned, the latest if not. */
@GET
@Path("/publicised/{wid}")
def retrievePublicWorkflow(
Expand All @@ -913,29 +959,27 @@ class WorkflowResource extends LazyLogging {
.where(WORKFLOW.WID.eq(wid))
.and(WORKFLOW.IS_PUBLIC.isTrue)
.fetchOne()
// Name and description come from the public copy for the same reason as the content: a pin has
// to hold everything on show.
val publicCopy = WorkflowPublishService.publicCopyOf(workflow.into(classOf[Workflow]))
WorkflowWithPrivilege(
workflow.getName,
workflow.getDescription,
publicCopy.name,
publicCopy.description,
workflow.getWid,
workflow.getContent,
publicCopy.content,
workflow.getCreationTime,
workflow.getLastModifiedTime,
workflow.getIsPublic,
readonly = true,
defaultView = workflow.getDefaultView
// The view freezes with the copy: the form's definition lives inside the content.
defaultView = publicCopy.defaultView
)
}

@GET
@Path("/workflow_description")
def getWorkflowDescription(@QueryParam("wid") wid: Integer): String = {
context
.select(
WORKFLOW.DESCRIPTION
)
.from(WORKFLOW)
.where(WORKFLOW.WID.eq(wid))
.fetchOneInto(classOf[String])
publicField(wid, WORKFLOW.PUBLISHED_DESCRIPTION, WORKFLOW.DESCRIPTION)
}

//TODO Get size from database
Expand All @@ -951,7 +995,9 @@ class WorkflowResource extends LazyLogging {
.fetch()
.asScala
.foreach { wf =>
result.put(wf.getWid, wf.getContent.length)
// Sized by the copy on show, so the number does not move when the author edits privately.
val onShow = WorkflowPublishService.publicCopyOf(wf.into(classOf[Workflow]))
result.put(wf.getWid, onShow.content.length)
}
}
result
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ object WorkflowVersionResource {
.createDSLContext()
private def workflowVersionDao = new WorkflowVersionDao(context.configuration)
private def workflowDao = new WorkflowDao(context.configuration)

/**
* Whether this user may read the workflow's revision history.
*
* Read access is enough while nothing is pinned: the public copy is then the author's latest, and
* its history is the history of what everyone can already see. A pin changes that -- replaying a
* version folds deltas back from the author's *current* content, so handing the history to a
* public viewer would hand them the edits the pin is holding back.
*/
private def canReadHistory(wid: Integer, uid: Integer): Boolean =
WorkflowAccessResource.hasGrantedAccess(wid, uid) ||
(WorkflowAccessResource.hasReadAccess(wid, uid) &&
Option(workflowDao.fetchOneByWid(wid)).forall(_.getPublishedContent == null))
// constant to indicate versions should be aggregated if they are within the specified time limit
private final val AGGREGATE_TIME_LIMIT_MILLSEC =
UserSystemConfig.workflowVersionCollapseIntervalInMinutes * 60000
Expand Down Expand Up @@ -336,7 +349,7 @@ class WorkflowVersionResource {
@Auth sessionUser: SessionUser
): List[VersionEntry] = {
val user = sessionUser.getUser
if (!WorkflowAccessResource.hasReadAccess(wid, user.getUid)) {
if (!canReadHistory(wid, user.getUid)) {
List()
} else {
encodeVersionImportance(
Expand Down Expand Up @@ -371,7 +384,7 @@ class WorkflowVersionResource {
@Auth sessionUser: SessionUser
): Workflow = {
val user = sessionUser.getUser
if (!WorkflowAccessResource.hasReadAccess(wid, user.getUid)) {
if (!canReadHistory(wid, user.getUid)) {
throw new ForbiddenException("No sufficient access privilege.")
} else {
// fetch all versions equal to and subsequent to the specified version
Expand Down
Loading
Loading