Skip to content

Commit

Permalink
Short links (#6461)
Browse files Browse the repository at this point in the history
Co-authored-by: Philipp Otto <[email protected]>
Co-authored-by: Florian M <[email protected]>
  • Loading branch information
3 people authored Sep 20, 2022
1 parent de45b21 commit 7eeb7f9
Show file tree
Hide file tree
Showing 16 changed files with 265 additions and 53 deletions.
1 change: 1 addition & 0 deletions .circleci/slack-notification.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ if [ "${CIRCLE_BRANCH}" == "master" ] ; then
author=${author/youri-k/<@youri>}
author=${author/Dagobert42/<@Arthur>}
author=${author/leowe/<@leo>}
author=${author/frcroth/<@felix.roth>}
channel="webknossos-bots"
commitmsg="$(git log --format=%s -n 1)"
pullregex="(.*)#([0-9]+)(.*)"
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
- The proofreading tool now supports merging and splitting (via min-cut) agglomerates by rightclicking a segment (and not a node). Note that there still has to be an active node so that both partners of the operation are defined. [#6464](https://github.com/scalableminds/webknossos/pull/6464)

### Changed
- Sharing links are shortened by default. Within the sharing modal, this shortening behavior can be disabled. [#6461](https://github.com/scalableminds/webknossos/pull/6461)

### Fixed
- Fixed sharing button for users who are currently visiting a dataset or annotation which was shared with them. [#6438](https://github.com/scalableminds/webknossos/pull/6438)
Expand Down
1 change: 1 addition & 0 deletions MIGRATIONS.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ User-facing changes are documented in the [changelog](CHANGELOG.released.md).
[Commits](https://github.com/scalableminds/webknossos/compare/22.09.0...HEAD)

### Postgres Evolutions:
- [088-shortlinks.sql](conf/evolutions/088-shortlinks.sql)
36 changes: 36 additions & 0 deletions app/controllers/ShortLinkController.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package controllers

import com.mohiva.play.silhouette.api.Silhouette
import com.scalableminds.util.tools.FoxImplicits
import models.shortlinks.{ShortLink, ShortLinkDAO}
import oxalis.security.{RandomIDGenerator, WkEnv}
import play.api.libs.json.Json
import play.api.mvc.{Action, AnyContent, PlayBodyParsers}
import utils.{ObjectId, WkConf}

import javax.inject.Inject
import scala.concurrent.ExecutionContext

class ShortLinkController @Inject()(shortLinkDAO: ShortLinkDAO, sil: Silhouette[WkEnv], wkConf: WkConf)(
implicit ec: ExecutionContext,
val bodyParsers: PlayBodyParsers)
extends Controller
with FoxImplicits {

def create: Action[String] = sil.SecuredAction.async(validateJson[String]) { implicit request =>
val longLink = request.body
val _id = ObjectId.generate
val key = RandomIDGenerator.generateBlocking(12)
for {
_ <- bool2Fox(longLink.startsWith(wkConf.Http.uri)) ?~> "Could not generate short link: URI does not match"
_ <- shortLinkDAO.insertOne(ShortLink(_id, key, longLink)) ?~> "create.failed"
inserted <- shortLinkDAO.findOne(_id)
} yield Ok(Json.toJson(inserted))
}

def getByKey(key: String): Action[AnyContent] = Action.async { implicit request =>
for {
shortLink <- shortLinkDAO.findOneByKey(key)
} yield Ok(Json.toJson(shortLink))
}
}
55 changes: 55 additions & 0 deletions app/models/shortlinks/ShortLink.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package models.shortlinks

import com.scalableminds.util.tools.Fox
import com.scalableminds.webknossos.schema.Tables
import com.scalableminds.webknossos.schema.Tables.{Shortlinks, ShortlinksRow}
import play.api.libs.json.{Json, OFormat}
import slick.jdbc.PostgresProfile.api._
import slick.lifted.Rep
import utils.{ObjectId, SQLClient, SQLDAO}

import javax.inject.Inject
import scala.concurrent.ExecutionContext

case class ShortLink(_id: ObjectId, key: String, longLink: String)

object ShortLink {
implicit val jsonFormat: OFormat[ShortLink] = Json.format[ShortLink]
}

class ShortLinkDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext)
extends SQLDAO[ShortLink, ShortlinksRow, Shortlinks](sqlClient) {
val collection = Shortlinks

def idColumn(x: Shortlinks): Rep[String] = x._Id

override def isDeletedColumn(x: Tables.Shortlinks): Rep[Boolean] = false

def parse(r: ShortlinksRow): Fox[ShortLink] =
Fox.successful(
ShortLink(
ObjectId(r._Id),
r.key,
r.longlink
)
)

def insertOne(sl: ShortLink): Fox[Unit] =
for {
_ <- run(sqlu"""insert into webknossos.shortLinks(_id, key, longlink)
values(${sl._id}, ${sl.key}, ${sl.longLink})""")
} yield ()

def findOne(id: String): Fox[ShortLink] =
for {
r <- run(sql"select #$columns from webknossos.shortLinks where id = $id".as[ShortlinksRow])
parsed <- parseFirst(r, id)
} yield parsed

def findOneByKey(key: String): Fox[ShortLink] =
for {
r <- run(sql"select #$columns from webknossos.shortLinks where key = $key".as[ShortlinksRow])
parsed <- parseFirst(r, key)
} yield parsed

}
14 changes: 14 additions & 0 deletions conf/evolutions/088-shortlinks.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
START TRANSACTION;

CREATE TABLE webknossos.shortLinks(
_id CHAR(24) PRIMARY KEY DEFAULT '',
key CHAR(16) NOT NULL UNIQUE,
longLink Text NOT NULL
);

CREATE INDEX ON webknossos.shortLinks(key);

UPDATE webknossos.releaseInformation
SET schemaVersion = 88;

COMMIT TRANSACTION;
7 changes: 7 additions & 0 deletions conf/evolutions/reversions/088-shortlinks.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
START TRANSACTION;

DROP TABLE webknossos.shortLinks;

UPDATE webknossos.releaseInformation SET schemaVersion = 87;

COMMIT TRANSACTION;
4 changes: 4 additions & 0 deletions conf/webknossos.latest.routes
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,7 @@ POST /jobs/:id/status
# Publications
GET /publications controllers.PublicationController.listPublications()
GET /publications/:id controllers.PublicationController.read(id: String)

# Shortlinks
POST /shortLinks controllers.ShortLinkController.create
GET /shortLinks/byKey/:key controllers.ShortLinkController.getByKey(key: String)
19 changes: 19 additions & 0 deletions frontend/javascripts/admin/admin_rest_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import type {
ServerEditableMapping,
APICompoundType,
ZarrPrivateLink,
ShortLink,
} from "types/api_flow_types";
import { APIAnnotationTypeEnum } from "types/api_flow_types";
import type { Vector3, Vector6 } from "oxalis/constants";
Expand Down Expand Up @@ -2293,3 +2294,21 @@ export async function getEdgesForAgglomerateMinCut(
),
);
}

// ### Short links

export const createShortLink = _.memoize(
(longLink: string): Promise<ShortLink> =>
Request.sendJSONReceiveJSON("/api/shortLinks", {
method: "POST",
// stringify is necessary because the back-end expects a JSON string
// (i.e., a string which contains quotes at the beginning and end).
// The Request module does not add additional string quotes
// if the data parameter is already a string.
data: JSON.stringify(longLink),
}),
);

export function getShortLink(key: string): Promise<ShortLink> {
return Request.receiveJSON(`/api/shortLinks/byKey/${key}`);
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { RouteComponentProps } from "react-router-dom";
import { withRouter } from "react-router-dom";
import React from "react";

type Props = {
redirectTo: () => Promise<string>;
history: RouteComponentProps["history"];
pushToHistory: boolean;
pushToHistory?: boolean;
};

class AsyncRedirect extends React.PureComponent<Props> {
Expand All @@ -19,6 +20,12 @@ class AsyncRedirect extends React.PureComponent<Props> {
async redirect() {
const newPath = await this.props.redirectTo();

if (newPath.startsWith(location.origin)) {
// The link is absolute which react-router does not support
// apparently. See https://stackoverflow.com/questions/42914666/react-router-external-link
location.replace(newPath);
}

if (this.props.pushToHistory) {
this.props.history.push(newPath);
} else {
Expand Down
11 changes: 8 additions & 3 deletions frontend/javascripts/libs/error_handling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,14 @@ class ErrorHandling {
window.addEventListener("unhandledrejection", (event) => {
// Create our own error for unhandled rejections here to get additional information for [Object object] errors in airbrake
const reasonAsString = event.reason instanceof Error ? event.reason.toString() : event.reason;
const wrappedError = event.reason instanceof Error ? event.reason : new Error(event.reason);
wrappedError.message =
UNHANDLED_REJECTION_PREFIX + JSON.stringify(reasonAsString).slice(0, 80);
let wrappedError = event.reason instanceof Error ? event.reason : new Error(event.reason);
wrappedError = {
...wrappedError,
// The message property is read-only in newer browser versions which is why
// the object is copied shallowly.
message: UNHANDLED_REJECTION_PREFIX + JSON.stringify(reasonAsString).slice(0, 80),
};

this.notify(wrappedError, {
originalError: reasonAsString,
});
Expand Down
107 changes: 80 additions & 27 deletions frontend/javascripts/oxalis/view/action-bar/share_modal_view.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { Alert, Divider, Radio, Modal, Input, Button, Row, Col, RadioChangeEvent } from "antd";
import { CopyOutlined, ShareAltOutlined } from "@ant-design/icons";
import ButtonComponent from "oxalis/view/components/button_component";
import {
Alert,
Divider,
Radio,
Modal,
Input,
Button,
Row,
Col,
RadioChangeEvent,
Tooltip,
} from "antd";
import { CompressOutlined, CopyOutlined, ShareAltOutlined } from "@ant-design/icons";
import { useSelector } from "react-redux";
import React, { useState, useEffect } from "react";
import type {
Expand All @@ -17,6 +27,7 @@ import {
sendAnalyticsEvent,
setOthersMayEditForAnnotation,
getSharingTokenFromUrlParameters,
createShortLink,
} from "admin/admin_rest_api";
import TeamSelectionComponent from "dashboard/dataset/team_selection_component";
import Toast from "libs/toast";
Expand All @@ -32,6 +43,7 @@ import {
import { setShareModalVisibilityAction } from "oxalis/model/actions/ui_actions";
import { ControlModeEnum } from "oxalis/constants";
import { makeComponentLazy } from "libs/react_helpers";
import { AsyncButton } from "components/async_clickables";

const RadioGroup = Radio.Group;
const sharingActiveNode = true;
Expand Down Expand Up @@ -115,10 +127,12 @@ export function ShareButton(props: { dataset: APIDataset; style?: Record<string,
// dataset is not public. For datasets or sandboxes, a token is included if the dataset is not public.
const includeToken = !dataset.isPublic && (isViewMode || isSandboxMode || annotationIsPublic);

const copySharingUrl = () => {
const createAndCopySharingUrl = async () => {
// Copy the url on-demand as it constantly changes
const url = getUrl(sharingToken, includeToken);
copyUrlToClipboard(url);
const shortLink = await createShortLink(url);

copyUrlToClipboard(`${location.origin}/links/${shortLink.key}`);

if (isTraceMode && !annotationIsPublic) {
// For public annotations and in dataset view mode, the link will work for all users.
Expand All @@ -144,10 +158,10 @@ export function ShareButton(props: { dataset: APIDataset; style?: Record<string,
};

return (
<ButtonComponent
<AsyncButton
icon={<ShareAltOutlined />}
title={messages["tracing.copy_sharing_link"]}
onClick={copySharingUrl}
onClick={createAndCopySharingUrl}
style={style}
/>
);
Expand Down Expand Up @@ -314,7 +328,8 @@ function _ShareModalView(props: Props) {
Private: "lock",
};
const includeToken = !dataset.isPublic && visibility === "Public";
const url = getUrl(sharingToken, includeToken);
const longUrl = getUrl(sharingToken, includeToken);

return (
<Modal
title="Share this annotation"
Expand All @@ -334,27 +349,10 @@ function _ShareModalView(props: Props) {
Sharing Link
</Col>
<Col span={18}>
<Input.Group compact>
<Input
style={{
width: "85%",
}}
value={url}
readOnly
/>
<Button
style={{
width: "15%",
}}
onClick={() => copyUrlToClipboard(url)}
icon={<CopyOutlined />}
>
Copy
</Button>
</Input.Group>
<CopyableSharingLink isVisible={isVisible} longUrl={longUrl} />
<Hint
style={{
margin: "6px 12px",
margin: "4px 9px 12px 4px",
}}
>
{messages["tracing.sharing_modal_basic_information"](sharingActiveNode)}
Expand Down Expand Up @@ -503,5 +501,60 @@ function _ShareModalView(props: Props) {
);
}

export function CopyableSharingLink({
isVisible,
longUrl,
}: {
isVisible: boolean;
longUrl: string;
}) {
const [shortUrl, setShortUrl] = useState<string | null>(null);

const [showShortLink, setShowShortLink] = useState(true);
useEffect(() => {
if (!isVisible || !showShortLink) {
// Don't create new shortlinks when the user does not want/need them.
// Set short url to null to avoid keeping a stale value.
setShortUrl(null);
return;
}

createShortLink(longUrl).then((shortLink) => {
setShortUrl(`${location.origin}/links/${shortLink.key}`);
});
}, [longUrl, isVisible, showShortLink]);
const linkToCopy = showShortLink ? shortUrl || longUrl : longUrl;

return (
<Input.Group compact>
<Tooltip title="When enabled, the link is shortened automatically.">
<Button
type={showShortLink ? "primary" : "default"}
onClick={() => setShowShortLink(!showShortLink)}
style={{ padding: "0px 8px" }}
>
<CompressOutlined className="without-icon-margin" />
</Button>
</Tooltip>
<Input
style={{
width: "78%",
}}
value={linkToCopy}
readOnly
/>
<Button
style={{
width: "15%",
}}
onClick={() => copyUrlToClipboard(linkToCopy)}
icon={<CopyOutlined />}
>
Copy
</Button>
</Input.Group>
);
}

const ShareModalView = makeComponentLazy(_ShareModalView);
export default ShareModalView;
Loading

0 comments on commit 7eeb7f9

Please sign in to comment.