Skip to content

Commit

Permalink
Make video routes work with Opencast Ids (#1190)
Browse files Browse the repository at this point in the history
This helps with making paths from the old ETH video portal work in
Tobira by letting video links work with Opencast Ids as well as Tobira's
internally used Ids.
Meaning URLs like `/speakers/introductory-lectures/v/PYhD7DtIL9c` and
`/speakers/introductory-lectures/v/774b3a56-c9c7-46c4-be00-9b55eb15b940`
can be used interchangeably.

In conjunction with
https://gitlab.elan-ev.de/opencast/eth/tobira-opencast-ansible/-/merge_requests/1,
this closes #1185
  • Loading branch information
LukasKalbertodt authored Jun 27, 2024
2 parents 14e3e34 + baba85d commit 4251d14
Show file tree
Hide file tree
Showing 6 changed files with 165 additions and 71 deletions.
23 changes: 22 additions & 1 deletion backend/src/api/model/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use crate::{

#[derive(Debug)]
pub(crate) struct AuthorizedEvent {
key: Key,
pub(crate) key: Key,
series: Option<Key>,
opencast_id: String,
is_live: bool,
Expand Down Expand Up @@ -252,6 +252,27 @@ impl AuthorizedEvent {
";
acl::load_for(context, raw_roles_sql, dbargs![&self.key]).await
}

/// Returns `true` if the realm has a video block with this video
/// OR if the realm has a series block with this event's series.
/// Otherwise, `false` is returned.
pub(crate) async fn is_referenced_by_realm(&self, path: String, context: &Context) -> ApiResult<bool> {
let query = "select exists(\
select 1 \
from blocks \
join realms on blocks.realm = realms.id \
where realms.full_path = $1 \
and ( \
blocks.video = $2 or \
blocks.series = (select series from events where id = $2) \
)\
)\
";
context.db.query_one(&query, &[&path.trim_end_matches('/'), &self.key])
.await?
.get::<_, bool>(0)
.pipe(Ok)
}
}

#[derive(juniper::GraphQLUnion)]
Expand Down
47 changes: 9 additions & 38 deletions backend/src/api/model/realm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ use juniper::{graphql_object, GraphQLEnum, GraphQLObject, GraphQLUnion, graphql_
use postgres_types::{FromSql, ToSql};

use crate::{
api::{Context, Id, err::ApiResult, Node, NodeValue, model::acl::{Acl, self}},
api::{
Context,
err::ApiResult,
Id,
model::acl::{self, Acl},
Node,
NodeValue,
},
auth::AuthContext,
db::{types::Key, util::{select, impl_from_db}},
db::{types::Key, util::{impl_from_db, select}},
prelude::*,
};
use super::block::{Block, BlockValue, SeriesBlock, VideoBlock};
Expand Down Expand Up @@ -448,40 +455,4 @@ impl Realm {
fn can_current_user_moderate(&self, context: &Context) -> bool {
self.can_current_user_moderate(context)
}

/// Returns `true` if this realm somehow references the given node via
/// blocks. Currently, the following rules are used:
///
/// - If `id` refers to a series: returns `true` if the realm has a series
/// block with that series.
/// - If `id` refers to an event: returns `true` if the realm has a video
/// block with that video OR if the realm has a series block with that
/// event's series.
/// - Otherwise, `false` is returned.
async fn references(&self, id: Id, context: &Context) -> ApiResult<bool> {
if let Some(event_key) = id.key_for(Id::EVENT_KIND) {
let query = "select exists(\
select 1 \
from blocks \
where realm = $1 and ( \
video = $2 or \
series = (select series from events where id = $2) \
)\
)";
context.db.query_one(&query, &[&self.key, &event_key])
.await?
.get::<_, bool>(0)
.pipe(Ok)
} else if let Some(series_key) = id.key_for(Id::SERIES_KIND) {
let query = "select exists(\
select 1 from blocks where realm = $1 and series = $2\
)";
context.db.query_one(&query, &[&self.key, &series_key])
.await?
.get::<_, bool>(0)
.pipe(Ok)
} else {
Ok(false)
}
}
}
15 changes: 15 additions & 0 deletions backend/src/api/model/series.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,21 @@ impl Series {
async fn events(&self, order: EventSortOrder, context: &Context) -> ApiResult<Vec<AuthorizedEvent>> {
AuthorizedEvent::load_for_series(self.key, order, context).await
}

/// Returns `true` if the realm has a series block with this series.
/// Otherwise, `false` is returned.
pub(crate) async fn is_referenced_by_realm(&self, path: String, context: &Context) -> ApiResult<bool> {
let query = "select exists(\
select 1 \
from blocks \
join realms on blocks.realm = realms.id \
where realms.full_path = $1 and blocks.series = $2 \
)";
context.db.query_one(&query, &[&path.trim_end_matches('/'), &self.key])
.await?
.get::<_, bool>(0)
.pipe(Ok)
}
}

impl Node for Series {
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { AddChildRoute } from "./routes/manage/Realm/AddChild";
import { ManageRealmContentRoute } from "./routes/manage/Realm/Content";
import { NotFoundRoute } from "./routes/NotFound";
import { RealmRoute } from "./routes/Realm";
import { DirectOpencastVideoRoute, DirectVideoRoute, VideoRoute } from "./routes/Video";
import {
DirectOpencastVideoRoute,
DirectVideoRoute,
OpencastVideoRoute,
VideoRoute,
} from "./routes/Video";
import { DirectSeriesOCRoute, DirectSeriesRoute } from "./routes/Series";
import { ManageVideosRoute } from "./routes/manage/Video";
import { UploadRoute } from "./routes/Upload";
Expand Down Expand Up @@ -37,6 +42,7 @@ const {
LoginRoute,
RealmRoute,
SearchRoute,
OpencastVideoRoute,
VideoRoute,
DirectVideoRoute,
DirectOpencastVideoRoute,
Expand Down
120 changes: 101 additions & 19 deletions frontend/src/routes/Video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
} from "./__generated__/VideoPageDirectOpencastLinkQuery.graphql";
import { UserData$key } from "../__generated__/UserData.graphql";
import { NavigationData$key } from "../layout/__generated__/NavigationData.graphql";
import { VideoPageByOcIdInRealmQuery } from "./__generated__/VideoPageByOcIdInRealmQuery.graphql";
import { getEventTimeInfo } from "../util/video";
import { formatDuration } from "../ui/Video";
import { ellipsisOverflowCss, focusStyle } from "../ui";
Expand All @@ -77,36 +78,28 @@ export const VideoRoute = makeRoute({
url: ({ realmPath, videoID }: { realmPath: string; videoID: string }) =>
`${realmPath === "/" ? "" : realmPath}/v/${keyOfId(videoID)}`,
match: url => {
const urlPath = url.pathname.replace(/^\/|\/$/g, "");
const parts = urlPath.split("/").map(decodeURIComponent);
if (parts.length < 2) {
return null;
}
if (parts[parts.length - 2] !== "v") {
return null;
}
const videoId = parts[parts.length - 1];
if (!videoId.match(b64regex)) {
return null;
}

const realmPathParts = parts.slice(0, parts.length - 2);
if (!isValidRealmPath(realmPathParts)) {
const params = getVideoDetailsFromUrl(url, b64regex);
if (params === null) {
return null;
}
const [realmPath, videoId] = params;

const query = graphql`
query VideoPageInRealmQuery($id: ID!, $realmPath: String!) {
... UserData
event: eventById(id: $id) { ... VideoPageEventData }
event: eventById(id: $id) {
... VideoPageEventData
... on AuthorizedEvent {
isReferencedByRealm(path: $realmPath)
}
}
realm: realmByPath(path: $realmPath) {
referencesVideo: references(id: $id)
... VideoPageRealmData
... NavigationData
}
}
`;
const realmPath = "/" + realmPathParts.join("/");

const queryRef = loadQuery<VideoPageInRealmQuery>(query, {
id: eventId(videoId),
realmPath,
Expand All @@ -121,7 +114,7 @@ export const VideoRoute = makeRoute({
return <NotFound kind="video" />;
}

if (!realm || !realm.referencesVideo) {
if (!realm || !event.isReferencedByRealm) {
return <ForwardToDirectRoute videoId={videoId} />;
}

Expand All @@ -137,12 +130,101 @@ export const VideoRoute = makeRoute({
},
});

/** Video in realm route: `/path/to/realm/v/:<ocid>` */
export const OpencastVideoRoute = makeRoute({
url: ({ realmPath, ocId }: { realmPath: string; ocId: string }) =>
`${realmPath === "/" ? "" : realmPath}/v/:${ocId}`,
match: url => {
const params = getVideoDetailsFromUrl(url, ":([^/]+)");
if (params === null) {
return null;
}

const [realmPath, id] = params;
const videoId = id.substring(1);

const query = graphql`
query VideoPageByOcIdInRealmQuery($id: String!, $realmPath: String!) {
... UserData
event: eventByOpencastId(id: $id) {
... VideoPageEventData
... on AuthorizedEvent {
isReferencedByRealm(path: $realmPath)
}
}
realm: realmByPath(path: $realmPath) {
... VideoPageRealmData
... NavigationData
}
}
`;

const queryRef = loadQuery<VideoPageByOcIdInRealmQuery>(query, {
id: videoId,
realmPath,
});

return {
render: () => <RootLoader
{... { query, queryRef }}
nav={data => data.realm ? <Nav fragRef={data.realm} /> : []}
render={({ event, realm }) => {
if (!event) {
return <NotFound kind="video" />;
}

if (!realm || !event.isReferencedByRealm) {
return <ForwardToDirectOcRoute ocID={videoId} />;
}

return <VideoPage
eventRef={event}
realmRef={realm}
basePath={realmPath.replace(/\/$/u, "") + "/v"}
/>;
}}
/>,
dispose: () => queryRef.dispose(),
};
},
});

const getVideoDetailsFromUrl = (url: URL, regEx: string) => {
const urlPath = url.pathname.replace(/^\/|\/$/g, "");
const parts = urlPath.split("/").map(decodeURIComponent);
if (parts.length < 2) {
return null;
}
if (parts[parts.length - 2] !== "v") {
return null;
}
const videoId = parts[parts.length - 1];
if (!videoId.match(regEx)) {
return null;
}

const realmPathParts = parts.slice(0, parts.length - 2);
if (!isValidRealmPath(realmPathParts)) {
return null;
}

const realmPath = "/" + realmPathParts.join("/");

return [realmPath, videoId];
};

const ForwardToDirectRoute: React.FC<{ videoId: string }> = ({ videoId }) => {
const router = useRouter();
useEffect(() => router.goto(DirectVideoRoute.url({ videoId })));
return <InitialLoading />;
};

const ForwardToDirectOcRoute: React.FC<{ ocID: string }> = ({ ocID }) => {
const router = useRouter();
useEffect(() => router.goto(DirectOpencastVideoRoute.url({ ocID })));
return <InitialLoading />;
};

/** Direct link to video with our ID: `/!v/<videoid>` */
export const DirectVideoRoute = makeRoute({
url: (args: { videoId: string }) => `/!v/${keyOfId(args.videoId)}`,
Expand Down
23 changes: 11 additions & 12 deletions frontend/src/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ type Series {
syncedData: SyncedSeriesData
hostRealms: [Realm!]!
events(order: EventSortOrder = {column: "CREATED", direction: "DESCENDING"}): [AuthorizedEvent!]!
"""
Returns `true` if the realm has a series block with this series.
Otherwise, `false` is returned.
"""
isReferencedByRealm(path: String!): Boolean!
}

"Some extra information we know about a role."
Expand Down Expand Up @@ -259,6 +264,12 @@ type AuthorizedEvent implements Node {
"Returns a list of realms where this event is referenced (via some kind of block)."
hostRealms: [Realm!]!
acl: [AclItem!]!
"""
Returns `true` if the realm has a video block with this video
OR if the realm has a series block with this event's series.
Otherwise, `false` is returned.
"""
isReferencedByRealm(path: String!): Boolean!
}

"A block just showing some title."
Expand Down Expand Up @@ -389,18 +400,6 @@ type Realm implements Node {
and non-critical settings.
"""
canCurrentUserModerate: Boolean!
"""
Returns `true` if this realm somehow references the given node via
blocks. Currently, the following rules are used:
- If `id` refers to a series: returns `true` if the realm has a series
block with that series.
- If `id` refers to an event: returns `true` if the realm has a video
block with that video OR if the realm has a series block with that
event's series.
- Otherwise, `false` is returned.
"""
references(id: ID!): Boolean!
}

"A role being granted permission to perform certain actions."
Expand Down

0 comments on commit 4251d14

Please sign in to comment.