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 Oct 20, 2024
1 parent fdf1b40 commit 85d4c1f
Show file tree
Hide file tree
Showing 11 changed files with 106 additions and 41 deletions.
1 change: 1 addition & 0 deletions backend/src/http/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ fn frontend_config(config: &Config) -> serde_json::Value {
},
"sync": {
"pollPeriod": config.sync.poll_period.as_secs_f64(),
"interpretETHPasswords": config.sync.interpret_eth_passwords,
},
})
}
1 change: 1 addition & 0 deletions frontend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ type UploadConfig = {

type SyncConfig = {
pollPeriod: number;
interpretETHPasswords: boolean;
};

type MetadataLabel = "builtin:license" | "builtin:source" | TranslatedString;
Expand Down
17 changes: 10 additions & 7 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 @@ -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
4 changes: 4 additions & 0 deletions frontend/src/routes/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,10 @@ const SearchEvent: React.FC<SearchEventProps> = ({
title,
isLive,
created,
series: (seriesTitle && seriesId) ? {
title: seriesTitle,
id: seriesId,
} : null,
syncedData: {
duration,
startTime,
Expand Down
34 changes: 28 additions & 6 deletions frontend/src/routes/Video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
keyOfId,
playlistId,
getCredentials,
useAuthenticatedDataQuery,
} from "../util";
import { BREAKPOINT_SMALL, BREAKPOINT_MEDIUM } from "../GlobalStyle";
import { LinkButton } from "../ui/LinkButton";
Expand Down Expand Up @@ -470,11 +471,29 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath
if (event.__typename !== "AuthorizedEvent") {
return unreachable();
}

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

// ===ETH SPECIAL FEATURE===
// If the event is password protected and `interpret_eth_passwords` is enabled, 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 +522,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 @@ -618,6 +634,12 @@ const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ event, embedded }) =>
storage.setItem(CREDENTIALS_STORAGE_KEY + keyOfId(event.id), credentials);
storage.setItem(CREDENTIALS_STORAGE_KEY + event.opencastId, credentials);

// ===ETH SPECIAL FEATURE===
// If `interpret_eth_passwords` is enabled, this stores the series id of the event.
// With that, each other event of that series will also be unlocked.
if (CONFIG.sync.interpretETHPasswords && event.series?.id) {
storage.setItem(CREDENTIALS_STORAGE_KEY + 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 title }
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
10 changes: 7 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,10 @@ type ThumbnailProps = JSX.IntrinsicElements["div"] & {
title: string;
isLive: boolean;
created: string;
series?: {
id: string;
title: string;
} | null;
syncedData?: {
duration: number;
startTime?: string | null;
Expand Down Expand Up @@ -53,9 +57,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
48 changes: 34 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 @@ -253,19 +253,34 @@ 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.
*
* ===ETH SPECIAL FEATURE===
* This can be passed a series id and the authenticated data of an event.
* If the `interpret_eth_passwords` feature is enabled, this will check if credentials
* with that reference are stored and, if they are and the authenticated data is not yet
* present, query and return that data.
*/
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 an event/series id (i.e. with prefix `ev`/`sr`).
const credentials = seriesID && CONFIG.sync.interpretETHPasswords
? getCredentials(seriesId(keyOfId(seriesID)))
: getCredentials(eventID);
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-only" : "store-or-network" }
);

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

/**
Expand All @@ -277,10 +292,15 @@ export const useAuthenticatedDataQuery = (id: string) => {
* 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.
*
* ===ETH SPECIAL FEATURE===
* If the `interpret_eth_passwords` feature is enabled, this will also return credentials for
* a passed series ID, if stored. With this, unlocking an event of a series will also unlock
* all other events of that series since they share and inherit the series' credentials.
*/
export const getCredentials = (eventId: string): Credentials => {
const credentials = window.localStorage.getItem(CREDENTIALS_STORAGE_KEY + eventId)
?? window.sessionStorage.getItem(CREDENTIALS_STORAGE_KEY + eventId);
export const getCredentials = (id: string): Credentials => {
const credentials = window.localStorage.getItem(CREDENTIALS_STORAGE_KEY + id)
?? window.sessionStorage.getItem(CREDENTIALS_STORAGE_KEY + id);

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

0 comments on commit 85d4c1f

Please sign in to comment.