Skip to content

Commit

Permalink
Allow authentication via series id
Browse files Browse the repository at this point in the history
Authenticating for an event will now also store that event's
series id with its credentials.
With that, all events of that particular series will
be unlocked.
This is a feature only used by the ETH, and as such must be
enabled via a configuration option.
  • Loading branch information
owi92 committed Nov 19, 2024
1 parent 4f6d742 commit f30f21d
Show file tree
Hide file tree
Showing 9 changed files with 109 additions and 52 deletions.
21 changes: 12 additions & 9 deletions frontend/src/routes/Embed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from "react-relay";
import { unreachable } from "@opencast/appkit";

import { eventId, getCredentials, isSynced, keyOfId } from "../util";
import { eventId, getCredentials, isSynced, keyOfId, useAuthenticatedDataQuery } from "../util";
import { GlobalErrorBoundary } from "../util/err";
import { loadQuery } from "../relay";
import { makeRoute, MatchedRoute } from "../rauta";
Expand Down Expand Up @@ -39,7 +39,7 @@ export const EmbedVideoRoute = makeRoute({

const queryRef = loadQuery<EmbedQuery>(query, {
id,
...getCredentials("event" + id),
...getCredentials("event", id),
});


Expand Down Expand Up @@ -69,7 +69,7 @@ export const EmbedOpencastVideoRoute = makeRoute({
const videoId = decodeURIComponent(matches[1]);
const queryRef = loadQuery<EmbedDirectOpencastQuery>(query, {
id: videoId,
...getCredentials(videoId),
...getCredentials("oc-event", videoId),
});

return matchedEmbedRoute(query, queryRef);
Expand Down Expand Up @@ -113,7 +113,7 @@ const embedEventFragment = graphql`
description
canWrite
hasPassword
series { title opencastId }
series { title id opencastId }
syncedData {
updated
startTime
Expand Down Expand Up @@ -169,11 +169,14 @@ const Embed: React.FC<EmbedProps> = ({ query, queryRef }) => {
</PlayerPlaceholder>;
}

return event.authorizedData
? <Player event={{
...event,
authorizedData: event.authorizedData,
}} />
const authorizedData = useAuthenticatedDataQuery(
event.id,
event.series?.id,
{ authorizedData: event.authorizedData },
);

return authorizedData
? <Player event={{ ...event, authorizedData }} />
: <PreviewPlaceholder embedded {...{ event }}/>;
};

Expand Down
7 changes: 6 additions & 1 deletion frontend/src/routes/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,9 @@ const SearchEvent: React.FC<EventItem> = ({
: VideoRoute.url({ realmPath: hostRealms[0].path, videoID: id });

// TODO: This check should be done in backend.
const showMatches = userIsAuthorized || (hasPassword && getCredentials(keyOfId(id)));
const showMatches = userIsAuthorized || (
hasPassword && getCredentials("event", eventId(keyOfId(id)))
);

return (
<Item key={id} breakpoint={BREAKPOINT_MEDIUM} link={link}>{{
Expand All @@ -544,6 +546,9 @@ const SearchEvent: React.FC<EventItem> = ({
title,
isLive,
created,
series: seriesId ? {
id: seriesId,
} : null,
syncedData: {
duration,
startTime,
Expand Down
48 changes: 34 additions & 14 deletions frontend/src/routes/Video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import {
keyOfId,
playlistId,
getCredentials,
useAuthenticatedDataQuery,
credentialsStorageKey,
} from "../util";
import { BREAKPOINT_SMALL, BREAKPOINT_MEDIUM } from "../GlobalStyle";
import { LinkButton } from "../ui/LinkButton";
Expand Down Expand Up @@ -125,7 +127,7 @@ export const VideoRoute = makeRoute({
id,
realmPath,
listId,
...getCredentials(videoId),
...getCredentials("event", id),
});

return {
Expand Down Expand Up @@ -194,7 +196,7 @@ export const OpencastVideoRoute = makeRoute({
id,
realmPath,
listId,
...getCredentials(id),
...getCredentials("oc-event", id),
});

return {
Expand Down Expand Up @@ -261,11 +263,11 @@ export const DirectVideoRoute = makeRoute({
playlist: playlistById(id: $listId) { ...PlaylistBlockPlaylistData }
}
`;
const videoId = decodeURIComponent(params[1]);
const id = eventId(decodeURIComponent(params[1]));
const queryRef = loadQuery<VideoPageDirectLinkQuery>(query, {
id: eventId(videoId),
id,
listId: makeListId(url.searchParams.get("list")),
...getCredentials(videoId),
...getCredentials("event", id),
});

return matchedDirectRoute(query, queryRef);
Expand Down Expand Up @@ -302,7 +304,7 @@ export const DirectOpencastVideoRoute = makeRoute({
const queryRef = loadQuery<VideoPageDirectOpencastLinkQuery>(query, {
id,
listId: makeListId(url.searchParams.get("list")),
...getCredentials(id),
...getCredentials("oc-event", id),
});

return matchedDirectRoute(query, queryRef);
Expand Down Expand Up @@ -470,11 +472,27 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath
if (event.__typename !== "AuthorizedEvent") {
return unreachable();
}

if (!isSynced(event)) {
return <WaitingPage type="video" />;
}

// If the event is password protected this will check if there are credentials for this event's
// series are stored, and if so, skip the authentication.
// Ideally this would happen at the top level in the `makeRoute` call, but at that point the
// series id isn't known. To prevent unnecessary queries, the hook is also passed the authorized
// data of this event. If that is neither null nor undefined, nothing is fetched.
//
// This extra check is particularly useful in this specific component, where we might run into a
// situation where an event has been previously authenticated and its credentials are stored
// with both its own ID (with which it is possible to already fetch the authenticated data in
// the initial video page query) and its series ID. So when the authenticated data is already
// present, it shouldn't be fetched a second time.
const authorizedData = useAuthenticatedDataQuery(
event.id,
event.series?.id,
{ authorizedData: event.authorizedData },
);

const breadcrumbs = realm.isMainRoot ? [] : realmBreadcrumbs(t, realm.ancestors.concat(realm));
const { hasStarted, hasEnded } = getEventTimeInfo(event);
const isCurrentlyLive = hasStarted === true && hasEnded === false;
Expand Down Expand Up @@ -503,12 +521,9 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath
<Breadcrumbs path={breadcrumbs} tail={event.title} />
<script type="application/ld+json">{JSON.stringify(structuredData)}</script>
<PlayerContextProvider>
{event.authorizedData
{authorizedData
? <InlinePlayer
event={{
...event,
authorizedData: event.authorizedData,
}}
event={{ ...event, authorizedData }}
css={{ margin: "-4px auto 0" }}
onEventStateChange={rerender}
/>
Expand Down Expand Up @@ -615,9 +630,14 @@ const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ event, embedded }) =>
// The check will return a result for either ID regardless of its kind, as long as
// one of them is stored.
const storage = isRealUser(user) ? window.localStorage : window.sessionStorage;
storage.setItem(CREDENTIALS_STORAGE_KEY + keyOfId(event.id), credentials);
storage.setItem(CREDENTIALS_STORAGE_KEY + event.opencastId, credentials);
storage.setItem(credentialsStorageKey("event", event.id), credentials);
storage.setItem(credentialsStorageKey("oc-event", event.opencastId), credentials);

// We also store the series id of the event. If other events of that series use
// the same credentials, they will also be unlocked.
if (event.series?.id) {
storage.setItem(credentialsStorageKey("series", event.series.id), credentials);
}
},
error: (error: Error) => {
setAuthError(error.message);
Expand Down
15 changes: 12 additions & 3 deletions frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const EditVideoBlock: React.FC<EditVideoBlockProps> = ({ block: blockRef
... on AuthorizedEvent {
id
title
series { title }
series { id title }
created
isLive
creators
Expand Down Expand Up @@ -82,7 +82,12 @@ export const EditVideoBlock: React.FC<EditVideoBlockProps> = ({ block: blockRef
const { formState: { errors } } = form;

const currentEvent = event?.__typename === "AuthorizedEvent"
? { ...event, ...event.syncedData, seriesTitle: event.series?.title }
? {
...event,
...event.syncedData,
seriesId: event.series?.id,
seriesTitle: event.series?.title,
}
: undefined;

return <EditModeForm create={create} save={save} map={(data: VideoFormData) => data}>
Expand Down Expand Up @@ -138,6 +143,7 @@ const EventSelector: React.FC<EventSelectorProps> = ({ onChange, onBlur, default
items {
id
title
seriesId
seriesTitle
creators
thumbnail
Expand Down Expand Up @@ -168,7 +174,10 @@ const EventSelector: React.FC<EventSelectorProps> = ({ onChange, onBlur, default
id: item.id.replace(/^es/, "ev"),
syncedData: item,
authorizedData: item,
series: item.seriesTitle == null ? null : { title: item.seriesTitle },
series: (item.seriesTitle == null || item.seriesId == null) ? null : {
id: item.seriesId,
title: item.seriesTitle,
},
})));
},
start: () => {},
Expand Down
1 change: 1 addition & 0 deletions frontend/src/routes/manage/Video/Shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ const query = graphql`
tracks { flavor resolution mimetype uri }
}
series {
id
title
opencastId
...SeriesBlockSeriesData
Expand Down
1 change: 1 addition & 0 deletions frontend/src/routes/manage/Video/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const query = graphql`
}
items {
id title created description isLive tobiraDeletionTimestamp
series { id }
syncedData {
duration updated startTime endTime
}
Expand Down
15 changes: 7 additions & 8 deletions frontend/src/ui/Blocks/Video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const VideoBlock: React.FC<Props> = ({ fragRef, basePath }) => {
description
canWrite
hasPassword
series { title opencastId }
series { title id opencastId }
syncedData {
duration
updated
Expand Down Expand Up @@ -69,20 +69,19 @@ export const VideoBlock: React.FC<Props> = ({ fragRef, basePath }) => {
return unreachable();
}

const authenticatedData = useAuthenticatedDataQuery(keyOfId(event.id));
const authorizedData = event.authorizedData
?? authenticatedData.authorizedEvent?.authorizedData;
const authorizedData = useAuthenticatedDataQuery(
event.id,
event.series?.id,
{ authorizedData: event.authorizedData },
);


return <div css={{ maxWidth: 800 }}>
{showTitle && <Title title={event.title} />}
<PlayerContextProvider>
{authorizedData && isSynced(event)
? <InlinePlayer
event={{
...event,
authorizedData,
}}
event={{ ...event, authorizedData }}
css={{ margin: "-4px auto 0" }}
/>
: <PreviewPlaceholder {...{ event }} />
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/ui/Video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import { useColorScheme } from "@opencast/appkit";

import { COLORS } from "../color";
import { keyOfId, useAuthenticatedDataQuery } from "../util";
import { useAuthenticatedDataQuery } from "../util";


type ThumbnailProps = JSX.IntrinsicElements["div"] & {
Expand All @@ -22,6 +22,9 @@ type ThumbnailProps = JSX.IntrinsicElements["div"] & {
title: string;
isLive: boolean;
created: string;
series?: {
id: string;
} | null;
syncedData?: {
duration: number;
startTime?: string | null;
Expand Down Expand Up @@ -53,9 +56,9 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
}) => {
const { t } = useTranslation();
const isDark = useColorScheme().scheme === "dark";
const authenticatedData = useAuthenticatedDataQuery(keyOfId(event.id));
const authenticatedData = useAuthenticatedDataQuery(event.id, event.series?.id);
const authorizedThumbnail = event.authorizedData?.thumbnail
?? authenticatedData.authorizedEvent?.authorizedData?.thumbnail;
?? authenticatedData?.thumbnail;
const isUpcoming = isUpcomingLiveEvent(event.syncedData?.startTime ?? null, event.isLive);
const audioOnly = event.authorizedData
? (
Expand Down
44 changes: 30 additions & 14 deletions frontend/src/util/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useLazyLoadQuery } from "react-relay";

import CONFIG, { TranslatedString } from "../config";
import { TimeUnit } from "../ui/Input";
import { authorizedDataQuery, CREDENTIALS_STORAGE_KEY } from "../routes/Video";
import { AuthorizedData, authorizedDataQuery, CREDENTIALS_STORAGE_KEY } from "../routes/Video";
import {
VideoAuthorizedDataQuery$data,
} from "../routes/__generated__/VideoAuthorizedDataQuery.graphql";
Expand Down Expand Up @@ -234,35 +234,51 @@ interface AuthenticatedData extends OperationType {
/**
* Returns `authorizedData` of password protected events by fetching it from the API,
* if the correct credentials were supplied.
* This will not send a request when there are no credentials.
* This will not send a request when there are no credentials and instead return the
* event's authorized data if that was already present and passed to this hook.
*/
export const useAuthenticatedDataQuery = (id: string) => {
const credentials = getCredentials(keyOfId(id));
return useLazyLoadQuery<AuthenticatedData>(
export const useAuthenticatedDataQuery = (
eventID: string,
seriesID?: string,
authData?: AuthorizedData | null,
) => {
// If `id` is coming from a search event, the prefix might be `es` or `ss`, but
// the query and storage need it to be a regular event/series id (i.e. with prefix `ev`/`sr`).
const credentials = getCredentials("event", eventId(keyOfId(eventID))) ?? (
seriesID && getCredentials("series", seriesId(keyOfId(seriesID)))
);
const authenticatedData = useLazyLoadQuery<AuthenticatedData>(
authorizedDataQuery,
// If `id` is coming from a search event, the prefix might be `es`, but
// the query needs it to be an event id (i.e. with prefix `ev`).
{ eventId: eventId(keyOfId(id)), ...credentials },
// This will only query the data for events with credentials.
// Unnecessary queries are prevented.
{ fetchPolicy: !credentials ? "store-only" : "store-or-network" }
{ eventId: eventId(keyOfId(eventID)), ...credentials },
// This will only query the data for events with stored credentials and/or yet unknown
// authorized data. This should help to prevent unnecessary queries.
{ fetchPolicy: credentials && !authData ? "store-or-network" : "store-only" }
);

return authData?.authorizedData ?? authenticatedData?.authorizedEvent?.authorizedData;
};

/**
* Returns stored credentials of events.
*
* Three kinds of IDs are stored when a user authenticates for an event:
* We need to store both Tobira ID and Opencast ID, since the video route can be accessed
* via both kinds. For this, both IDs are queried from the DB.
* The check for already stored credentials however happens in the same query,
* so we only have access to the single event ID from the url.
* In order to have a successful check when visiting a video page with either Tobira ID
* or Opencast ID in the url, this check accepts both ID kinds.
* Lastly, we also store the series ID of an event. If other events of that series use
* the same credentials, authenticating for the current event will also unlock
* these other events.
*/
export const getCredentials = (eventId: string): Credentials => {
const credentials = window.localStorage.getItem(CREDENTIALS_STORAGE_KEY + eventId)
?? window.sessionStorage.getItem(CREDENTIALS_STORAGE_KEY + eventId);
type IdKind = "event" | "oc-event" | "series";
export const getCredentials = (kind: IdKind, id: string): Credentials => {
const credentials = window.localStorage.getItem(credentialsStorageKey(kind, id))
?? window.sessionStorage.getItem(credentialsStorageKey(kind, id));

return credentials && JSON.parse(credentials);
};

export const credentialsStorageKey = (kind: IdKind, id: string) =>
CREDENTIALS_STORAGE_KEY + kind + "-" + id;

0 comments on commit f30f21d

Please sign in to comment.