From f30f21d6e919be5fe7dc2426e0f712308d59d4ed Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Sat, 19 Oct 2024 12:55:55 +0200 Subject: [PATCH] Allow authentication via series id 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. --- frontend/src/routes/Embed.tsx | 21 ++++---- frontend/src/routes/Search.tsx | 7 ++- frontend/src/routes/Video.tsx | 48 +++++++++++++------ .../Realm/Content/Edit/EditMode/Video.tsx | 15 ++++-- frontend/src/routes/manage/Video/Shared.tsx | 1 + frontend/src/routes/manage/Video/index.tsx | 1 + frontend/src/ui/Blocks/Video.tsx | 15 +++--- frontend/src/ui/Video.tsx | 9 ++-- frontend/src/util/index.ts | 44 +++++++++++------ 9 files changed, 109 insertions(+), 52 deletions(-) diff --git a/frontend/src/routes/Embed.tsx b/frontend/src/routes/Embed.tsx index e143d061d..3a6f1930d 100644 --- a/frontend/src/routes/Embed.tsx +++ b/frontend/src/routes/Embed.tsx @@ -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"; @@ -39,7 +39,7 @@ export const EmbedVideoRoute = makeRoute({ const queryRef = loadQuery(query, { id, - ...getCredentials("event" + id), + ...getCredentials("event", id), }); @@ -69,7 +69,7 @@ export const EmbedOpencastVideoRoute = makeRoute({ const videoId = decodeURIComponent(matches[1]); const queryRef = loadQuery(query, { id: videoId, - ...getCredentials(videoId), + ...getCredentials("oc-event", videoId), }); return matchedEmbedRoute(query, queryRef); @@ -113,7 +113,7 @@ const embedEventFragment = graphql` description canWrite hasPassword - series { title opencastId } + series { title id opencastId } syncedData { updated startTime @@ -169,11 +169,14 @@ const Embed: React.FC = ({ query, queryRef }) => { ; } - return event.authorizedData - ? + const authorizedData = useAuthenticatedDataQuery( + event.id, + event.series?.id, + { authorizedData: event.authorizedData }, + ); + + return authorizedData + ? : ; }; diff --git a/frontend/src/routes/Search.tsx b/frontend/src/routes/Search.tsx index a8d32e753..70bd086ee 100644 --- a/frontend/src/routes/Search.tsx +++ b/frontend/src/routes/Search.tsx @@ -533,7 +533,9 @@ const SearchEvent: React.FC = ({ : 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 ( {{ @@ -544,6 +546,9 @@ const SearchEvent: React.FC = ({ title, isLive, created, + series: seriesId ? { + id: seriesId, + } : null, syncedData: { duration, startTime, diff --git a/frontend/src/routes/Video.tsx b/frontend/src/routes/Video.tsx index ea822920e..bb9cc950c 100644 --- a/frontend/src/routes/Video.tsx +++ b/frontend/src/routes/Video.tsx @@ -35,6 +35,8 @@ import { keyOfId, playlistId, getCredentials, + useAuthenticatedDataQuery, + credentialsStorageKey, } from "../util"; import { BREAKPOINT_SMALL, BREAKPOINT_MEDIUM } from "../GlobalStyle"; import { LinkButton } from "../ui/LinkButton"; @@ -125,7 +127,7 @@ export const VideoRoute = makeRoute({ id, realmPath, listId, - ...getCredentials(videoId), + ...getCredentials("event", id), }); return { @@ -194,7 +196,7 @@ export const OpencastVideoRoute = makeRoute({ id, realmPath, listId, - ...getCredentials(id), + ...getCredentials("oc-event", id), }); return { @@ -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(query, { - id: eventId(videoId), + id, listId: makeListId(url.searchParams.get("list")), - ...getCredentials(videoId), + ...getCredentials("event", id), }); return matchedDirectRoute(query, queryRef); @@ -302,7 +304,7 @@ export const DirectOpencastVideoRoute = makeRoute({ const queryRef = loadQuery(query, { id, listId: makeListId(url.searchParams.get("list")), - ...getCredentials(id), + ...getCredentials("oc-event", id), }); return matchedDirectRoute(query, queryRef); @@ -470,11 +472,27 @@ const VideoPage: React.FC = ({ eventRef, realmRef, playlistRef, basePath if (event.__typename !== "AuthorizedEvent") { return unreachable(); } - if (!isSynced(event)) { return ; } + // 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; @@ -503,12 +521,9 @@ const VideoPage: React.FC = ({ eventRef, realmRef, playlistRef, basePath - {event.authorizedData + {authorizedData ? @@ -615,9 +630,14 @@ const ProtectedPlayer: React.FC = ({ 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); diff --git a/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx b/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx index 4fed75472..35dadac04 100644 --- a/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx +++ b/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx @@ -43,7 +43,7 @@ export const EditVideoBlock: React.FC = ({ block: blockRef ... on AuthorizedEvent { id title - series { title } + series { id title } created isLive creators @@ -82,7 +82,12 @@ export const EditVideoBlock: React.FC = ({ 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 data}> @@ -138,6 +143,7 @@ const EventSelector: React.FC = ({ onChange, onBlur, default items { id title + seriesId seriesTitle creators thumbnail @@ -168,7 +174,10 @@ const EventSelector: React.FC = ({ 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: () => {}, diff --git a/frontend/src/routes/manage/Video/Shared.tsx b/frontend/src/routes/manage/Video/Shared.tsx index 009a68efd..70bd5c0fd 100644 --- a/frontend/src/routes/manage/Video/Shared.tsx +++ b/frontend/src/routes/manage/Video/Shared.tsx @@ -98,6 +98,7 @@ const query = graphql` tracks { flavor resolution mimetype uri } } series { + id title opencastId ...SeriesBlockSeriesData diff --git a/frontend/src/routes/manage/Video/index.tsx b/frontend/src/routes/manage/Video/index.tsx index b0e78e1c1..167d68cae 100644 --- a/frontend/src/routes/manage/Video/index.tsx +++ b/frontend/src/routes/manage/Video/index.tsx @@ -77,6 +77,7 @@ const query = graphql` } items { id title created description isLive tobiraDeletionTimestamp + series { id } syncedData { duration updated startTime endTime } diff --git a/frontend/src/ui/Blocks/Video.tsx b/frontend/src/ui/Blocks/Video.tsx index 47d6d28e7..55b3d7332 100644 --- a/frontend/src/ui/Blocks/Video.tsx +++ b/frontend/src/ui/Blocks/Video.tsx @@ -38,7 +38,7 @@ export const VideoBlock: React.FC = ({ fragRef, basePath }) => { description canWrite hasPassword - series { title opencastId } + series { title id opencastId } syncedData { duration updated @@ -69,9 +69,11 @@ export const VideoBlock: React.FC = ({ 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
@@ -79,10 +81,7 @@ export const VideoBlock: React.FC = ({ fragRef, basePath }) => { {authorizedData && isSynced(event) ? : diff --git a/frontend/src/ui/Video.tsx b/frontend/src/ui/Video.tsx index d0e45c513..467ee6970 100644 --- a/frontend/src/ui/Video.tsx +++ b/frontend/src/ui/Video.tsx @@ -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"] & { @@ -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; @@ -53,9 +56,9 @@ export const Thumbnail: React.FC = ({ }) => { 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 ? ( diff --git a/frontend/src/util/index.ts b/frontend/src/util/index.ts index e44337827..633161a87 100644 --- a/frontend/src/util/index.ts +++ b/frontend/src/util/index.ts @@ -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"; @@ -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( +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( 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;