From b00d85a0138bb546a2eab9b114aa8e570418d50a Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Tue, 20 Aug 2024 16:30:12 +0200 Subject: [PATCH 01/26] Add migration and API for new preview_roles This is in preparation for upcoming changes regarding event specific authentication. Users with a preview role but without read access will only be allowed to view text metadata like title and description of events corresponding to that role. Specifically they will not be allowed to watch the video or access "revealing" information like thumbnail, slide segments, captions or track data. This will be implemented in the following commit. --- backend/src/api/model/event.rs | 13 ++- backend/src/api/model/search/mod.rs | 14 ++-- backend/src/api/query.rs | 2 +- backend/src/db/migrations.rs | 1 + .../db/migrations/39-event-preview-roles.sql | 79 +++++++++++++++++++ backend/src/search/event.rs | 23 +++++- frontend/src/schema.graphql | 7 +- 7 files changed, 125 insertions(+), 14 deletions(-) create mode 100644 backend/src/db/migrations/39-event-preview-roles.sql diff --git a/backend/src/api/model/event.rs b/backend/src/api/model/event.rs index a69487d90..0208992c5 100644 --- a/backend/src/api/model/event.rs +++ b/backend/src/api/model/event.rs @@ -37,6 +37,7 @@ pub(crate) struct AuthorizedEvent { pub(crate) metadata: ExtraMetadata, pub(crate) read_roles: Vec, pub(crate) write_roles: Vec, + pub(crate) preview_roles: Vec, pub(crate) synced_data: Option, pub(crate) tobira_deletion_timestamp: Option>, @@ -64,7 +65,7 @@ impl_from_db!( title, description, duration, creators, thumbnail, metadata, created, updated, start_time, end_time, tracks, captions, segments, - read_roles, write_roles, + read_roles, write_roles, preview_roles, tobira_deletion_timestamp, }, }, @@ -81,6 +82,7 @@ impl_from_db!( metadata: row.metadata(), read_roles: row.read_roles::>(), write_roles: row.write_roles::>(), + preview_roles: row.preview_roles::>(), tobira_deletion_timestamp: row.tobira_deletion_timestamp(), synced_data: match row.state::() { EventState::Ready => Some(SyncedEventData { @@ -198,6 +200,11 @@ impl AuthorizedEvent { fn write_roles(&self) -> &[String] { &self.write_roles } + /// This includes all read roles (and by extension write roles, + /// as they are a subset of read roles). + fn preview_roles(&self) -> &[String] { + &self.preview_roles + } fn synced_data(&self) -> &Option { &self.synced_data @@ -306,7 +313,7 @@ impl AuthorizedEvent { .await? .map(|row| { let event = Self::from_row_start(&row); - if context.auth.overlaps_roles(&event.read_roles) { + if context.auth.overlaps_roles(&event.preview_roles) { Event::Event(event) } else { Event::NotAllowed(NotAllowed) @@ -327,7 +334,7 @@ impl AuthorizedEvent { context.db .query_mapped(&query, dbargs![&series_key], |row| { let event = Self::from_row_start(&row); - if !context.auth.overlaps_roles(&event.read_roles) { + if !context.auth.overlaps_roles(&event.preview_roles) { return VideoListEntry::NotAllowed(NotAllowed); } diff --git a/backend/src/api/model/search/mod.rs b/backend/src/api/model/search/mod.rs index 96ec3551e..4a98f4a1a 100644 --- a/backend/src/api/model/search/mod.rs +++ b/backend/src/api/model/search/mod.rs @@ -166,7 +166,7 @@ pub(crate) async fn perform( let selection = search::Event::select(); let query = format!("select {selection} from search_events \ where id = (select id from events where opencast_id = $1) \ - and (read_roles || 'ROLE_ADMIN'::text) && $2"); + and (preview_roles || 'ROLE_ADMIN'::text) && $2"); let items: Vec = context.db .query_opt(&query, &[&uuid_query, &context.auth.roles_vec()]) .await? @@ -188,7 +188,7 @@ pub(crate) async fn perform( // Prepare the event search let filter = Filter::And( std::iter::once(Filter::Leaf("listed = true".into())) - .chain(acl_filter("read_roles", context)) + .chain(acl_filter("preview_roles", context)) // Filter out live events that are already over. .chain([Filter::Or([ Filter::Leaf("is_live = false ".into()), @@ -332,7 +332,7 @@ pub(crate) async fn all_events( if writable_only { writable } else { - Filter::or([Filter::listed_and_readable(context), writable]) + Filter::or([Filter::listed_and_readable("preview_roles", context), writable]) } }).to_string(); @@ -429,7 +429,7 @@ pub(crate) async fn all_playlists( if writable_only { writable } else { - Filter::or([Filter::listed_and_readable(context), writable]) + Filter::or([Filter::listed_and_readable("read_roles", context), writable]) } }).to_string(); @@ -492,8 +492,10 @@ impl Filter { /// Returns a filter checking that the current user has read access and that /// the item is listed. If the user has the privilege to find unlisted /// item, the second check is not performed. - fn listed_and_readable(context: &Context) -> Self { - let readable = Self::acl_access("read_roles", context); + /// "Readable" in this context can mean either "preview-able" in case of events + /// or actual "readable" in case of playlists, as they do not have preview roles. + fn listed_and_readable(roles_field: &str, context: &Context) -> Self { + let readable = Self::acl_access(roles_field, context); if context.auth.can_find_unlisted_items(&context.config.auth) { readable } else { diff --git a/backend/src/api/query.rs b/backend/src/api/query.rs index af3b8952d..127f57e17 100644 --- a/backend/src/api/query.rs +++ b/backend/src/api/query.rs @@ -118,7 +118,7 @@ impl Query { /// /// - Events that the user has write access to (listed & unlisted). /// - If `writable_only` is false, this also searches through videos that - /// the user has read access to. However, unless the user has the + /// the user has preview access to. However, unless the user has the /// privilege to find unlisted events, only listed ones are searched. async fn search_all_events( query: String, diff --git a/backend/src/db/migrations.rs b/backend/src/db/migrations.rs index 812802c9a..187a6e1cd 100644 --- a/backend/src/db/migrations.rs +++ b/backend/src/db/migrations.rs @@ -371,4 +371,5 @@ static MIGRATIONS: Lazy> = include_migrations![ 36: "playlist-blocks", 37: "redo-search-triggers-and-listed", 38: "event-texts", + 39: "event-preview-roles", ]; diff --git a/backend/src/db/migrations/39-event-preview-roles.sql b/backend/src/db/migrations/39-event-preview-roles.sql new file mode 100644 index 000000000..ccc4db8c3 --- /dev/null +++ b/backend/src/db/migrations/39-event-preview-roles.sql @@ -0,0 +1,79 @@ +-- Users with a preview role may only view text metadata of an event. +-- Any other action will require read or write roles (these imply preview rights). + +alter table all_events add column preview_roles text[] not null default '{}'; + +-- For convenience, all read roles are also copied over to preview roles. +-- Removing any roles from read will however _not_ remove them from preview, as they +-- might also have been added separately and we can't really account for that. +update all_events set preview_roles = read_roles; + +create function sync_preview_roles() +returns trigger language plpgsql as $$ +begin + new.preview_roles := ( + select array_agg(distinct role) from unnest(new.preview_roles || new.read_roles) as role + ); + return new; +end; +$$; + +create trigger sync_preview_roles_on_change +before insert or update of read_roles, preview_roles on all_events +for each row +execute function sync_preview_roles(); + +-- replace outdated view to include preview_roles +create or replace view events as select * from all_events where tobira_deletion_timestamp is null; + +-- add `preview_roles` to `search_events` view as well +drop view search_events; +create view search_events as + select + events.id, events.opencast_id, events.state, + events.series, series.title as series_title, + events.title, events.description, events.creators, + events.thumbnail, events.duration, + events.is_live, events.updated, events.created, events.start_time, events.end_time, + events.read_roles, events.write_roles, events.preview_roles, + coalesce( + array_agg( + distinct + row(search_realms.*)::search_realms + ) filter(where search_realms.id is not null), + '{}' + ) as host_realms, + is_audio_only(events.tracks) as audio_only, + coalesce( + array_agg(playlists.id) + filter(where playlists.id is not null), + '{}' + ) as containing_playlists, + ( + select array_agg(t) + from ( + select unnest(texts) as t + from event_texts + where event_id = events.id and ty = 'slide-text' + ) as subquery + ) as slide_texts, + ( + select array_agg(t) + from ( + select unnest(texts) as t + from event_texts + where event_id = events.id and ty = 'caption' + ) as subquery + ) as caption_texts + from all_events as events + left join series on events.series = series.id + -- This syntax instead of `foo = any(...)` to use the index, which is not + -- otherwise used. + left join playlists on array[events.opencast_id] <@ event_entry_ids(entries) + left join blocks on ( + type = 'series' and blocks.series = events.series + or type = 'video' and blocks.video = events.id + or type = 'playlist' and blocks.playlist = playlists.id + ) + left join search_realms on search_realms.id = blocks.realm + group by events.id, series.id; diff --git a/backend/src/search/event.rs b/backend/src/search/event.rs index 588b473b1..bd45a4198 100644 --- a/backend/src/search/event.rs +++ b/backend/src/search/event.rs @@ -49,6 +49,7 @@ pub(crate) struct Event { // we just assume that the cases where this matters are very rare. And in // those cases we just accept that our endpoint returns fewer than X // items. + pub(crate) preview_roles: Vec, pub(crate) read_roles: Vec, pub(crate) write_roles: Vec, @@ -74,7 +75,7 @@ impl_from_db!( search_events.{ id, series, series_title, title, description, creators, thumbnail, duration, is_live, updated, created, start_time, end_time, audio_only, - read_roles, write_roles, host_realms, slide_texts, caption_texts, + read_roles, write_roles, preview_roles, host_realms, slide_texts, caption_texts, }, }, |row| { @@ -100,6 +101,7 @@ impl_from_db!( start_time: row.start_time(), end_time, end_time_timestamp: end_time.map(|date_time| date_time.timestamp()), + preview_roles: util::encode_acl(&row.preview_roles::>()), read_roles: util::encode_acl(&row.read_roles::>()), write_roles: util::encode_acl(&row.write_roles::>()), listed: host_realms.iter().any(|realm| !realm.is_user_realm()), @@ -135,8 +137,23 @@ impl Event { pub(super) async fn prepare_index(index: &Index) -> Result<()> { util::lazy_set_special_attributes(index, "event", FieldAbilities { - searchable: &["title", "creators", "description", "series_title", "slide_texts.texts", "caption_texts.texts"], - filterable: &["listed", "read_roles", "write_roles", "is_live", "end_time_timestamp", "created_timestamp"], + searchable: &[ + "title", + "creators", + "description", + "series_title", + "slide_texts.texts", + "caption_texts.texts", + ], + filterable: &[ + "listed", + "preview_roles", + "read_roles", + "write_roles", + "is_live", + "end_time_timestamp", + "created_timestamp" + ], sortable: &["updated_timestamp"], }).await } diff --git a/frontend/src/schema.graphql b/frontend/src/schema.graphql index e9abdd11e..bd9aa0460 100644 --- a/frontend/src/schema.graphql +++ b/frontend/src/schema.graphql @@ -317,6 +317,11 @@ type AuthorizedEvent implements Node { readRoles: [String!]! "This doesn't contain `ROLE_ADMIN` as that is included implicitly." writeRoles: [String!]! + """ + This includes all read roles (and by extension write roles, + as they are a subset of read roles). + """ + previewRoles: [String!]! syncedData: SyncedEventData "Whether the current user has write access to this event." canWrite: Boolean! @@ -703,7 +708,7 @@ type Query { - Events that the user has write access to (listed & unlisted). - If `writable_only` is false, this also searches through videos that - the user has read access to. However, unless the user has the + the user has preview access to. However, unless the user has the privilege to find unlisted events, only listed ones are searched. """ searchAllEvents(query: String!, writableOnly: Boolean!): EventSearchOutcome! From f5898d76e51bf005c33383520ab9b99e06fd3f05 Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Wed, 21 Aug 2024 17:43:18 +0200 Subject: [PATCH 02/26] Move some event data to `authorizedData` field Authorized date is comprised of track information, segments, caption texts and thumbnails. This will be used to distinguish and handle any event data that is not viewable and shouldn't be exposed by the api when a user only has a `preview` role. The presence or absence of this data can be used to determine whether a user has a certain authorization level - this can be useful for certain frontend checks. This data will also get a credentials check which is to be performed on the backend side in a later commit. With that, it can be exposed when a user successfully authenticates. --- backend/src/api/model/event.rs | 24 +++++++++++++++++++ frontend/src/routes/Embed.tsx | 11 ++++++++- frontend/src/routes/Search.tsx | 8 ++++--- frontend/src/routes/Video.tsx | 19 +++++++++++---- .../Realm/Content/Edit/EditMode/Video.tsx | 7 +++++- frontend/src/routes/manage/Video/Shared.tsx | 4 +++- .../routes/manage/Video/TechnicalDetails.tsx | 8 +++---- frontend/src/routes/manage/Video/index.tsx | 5 +++- frontend/src/schema.graphql | 24 ++++++++++++------- frontend/src/ui/Blocks/Video.tsx | 14 ++++++++++- frontend/src/ui/Blocks/VideoList.tsx | 6 ++--- frontend/src/ui/Video.tsx | 16 +++++++------ frontend/src/ui/player/Paella.tsx | 12 +++++----- frontend/src/ui/player/index.tsx | 6 +++-- 14 files changed, 119 insertions(+), 45 deletions(-) diff --git a/backend/src/api/model/event.rs b/backend/src/api/model/event.rs index 0208992c5..fd1f39efb 100644 --- a/backend/src/api/model/event.rs +++ b/backend/src/api/model/event.rs @@ -40,6 +40,7 @@ pub(crate) struct AuthorizedEvent { pub(crate) preview_roles: Vec, pub(crate) synced_data: Option, + pub(crate) authorized_data: Option, pub(crate) tobira_deletion_timestamp: Option>, } @@ -51,6 +52,10 @@ pub(crate) struct SyncedEventData { /// Duration in milliseconds duration: i64, +} + +#[derive(Debug)] +pub(crate) struct AuthorizedEventData { tracks: Vec, thumbnail: Option, captions: Vec, @@ -90,6 +95,11 @@ impl_from_db!( start_time: row.start_time(), end_time: row.end_time(), duration: row.duration(), + }), + EventState::Waiting => None, + }, + authorized_data: match row.state::() { + EventState::Ready => Some(AuthorizedEventData { thumbnail: row.thumbnail(), tracks: row.tracks::>().into_iter().map(Track::from).collect(), captions: row.captions::>() @@ -152,6 +162,11 @@ impl SyncedEventData { fn duration(&self) -> f64 { self.duration as f64 } +} + +/// Represents event data that is only accessible for users with read access. +#[graphql_object(Context = Context, impl = NodeValue)] +impl AuthorizedEventData { fn tracks(&self) -> &[Track] { &self.tracks } @@ -210,6 +225,15 @@ impl AuthorizedEvent { &self.synced_data } + /// Returns the authorized event data if the user has read access. + fn authorized_data(&self, context: &Context) -> Option<&AuthorizedEventData> { + if context.auth.overlaps_roles(&self.read_roles) { + self.authorized_data.as_ref() + } else { + None + } + } + /// Whether the current user has write access to this event. fn can_write(&self, context: &Context) -> bool { context.auth.overlaps_roles(&self.write_roles) diff --git a/frontend/src/routes/Embed.tsx b/frontend/src/routes/Embed.tsx index 79908e81b..191da02d4 100644 --- a/frontend/src/routes/Embed.tsx +++ b/frontend/src/routes/Embed.tsx @@ -103,6 +103,8 @@ const embedEventFragment = graphql` startTime endTime duration + } + authorizedData { thumbnail tracks { uri flavor mimetype resolution isMaster } captions { uri lang } @@ -151,7 +153,14 @@ const Embed: React.FC = ({ query, queryRef }) => { ; } - return ; + if (!event.authorizedData) { + return <>nop; // TODO + } + + return ; }; export const BlockEmbedRoute = makeRoute({ diff --git a/frontend/src/routes/Search.tsx b/frontend/src/routes/Search.tsx index 8d9b62ac5..64a86ec95 100644 --- a/frontend/src/routes/Search.tsx +++ b/frontend/src/routes/Search.tsx @@ -531,10 +531,12 @@ const SearchEvent: React.FC = ({ isLive, created, syncedData: { - thumbnail, duration, startTime, endTime, + }, + authorizedData: { + thumbnail, audioOnly, }, }} @@ -631,7 +633,7 @@ const slidePreviewQuery = graphql` eventById(id: $id) { ...on AuthorizedEvent { id - syncedData { + authorizedData { segments { startTime uri } } } @@ -750,7 +752,7 @@ const TextMatchTooltipWithMaybeImage: React.FC { const data = usePreloadedQuery(slidePreviewQuery, queryRef); - const segments = data.eventById?.syncedData?.segments ?? []; + const segments = data.eventById?.authorizedData?.segments ?? []; // Find the segment with its start time closest to the `start` of the text // match, while still being smaller. diff --git a/frontend/src/routes/Video.tsx b/frontend/src/routes/Video.tsx index d49213a34..bb27780ed 100644 --- a/frontend/src/routes/Video.tsx +++ b/frontend/src/routes/Video.tsx @@ -368,12 +368,14 @@ const eventFragment = graphql` syncedData { updated duration - thumbnail startTime endTime + } + authorizedData { tracks { uri flavor mimetype resolution isMaster } captions { uri lang } segments { uri startTime } + thumbnail } series { id @@ -425,7 +427,7 @@ const VideoPage: React.FC = ({ eventRef, realmRef, playlistRef, basePath "@type": "VideoObject", name: event.title, description: event.description ?? undefined, - thumbnailUrl: event.syncedData.thumbnail ?? undefined, + thumbnailUrl: event.authorizedData?.thumbnail ?? undefined, uploadDate: event.created, duration: toIsoDuration(event.syncedData.duration), ...event.isLive && event.syncedData.startTime && event.syncedData.endTime && { @@ -440,13 +442,20 @@ const VideoPage: React.FC = ({ eventRef, realmRef, playlistRef, basePath // but it's not clear what for. }; + if (!event.authorizedData) { + return <>nop; // TODO + } + return <> @@ -740,9 +749,9 @@ const ShareButton: React.FC<{ event: SyncedEvent }> = ({ event }) => { ; }, "embed": () => { - const ar = event.syncedData == null + const ar = event.authorizedData == null ? [16, 9] - : getPlayerAspectRatio(event.syncedData.tracks); + : getPlayerAspectRatio(event.authorizedData.tracks); const url = new URL(location.href.replace(timeStringPattern, "")); url.search = addEmbedTimestamp && timestamp 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 8c75911e2..4fed75472 100644 --- a/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx +++ b/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx @@ -47,7 +47,8 @@ export const EditVideoBlock: React.FC = ({ block: blockRef created isLive creators - syncedData { thumbnail duration startTime endTime } + syncedData { duration startTime endTime } + authorizedData { thumbnail } } } showTitle @@ -166,6 +167,7 @@ const EventSelector: React.FC = ({ onChange, onBlur, default // starting with `ev`. id: item.id.replace(/^es/, "ev"), syncedData: item, + authorizedData: item, series: item.seriesTitle == null ? null : { title: item.seriesTitle }, }))); }, @@ -197,6 +199,9 @@ const formatOption = (event: Option, t: TFunction) => ( ...event, syncedData: event.syncedData && { ...event.syncedData, + }, + authorizedData: event.authorizedData && { + ...event.authorizedData, audioOnly: false, // TODO }, }} diff --git a/frontend/src/routes/manage/Video/Shared.tsx b/frontend/src/routes/manage/Video/Shared.tsx index 7f9f13ac9..009a68efd 100644 --- a/frontend/src/routes/manage/Video/Shared.tsx +++ b/frontend/src/routes/manage/Video/Shared.tsx @@ -89,10 +89,12 @@ const query = graphql` acl { role actions info { label implies large } } syncedData { duration - thumbnail updated startTime endTime + } + authorizedData { + thumbnail tracks { flavor resolution mimetype uri } } series { diff --git a/frontend/src/routes/manage/Video/TechnicalDetails.tsx b/frontend/src/routes/manage/Video/TechnicalDetails.tsx index 6ddbd9742..220e30b03 100644 --- a/frontend/src/routes/manage/Video/TechnicalDetails.tsx +++ b/frontend/src/routes/manage/Video/TechnicalDetails.tsx @@ -24,8 +24,8 @@ type Props = { type TrackInfoProps = { event: { - syncedData?: null | { - tracks: NonNullable["tracks"]; + authorizedData?: null | { + tracks: NonNullable["tracks"]; }; }; className?: string; @@ -90,7 +90,7 @@ export const TrackInfo: React.FC = ( ) => { const { t } = useTranslation(); - if (event.syncedData == null) { + if (event.authorizedData == null) { return null; } @@ -106,7 +106,7 @@ export const TrackInfo: React.FC = ( }; const flavors: Map = new Map(); - for (const { flavor, resolution, mimetype, uri } of event.syncedData.tracks) { + for (const { flavor, resolution, mimetype, uri } of event.authorizedData.tracks) { let tracks = flavors.get(flavor); if (tracks === undefined) { tracks = []; diff --git a/frontend/src/routes/manage/Video/index.tsx b/frontend/src/routes/manage/Video/index.tsx index 42a1fc948..b0e78e1c1 100644 --- a/frontend/src/routes/manage/Video/index.tsx +++ b/frontend/src/routes/manage/Video/index.tsx @@ -78,7 +78,10 @@ const query = graphql` items { id title created description isLive tobiraDeletionTimestamp syncedData { - duration thumbnail updated startTime endTime + duration updated startTime endTime + } + authorizedData { + thumbnail tracks { resolution } } } diff --git a/frontend/src/schema.graphql b/frontend/src/schema.graphql index bd9aa0460..3584cbbc5 100644 --- a/frontend/src/schema.graphql +++ b/frontend/src/schema.graphql @@ -213,10 +213,6 @@ type SyncedEventData implements Node { endTime: DateTimeUtc "Duration in ms." duration: Float! - tracks: [Track!]! - thumbnail: String - captions: [Caption!]! - segments: [Segment!]! } input NewPlaylistBlock { @@ -323,6 +319,8 @@ type AuthorizedEvent implements Node { """ previewRoles: [String!]! syncedData: SyncedEventData + "Returns the authorized event data if the user has read access." + authorizedData: AuthorizedEventData "Whether the current user has write access to this event." canWrite: Boolean! tobiraDeletionTimestamp: DateTimeUtc @@ -399,11 +397,6 @@ input UpdateTextBlock { content: String } -type ByteSpan { - start: Int! - len: Int! -} - union Playlist = AuthorizedPlaylist | NotAllowed type Realm implements Node { @@ -497,6 +490,11 @@ type Realm implements Node { canCurrentUserModerate: Boolean! } +type ByteSpan { + start: Int! + len: Int! +} + "A block just showing the list of videos in an Opencast playlist" type PlaylistBlock implements Block & RealmNameSourceBlock { playlist: Playlist @@ -761,6 +759,14 @@ type EmptyQuery { union RemoveMountedSeriesOutcome = RemovedRealm | RemovedBlock +"Represents event data that is only accessible for users with read access." +type AuthorizedEventData implements Node { + tracks: [Track!]! + thumbnail: String + captions: [Caption!]! + segments: [Segment!]! +} + union PlaylistSearchOutcome = SearchUnavailable | PlaylistSearchResults "A string in different languages" diff --git a/frontend/src/ui/Blocks/Video.tsx b/frontend/src/ui/Blocks/Video.tsx index ed1d77487..383914f29 100644 --- a/frontend/src/ui/Blocks/Video.tsx +++ b/frontend/src/ui/Blocks/Video.tsx @@ -38,6 +38,8 @@ export const VideoBlock: React.FC = ({ fragRef, basePath }) => { updated startTime endTime + } + authorizedData { thumbnail tracks { uri flavor mimetype resolution isMaster } captions { uri lang } @@ -61,11 +63,21 @@ export const VideoBlock: React.FC = ({ fragRef, basePath }) => { return unreachable(); } + if (!event.authorizedData) { + return <>nop; // TODO + } + return
{showTitle && } {isSynced(event) ? <PlayerContextProvider> - <InlinePlayer event={event} css={{ maxWidth: 800 }} /> + <InlinePlayer + event={{ + ...event, + authorizedData: event.authorizedData, + }} + css={{ maxWidth: 800 }} + /> </PlayerContextProvider> : <Card kind="info">{t("video.not-ready.title")}</Card>} {showLink && <Link diff --git a/frontend/src/ui/Blocks/VideoList.tsx b/frontend/src/ui/Blocks/VideoList.tsx index 32f3c1ffa..6a1c13dba 100644 --- a/frontend/src/ui/Blocks/VideoList.tsx +++ b/frontend/src/ui/Blocks/VideoList.tsx @@ -56,11 +56,9 @@ export const videoListEventFragment = graphql` isLive description series { title id } - syncedData { - duration + syncedData { duration startTime endTime } + authorizedData { thumbnail - startTime - endTime tracks { resolution } } } diff --git a/frontend/src/ui/Video.tsx b/frontend/src/ui/Video.tsx index 12f8c78be..93bbc4e6e 100644 --- a/frontend/src/ui/Video.tsx +++ b/frontend/src/ui/Video.tsx @@ -14,9 +14,11 @@ type ThumbnailProps = JSX.IntrinsicElements["div"] & { created: string; syncedData?: { duration: number; - thumbnail?: string | null; startTime?: string | null; endTime?: string | null; + } | null; + authorizedData?: { + thumbnail?: string | null; } & ( { tracks: readonly { resolution?: readonly number[] | null }[]; @@ -42,19 +44,19 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ const { t } = useTranslation(); const isDark = useColorScheme().scheme === "dark"; const isUpcoming = isUpcomingLiveEvent(event.syncedData?.startTime ?? null, event.isLive); - const audioOnly = event.syncedData + const audioOnly = event.authorizedData ? ( - "audioOnly" in event.syncedData - ? event.syncedData.audioOnly - : event.syncedData.tracks.every(t => t.resolution == null) + "audioOnly" in event.authorizedData + ? event.authorizedData.audioOnly + : event.authorizedData.tracks.every(t => t.resolution == null) ) : false; let inner; - if (event.syncedData?.thumbnail != null && !deletionIsPending) { + if (event.authorizedData?.thumbnail != null && !deletionIsPending) { // We have a proper thumbnail. inner = <ThumbnailImg - src={event.syncedData.thumbnail} + src={event.authorizedData.thumbnail} alt={t("video.thumbnail-for", { video: event.title })} />; } else { diff --git a/frontend/src/ui/player/Paella.tsx b/frontend/src/ui/player/Paella.tsx index 6f13b772e..2afea116c 100644 --- a/frontend/src/ui/player/Paella.tsx +++ b/frontend/src/ui/player/Paella.tsx @@ -43,7 +43,7 @@ const PaellaPlayer: React.FC<PaellaPlayerProps> = ({ event }) => { if (!paella.current) { // Video/event specific information we have to give to Paella. const tracksByKind: Record<string, Track[]> = {}; - for (const track of event.syncedData.tracks) { + for (const track of event.authorizedData.tracks) { const kind = track.flavor.split("/")[0]; if (!(kind in tracksByKind)) { tracksByKind[kind] = []; @@ -69,7 +69,7 @@ const PaellaPlayer: React.FC<PaellaPlayerProps> = ({ event }) => { metadata: { title: event.title, duration: fixedDuration, - preview: event.syncedData.thumbnail, + preview: event.authorizedData.thumbnail, // These are not strictly necessary for Paella to know, but can be used by // plugins, like the Matomo plugin. It is not well defined what to pass how, @@ -87,7 +87,7 @@ const PaellaPlayer: React.FC<PaellaPlayerProps> = ({ event }) => { content: key, sources: tracksToPaellaSources(tracks, event.isLive), })), - captions: event.syncedData.captions.map(({ uri, lang }, index) => ({ + captions: event.authorizedData.captions.map(({ uri, lang }, index) => ({ format: "vtt", url: uri, lang: lang ?? undefined, @@ -95,9 +95,9 @@ const PaellaPlayer: React.FC<PaellaPlayerProps> = ({ event }) => { // improved in the future, hopefully by getting better information. text: t("video.caption") + (lang ? ` (${lang})` : "") - + (event.syncedData.captions.length > 1 ? ` [${index + 1}]` : ""), + + (event.authorizedData.captions.length > 1 ? ` [${index + 1}]` : ""), })), - frameList: event.syncedData.segments.map(segment => { + frameList: event.authorizedData.segments.map(segment => { const time = segment.startTime / 1000; return { id: "frame_" + time, @@ -116,7 +116,7 @@ const PaellaPlayer: React.FC<PaellaPlayerProps> = ({ event }) => { if (manifest.streams.length > 1 && !("presenter" in tracksByKind)) { // eslint-disable-next-line no-console console.warn("Picking first stream as main audio source. Tracks: ", - event.syncedData.tracks); + event.authorizedData.tracks); manifest.streams[0].role = "mainAudio"; } diff --git a/frontend/src/ui/player/index.tsx b/frontend/src/ui/player/index.tsx index 4e093db67..083724bcb 100644 --- a/frontend/src/ui/player/index.tsx +++ b/frontend/src/ui/player/index.tsx @@ -39,6 +39,8 @@ export type PlayerEvent = { startTime?: string | null; endTime?: string | null; duration: number; + }; + authorizedData: { tracks: readonly Track[]; captions: readonly Caption[]; thumbnail?: string | null; @@ -96,7 +98,7 @@ export const Player: React.FC<PlayerProps> = ({ event, onEventStateChange }) => }); return ( - <Suspense fallback={<PlayerFallback image={event.syncedData.thumbnail} />}> + <Suspense fallback={<PlayerFallback image={event.authorizedData?.thumbnail} />}> {event.isLive && (hasStarted === false || hasEnded === true) ? <LiveEventPlaceholder {...{ ...hasStarted === false @@ -125,7 +127,7 @@ const delayTill = (date: Date): number => { * in order to work correctly. */ export const InlinePlayer: React.FC<PlayerProps> = ({ className, event, ...playerProps }) => { - const aspectRatio = getPlayerAspectRatio(event.syncedData.tracks); + const aspectRatio = getPlayerAspectRatio(event.authorizedData.tracks); const isDark = useColorScheme().scheme === "dark"; const ref = useRef<HTMLDivElement>(null); From 7d003dd1db7da51b03343cf6523ba243d800655b Mon Sep 17 00:00:00 2001 From: Ole Wieners <olewieners@yahoo.com> Date: Mon, 26 Aug 2024 12:11:25 +0200 Subject: [PATCH 03/26] Apply UI changes related to new preview roles and authorized data Some video pages will now show a placeholder and note instead of the player, while these videos in series blocks and search results will have a "special" preview thumbnail instead of the real one. This applies to videos a user only has preview access to, which is a new special case: The event is authorized, but there is some data (thumbnail, captions, segments and tracks) that is only viewable when the user has at least read access. --- frontend/src/i18n/locales/de.yaml | 4 ++- frontend/src/i18n/locales/en.yaml | 2 ++ frontend/src/routes/Video.tsx | 47 ++++++++++++++++++++++--------- frontend/src/ui/Video.tsx | 36 +++++++++++++++-------- frontend/src/ui/player/index.tsx | 8 ++++-- 5 files changed, 68 insertions(+), 29 deletions(-) diff --git a/frontend/src/i18n/locales/de.yaml b/frontend/src/i18n/locales/de.yaml index 401bc2b9b..aa9d53c96 100644 --- a/frontend/src/i18n/locales/de.yaml +++ b/frontend/src/i18n/locales/de.yaml @@ -130,9 +130,11 @@ video: link: Zur Videoseite part-of-series: Teil der Serie more-from-series: Mehr von „{{series}}“ - more-from-playlist: Mehr von “{{playlist}}” + more-from-playlist: Mehr von „{{playlist}}” deleted-video-block: Das hier referenzierte Video wurde gelöscht. not-allowed-video-block: Sie sind nicht autorisiert, das hier eingebettete Video zu sehen. + preview: Vorschau + preview-only: Sie brauchen zusätzliche Berichtigungen, um dieses Video anzuschauen. not-ready: title: Video noch nicht verarbeitet text: > diff --git a/frontend/src/i18n/locales/en.yaml b/frontend/src/i18n/locales/en.yaml index 3dd91daf6..fc01b817b 100644 --- a/frontend/src/i18n/locales/en.yaml +++ b/frontend/src/i18n/locales/en.yaml @@ -131,6 +131,8 @@ video: more-from-playlist: More from “{{playlist}}” deleted-video-block: The video referenced here was deleted. not-allowed-video-block: You are not allowed to view the video embedded here. + preview: Preview + preview-only: You need additional permissions to watch this video. not-ready: title: Video not processed yet text: > diff --git a/frontend/src/routes/Video.tsx b/frontend/src/routes/Video.tsx index bb27780ed..8ab4c296f 100644 --- a/frontend/src/routes/Video.tsx +++ b/frontend/src/routes/Video.tsx @@ -2,7 +2,9 @@ import React, { ReactElement, ReactNode, useEffect, useRef, useState } from "rea import { graphql, GraphQLTaggedNode, PreloadedQuery, useFragment } from "react-relay/hooks"; import { useTranslation } from "react-i18next"; import { OperationType } from "relay-runtime"; -import { LuCode, LuDownload, LuLink, LuQrCode, LuRss, LuSettings, LuShare2 } from "react-icons/lu"; +import { + LuCode, LuDownload, LuInfo, LuLink, LuQrCode, LuRss, LuSettings, LuShare2, +} from "react-icons/lu"; import { QRCodeCanvas } from "qrcode.react"; import { match, unreachable, ProtoButton, @@ -16,7 +18,7 @@ import { InitialLoading, RootLoader } from "../layout/Root"; import { NotFound } from "./NotFound"; import { Nav } from "../layout/Navigation"; import { WaitingPage } from "../ui/Waiting"; -import { getPlayerAspectRatio, InlinePlayer } from "../ui/player"; +import { getPlayerAspectRatio, InlinePlayer, PlayerPlaceholder } from "../ui/player"; import { SeriesBlockFromSeries } from "../ui/Blocks/Series"; import { makeRoute, MatchedRoute } from "../rauta"; import { isValidRealmPath } from "./Realm"; @@ -442,23 +444,23 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath // but it's not clear what for. }; - if (!event.authorizedData) { - return <>nop</>; // TODO - } return <> <Breadcrumbs path={breadcrumbs} tail={event.title} /> <script type="application/ld+json">{JSON.stringify(structuredData)}</script> <PlayerContextProvider> - <InlinePlayer - event={{ - ...event, - authorizedData: event.authorizedData, - }} - css={{ margin: "-4px auto 0" }} - onEventStateChange={rerender} - /> + {event.authorizedData + ? <InlinePlayer + event={{ + ...event, + authorizedData: event.authorizedData, + }} + css={{ margin: "-4px auto 0" }} + onEventStateChange={rerender} + /> + : <PreviewPlayerPlaceholder /> + } <Metadata id={event.id} event={event} /> </PlayerContextProvider> @@ -481,6 +483,21 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath </>; }; +const PreviewPlayerPlaceholder: React.FC = () => { + const { t } = useTranslation(); + + return <div css={{ + maxHeight: "calc(100vh - var(--header-height) - 18px - ${MAIN_PADDING}px - 38px - 60px)", + aspectRatio: "16 / 9", + width: "100%", + }}> + <PlayerPlaceholder> + <LuInfo /> + <div>{t("video.preview-only")}</div> + </PlayerPlaceholder> + </div>; +}; + type Event = Extract<NonNullable<VideoPageEventData$data>, { __typename: "AuthorizedEvent" }>; type SyncedEvent = SyncedOpencastEntity<Event>; @@ -530,7 +547,9 @@ const Metadata: React.FC<MetadataProps> = ({ id, event }) => { {t("video.manage")} </LinkButton> )} - {CONFIG.showDownloadButton && <DownloadButton event={event} />} + {CONFIG.showDownloadButton && event.authorizedData && ( + <DownloadButton event={event} /> + )} <ShareButton {...{ event }} /> </div> </div> diff --git a/frontend/src/ui/Video.tsx b/frontend/src/ui/Video.tsx index 93bbc4e6e..2908a966e 100644 --- a/frontend/src/ui/Video.tsx +++ b/frontend/src/ui/Video.tsx @@ -1,6 +1,14 @@ import { PropsWithChildren, useState } from "react"; import { useTranslation } from "react-i18next"; -import { LuAlertTriangle, LuFilm, LuRadio, LuTrash, LuUserCircle, LuVolume2 } from "react-icons/lu"; +import { + LuAlertTriangle, + LuFilm, + LuLock, + LuRadio, + LuTrash, + LuUserCircle, + LuVolume2, +} from "react-icons/lu"; import { useColorScheme } from "@opencast/appkit"; import { COLORS } from "../color"; @@ -60,10 +68,15 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ alt={t("video.thumbnail-for", { video: event.title })} />; } else { - inner = <ThumbnailReplacement {...{ audioOnly, isUpcoming, isDark, deletionIsPending }} />; + inner = <ThumbnailReplacement + {...{ audioOnly, isUpcoming, isDark, deletionIsPending }} + previewOnly={!event.authorizedData} + />; } let overlay; + let innerOverlay; + let backgroundColor = "hsla(0, 0%, 0%, 0.75)"; if (deletionIsPending) { overlay = null; } else if (event.isLive) { @@ -73,9 +86,7 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ const endTime = event.syncedData?.endTime; const hasEnded = endTime == null ? null : new Date(endTime) < now; const hasStarted = startTime < now; - const currentlyLive = hasStarted && !hasEnded; - let innerOverlay; if (hasEnded) { innerOverlay = t("video.ended"); } else if (hasStarted) { @@ -83,19 +94,18 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ <LuRadio css={{ fontSize: 19, strokeWidth: 1.4 }} /> {t("video.live")} </>; + backgroundColor = "rgba(200, 0, 0, 0.9)"; } else { innerOverlay = t("video.upcoming"); } + } else if (event.syncedData) { + innerOverlay = formatDuration(event.syncedData.duration); + } - const backgroundColor = currentlyLive ? "rgba(200, 0, 0, 0.9)" : "hsla(0, 0%, 0%, 0.75)"; - + if (innerOverlay) { overlay = <ThumbnailOverlay {...{ backgroundColor }}> {innerOverlay} </ThumbnailOverlay>; - } else if (event.syncedData) { - overlay = <ThumbnailOverlay backgroundColor="hsla(0, 0%, 0%, 0.75)"> - {formatDuration(event.syncedData.duration)} - </ThumbnailOverlay>; } return <ThumbnailOverlayContainer {...rest}> @@ -110,9 +120,10 @@ type ThumbnailReplacementProps = { isDark: boolean; isUpcoming?: boolean; deletionIsPending?: boolean; + previewOnly?: boolean; } export const ThumbnailReplacement: React.FC<ThumbnailReplacementProps> = ( - { audioOnly, isDark, isUpcoming, deletionIsPending } + { audioOnly, isDark, isUpcoming, deletionIsPending, previewOnly } ) => { // We have no thumbnail. If the resolution is `null` as well, we are // dealing with an audio-only event and show an appropriate icon. @@ -123,6 +134,9 @@ export const ThumbnailReplacement: React.FC<ThumbnailReplacementProps> = ( if (audioOnly) { icon = <LuVolume2 />; } + if (previewOnly) { + icon = <LuLock />; + } if (deletionIsPending) { icon = <LuTrash />; } diff --git a/frontend/src/ui/player/index.tsx b/frontend/src/ui/player/index.tsx index 083724bcb..528afd607 100644 --- a/frontend/src/ui/player/index.tsx +++ b/frontend/src/ui/player/index.tsx @@ -171,9 +171,11 @@ export const InlinePlayer: React.FC<PlayerProps> = ({ className, event, ...playe flexDirection: "column", // We want to be able to see the full header, the video title and some metadata. // So: full height minus header, minus separation line (18px), minus main - // padding (16px), minus breadcrumbs (roughly 42px), minus the amount of space - // we want to see below the video (roughly 120px). - maxHeight: "calc(100vh - var(--header-height) - 18px - 16px - 38px - 60px)", + // padding (16px), minus breadcrumbs (roughly 38px), minus the amount of space + // we want to see below the video (roughly 60px). + maxHeight: ` + calc(100vh - var(--header-height) - 18px - ${MAIN_PADDING}px - 38px - 60px) + `, minHeight: `min(320px, (100vw - 32px) / (${aspectRatio[0]} / ${aspectRatio[1]}))`, width: controlsFit ? "unset" : "100%", maxWidth: "100%", From dc7f407039d062f66d8972afc182a950d137153e Mon Sep 17 00:00:00 2001 From: Ole Wieners <olewieners@yahoo.com> Date: Thu, 12 Sep 2024 16:13:56 +0200 Subject: [PATCH 04/26] Hide search thumbnails for users without read access These are supposed to be replaced by a generic preview image everywhere when a user only has a preview role. Search events use another table and an api that is missing the `authorized_data` field, so there needs to be an additional check to keep the thumbnails hidden. This poses another issue for authentication which will be addressed in a later commit. --- backend/src/api/model/search/event.rs | 24 ++++++++++++++++++------ backend/src/api/model/search/mod.rs | 6 +++--- backend/src/search/mod.rs | 2 +- backend/src/search/util.rs | 11 +++++++++++ 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/backend/src/api/model/search/event.rs b/backend/src/api/model/search/event.rs index c77e4774d..1b9dc043c 100644 --- a/backend/src/api/model/search/event.rs +++ b/backend/src/api/model/search/event.rs @@ -3,8 +3,9 @@ use juniper::GraphQLObject; use crate::{ api::{Context, Id, Node, NodeValue}, + auth::HasRoles, db::types::TextAssetType, - search, + search::{self, util::decode_acl}, }; use super::{field_matches_for, match_ranges_for, ByteSpan, SearchRealm}; @@ -65,11 +66,11 @@ impl Node for SearchEvent { } impl SearchEvent { - pub(crate) fn without_matches(src: search::Event) -> Self { - Self::new_inner(src, vec![], SearchEventMatches::default()) + pub(crate) fn without_matches(src: search::Event, context: &Context) -> Self { + Self::new_inner(src, vec![], SearchEventMatches::default(), context) } - pub(crate) fn new(hit: meilisearch_sdk::SearchResult<search::Event>) -> Self { + pub(crate) fn new(hit: meilisearch_sdk::SearchResult<search::Event>, context: &Context) -> Self { let match_positions = hit.matches_position.as_ref(); let src = hit.result; @@ -91,14 +92,25 @@ impl SearchEvent { series_title: field_matches_for(match_positions, "series_title"), }; - Self::new_inner(src, text_matches, matches) + Self::new_inner(src, text_matches, matches, context) } fn new_inner( src: search::Event, text_matches: Vec<TextMatch>, matches: SearchEventMatches, + context: &Context, ) -> Self { + let thumbnail = { + let read_roles = decode_acl(&src.read_roles); + + if context.auth.overlaps_roles(read_roles) { + src.thumbnail + } else { + None + } + }; + Self { id: Id::search_event(src.id.0), series_id: src.series_id.map(|id| Id::search_series(id.0)), @@ -106,7 +118,7 @@ impl SearchEvent { title: src.title, description: src.description, creators: src.creators, - thumbnail: src.thumbnail, + thumbnail, duration: src.duration as f64, created: src.created, start_time: src.start_time, diff --git a/backend/src/api/model/search/mod.rs b/backend/src/api/model/search/mod.rs index 4a98f4a1a..ace73503a 100644 --- a/backend/src/api/model/search/mod.rs +++ b/backend/src/api/model/search/mod.rs @@ -172,7 +172,7 @@ pub(crate) async fn perform( .await? .map(|row| { let e = search::Event::from_row_start(&row); - SearchEvent::without_matches(e).into() + SearchEvent::without_matches(e, &context).into() }) .into_iter() .collect(); @@ -243,7 +243,7 @@ pub(crate) async fn perform( // https://github.com/orgs/meilisearch/discussions/489#discussioncomment-6160361 let events = event_results.hits.into_iter().map(|result| { let score = result.ranking_score; - (NodeValue::from(SearchEvent::new(result)), score) + (NodeValue::from(SearchEvent::new(result, &context)), score) }); let series = series_results.hits.into_iter().map(|result| { let score = result.ranking_score; @@ -346,7 +346,7 @@ pub(crate) async fn all_events( } let res = query.execute::<search::Event>().await; let results = handle_search_result!(res, EventSearchOutcome); - let items = results.hits.into_iter().map(|h| SearchEvent::new(h)).collect(); + let items = results.hits.into_iter().map(|h| SearchEvent::new(h, &context)).collect(); let total_hits = results.estimated_total_hits.unwrap_or(0); Ok(EventSearchOutcome::Results(SearchResults { items, total_hits, duration: elapsed_time() })) diff --git a/backend/src/search/mod.rs b/backend/src/search/mod.rs index d9431508c..89954a3ca 100644 --- a/backend/src/search/mod.rs +++ b/backend/src/search/mod.rs @@ -25,7 +25,7 @@ mod series; pub(crate) mod writer; mod update; mod user; -mod util; +pub(crate) mod util; mod playlist; use self::writer::MeiliWriter; diff --git a/backend/src/search/util.rs b/backend/src/search/util.rs index ae45af114..4292ce87a 100644 --- a/backend/src/search/util.rs +++ b/backend/src/search/util.rs @@ -54,6 +54,17 @@ pub(super) fn encode_acl(roles: &[String]) -> Vec<String> { .collect() } +/// Decodes hex encoded ACL roles. +pub(crate) fn decode_acl(roles: &[String]) -> Vec<String> { + roles.iter() + .map(|role| { + let bytes = hex::decode(role).expect("Failed to decode role"); + + String::from_utf8(bytes).expect("Failed to convert bytes to string") + }) + .collect() +} + /// Returns `true` if the given error has the error code `IndexNotFound` pub(super) fn is_index_not_found(err: &Error) -> bool { matches!(err, Error::Meilisearch(e) if e.error_code == ErrorCode::IndexNotFound) From 5480f3e5deeb7cde9db90a40b5ef663464f2031e Mon Sep 17 00:00:00 2001 From: Ole Wieners <olewieners@yahoo.com> Date: Tue, 27 Aug 2024 16:00:47 +0200 Subject: [PATCH 05/26] Sync preview roles when harvesting --- backend/src/sync/harvest/mod.rs | 2 ++ backend/src/sync/harvest/response.rs | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/backend/src/sync/harvest/mod.rs b/backend/src/sync/harvest/mod.rs index 8195c7bf9..83ae03958 100644 --- a/backend/src/sync/harvest/mod.rs +++ b/backend/src/sync/harvest/mod.rs @@ -182,6 +182,7 @@ async fn store_in_db( // We always handle the admin role in a special way, so no need // to store it for every single event. + acl.preview.retain(|role| role != ROLE_ADMIN); acl.read.retain(|role| role != ROLE_ADMIN); acl.write.retain(|role| role != ROLE_ADMIN); @@ -210,6 +211,7 @@ async fn store_in_db( ("creators", &creators), ("thumbnail", &thumbnail), ("metadata", &metadata), + ("preview_roles", &acl.preview), ("read_roles", &acl.read), ("write_roles", &acl.write), ("custom_action_roles", &acl.custom_actions), diff --git a/backend/src/sync/harvest/response.rs b/backend/src/sync/harvest/response.rs index abcf1ef8d..df66fc18e 100644 --- a/backend/src/sync/harvest/response.rs +++ b/backend/src/sync/harvest/response.rs @@ -181,6 +181,8 @@ pub(crate) struct Acl { pub(crate) read: Vec<String>, #[serde(default)] pub(crate) write: Vec<String>, + #[serde(default)] + pub(crate) preview: Vec<String>, #[serde(flatten)] pub(crate) custom_actions: CustomActions, } @@ -224,6 +226,7 @@ mod tests { title: "Cats".into(), description: Some("Several videos of cats".into()), acl: Acl { + preview: vec![], read: vec!["ROLE_ANONYMOUS".into()], write: vec!["ROLE_ANONYMOUS".into()], custom_actions: CustomActions::default(), @@ -237,6 +240,7 @@ mod tests { title: "Video Of A Tabby Cat".into(), description: None, acl: Acl { + preview: vec![], read: vec!["ROLE_ADMIN".into(), "ROLE_ANONYMOUS".into()], write: vec!["ROLE_ADMIN".into()], custom_actions: CustomActions::default(), From 978a6d23273803192eeddca266e9dbc5cd6199c7 Mon Sep 17 00:00:00 2001 From: Ole Wieners <olewieners@yahoo.com> Date: Thu, 12 Sep 2024 15:26:55 +0200 Subject: [PATCH 06/26] Add endpoints and UI for preliminary authentication Optional event credentials are now storable in DB, and the are some endpoints to check whether an event is password protected. This also adds a dummy check to `authenticated_data`, to simulate event authentication. Will be extended/replaced in a later commit. --- backend/src/api/model/event.rs | 32 ++++++++++++++++--- backend/src/api/model/search/event.rs | 2 ++ backend/src/db/migrations.rs | 2 +- ...> 39-event-preview-roles-and-password.sql} | 16 ++++++++-- backend/src/search/event.rs | 5 ++- frontend/src/schema.graphql | 12 +++++-- 6 files changed, 58 insertions(+), 11 deletions(-) rename backend/src/db/migrations/{39-event-preview-roles.sql => 39-event-preview-roles-and-password.sql} (83%) diff --git a/backend/src/api/model/event.rs b/backend/src/api/model/event.rs index fd1f39efb..fb8bdf446 100644 --- a/backend/src/api/model/event.rs +++ b/backend/src/api/model/event.rs @@ -164,7 +164,8 @@ impl SyncedEventData { } } -/// Represents event data that is only accessible for users with read access. +/// Represents event data that is only accessible for users with read access +/// and event-specific authenticated users. #[graphql_object(Context = Context, impl = NodeValue)] impl AuthorizedEventData { fn tracks(&self) -> &[Track] { @@ -225,9 +226,17 @@ impl AuthorizedEvent { &self.synced_data } - /// Returns the authorized event data if the user has read access. - fn authorized_data(&self, context: &Context) -> Option<&AuthorizedEventData> { - if context.auth.overlaps_roles(&self.read_roles) { + /// Returns the authorized event data if the user has read access or is authenticated for the event. + async fn authorized_data(&self, context: &Context, user: Option<String>, password: Option<String>) -> Option<&AuthorizedEventData> { + // TODO: replace with hashed credentials from db, add actual comparison check with hashed user inputs + let expected_user = "plane"; + let expected_pw = "bird"; + + let matches = self.has_password(context).await.unwrap_or(false) + && user.map_or(false, |u| u == expected_user) + && password.map_or(false, |p| p == expected_pw); + + if context.auth.overlaps_roles(&self.read_roles) || matches { self.authorized_data.as_ref() } else { None @@ -269,6 +278,12 @@ impl AuthorizedEvent { ).await?.pipe(Ok) } + + /// Whether this event is password protected. + async fn has_password(&self, context: &Context) -> ApiResult<bool> { + self.has_password(context).await + } + async fn acl(&self, context: &Context) -> ApiResult<Acl> { let raw_roles_sql = "\ select unnest(read_roles) as role, 'read' as action from events where id = $1 @@ -325,6 +340,15 @@ impl AuthorizedEvent { Self::load_by_any_id_impl("opencast_id", &oc_id, context).await } + /// Whether this event is password protected. + async fn has_password(&self, context: &Context) -> ApiResult<bool> { + let query = format!("select credentials is not null from all_events where id = $1"); + context.db.query_one(&query, &[&self.key]) + .await? + .get::<_, bool>(0) + .pipe(Ok) + } + pub(crate) async fn load_by_any_id_impl( col: &str, id: &(dyn ToSql + Sync), diff --git a/backend/src/api/model/search/event.rs b/backend/src/api/model/search/event.rs index 1b9dc043c..ed6f5d8aa 100644 --- a/backend/src/api/model/search/event.rs +++ b/backend/src/api/model/search/event.rs @@ -29,6 +29,7 @@ pub(crate) struct SearchEvent { pub host_realms: Vec<SearchRealm>, pub text_matches: Vec<TextMatch>, pub matches: SearchEventMatches, + pub has_password: bool, } #[derive(Debug, GraphQLObject, Default)] @@ -130,6 +131,7 @@ impl SearchEvent { .collect(), text_matches, matches, + has_password: src.has_password, } } } diff --git a/backend/src/db/migrations.rs b/backend/src/db/migrations.rs index 187a6e1cd..7b271cea9 100644 --- a/backend/src/db/migrations.rs +++ b/backend/src/db/migrations.rs @@ -371,5 +371,5 @@ static MIGRATIONS: Lazy<BTreeMap<u64, Migration>> = include_migrations![ 36: "playlist-blocks", 37: "redo-search-triggers-and-listed", 38: "event-texts", - 39: "event-preview-roles", + 39: "event-preview-roles-and-password", ]; diff --git a/backend/src/db/migrations/39-event-preview-roles.sql b/backend/src/db/migrations/39-event-preview-roles-and-password.sql similarity index 83% rename from backend/src/db/migrations/39-event-preview-roles.sql rename to backend/src/db/migrations/39-event-preview-roles-and-password.sql index ccc4db8c3..e19141cbc 100644 --- a/backend/src/db/migrations/39-event-preview-roles.sql +++ b/backend/src/db/migrations/39-event-preview-roles-and-password.sql @@ -1,7 +1,18 @@ +-- Adds columns `preview_roles` and `credentials`. + -- Users with a preview role may only view text metadata of an event. -- Any other action will require read or write roles (these imply preview rights). -alter table all_events add column preview_roles text[] not null default '{}'; +-- `credentials` is an optional column which holds a user/group name and a corresponding +-- password. If set, users need these credentials to gain read access to an event. +create type credentials as ( + name text, -- as `<hash-type>:<hashed-username>` + password text -- as `<hash-type>:<hashed-pw>` +); + +alter table all_events + add column credentials credentials, + add column preview_roles text[] not null default '{}'; -- For convenience, all read roles are also copied over to preview roles. -- Removing any roles from read will however _not_ remove them from preview, as they @@ -64,7 +75,8 @@ create view search_events as from event_texts where event_id = events.id and ty = 'caption' ) as subquery - ) as caption_texts + ) as caption_texts, + (events.credentials is not null) as has_password from all_events as events left join series on events.series = series.id -- This syntax instead of `foo = any(...)` to use the index, which is not diff --git a/backend/src/search/event.rs b/backend/src/search/event.rs index bd45a4198..9191e8cb5 100644 --- a/backend/src/search/event.rs +++ b/backend/src/search/event.rs @@ -38,6 +38,7 @@ pub(crate) struct Event { pub(crate) end_time_timestamp: Option<i64>, pub(crate) is_live: bool, pub(crate) audio_only: bool, + pub(crate) has_password: bool, // These are filterable. All roles are hex encoded to work around Meilis // inability to filter case-sensitively. For roles, we have to compare @@ -75,7 +76,8 @@ impl_from_db!( search_events.{ id, series, series_title, title, description, creators, thumbnail, duration, is_live, updated, created, start_time, end_time, audio_only, - read_roles, write_roles, preview_roles, host_realms, slide_texts, caption_texts, + read_roles, write_roles, preview_roles, has_password, + host_realms, slide_texts, caption_texts, }, }, |row| { @@ -110,6 +112,7 @@ impl_from_db!( .unwrap_or_else(TextSearchIndex::empty), caption_texts: row.caption_texts::<Option<TextSearchIndex>>() .unwrap_or_else(TextSearchIndex::empty), + has_password: row.has_password(), } } ); diff --git a/frontend/src/schema.graphql b/frontend/src/schema.graphql index 3584cbbc5..ad3d00644 100644 --- a/frontend/src/schema.graphql +++ b/frontend/src/schema.graphql @@ -102,6 +102,7 @@ type SearchEvent implements Node { hostRealms: [SearchRealm!]! textMatches: [TextMatch!]! matches: SearchEventMatches! + hasPassword: Boolean! } input ChildIndex { @@ -319,14 +320,16 @@ type AuthorizedEvent implements Node { """ previewRoles: [String!]! syncedData: SyncedEventData - "Returns the authorized event data if the user has read access." - authorizedData: AuthorizedEventData + "Returns the authorized event data if the user has read access or is authenticated for the event." + authorizedData(user: String, password: String): AuthorizedEventData "Whether the current user has write access to this event." canWrite: Boolean! tobiraDeletionTimestamp: DateTimeUtc series: Series "Returns a list of realms where this event is referenced (via some kind of block)." hostRealms: [Realm!]! + "Whether this event is password protected." + hasPassword: Boolean! acl: [AclItem!]! """ Returns `true` if the realm has a video block with this video @@ -759,7 +762,10 @@ type EmptyQuery { union RemoveMountedSeriesOutcome = RemovedRealm | RemovedBlock -"Represents event data that is only accessible for users with read access." +""" + Represents event data that is only accessible for users with read access + and event-specific authenticated users. +""" type AuthorizedEventData implements Node { tracks: [Track!]! thumbnail: String From 6ecf99e4f4ddc1b4092d5239e427d68f71286917 Mon Sep 17 00:00:00 2001 From: Ole Wieners <olewieners@yahoo.com> Date: Mon, 2 Sep 2024 18:05:49 +0200 Subject: [PATCH 07/26] Add authentication mask to password protected videos This repurposes the UI component that is also used on our login page with some adjustments. The mask is shown on the video route, the embed route and also for video blocks in place of the video player. Entering the correct credentials will unlock the video. --- frontend/src/i18n/locales/de.yaml | 11 ++ frontend/src/i18n/locales/en.yaml | 11 ++ frontend/src/routes/Embed.tsx | 28 +++- frontend/src/routes/Login.tsx | 102 ++++++++---- frontend/src/routes/Video.tsx | 267 ++++++++++++++++++++++++++++-- frontend/src/ui/Blocks/Video.tsx | 37 +++-- frontend/src/ui/player/index.tsx | 4 +- 7 files changed, 390 insertions(+), 70 deletions(-) diff --git a/frontend/src/i18n/locales/de.yaml b/frontend/src/i18n/locales/de.yaml index aa9d53c96..c4b16aa86 100644 --- a/frontend/src/i18n/locales/de.yaml +++ b/frontend/src/i18n/locales/de.yaml @@ -180,6 +180,17 @@ video: embed: Einbetten rss: RSS show-qr-code: QR Code anzeigen + password: + heading: Geschütztes Video + sub-heading: Zugriff zu diesem Video ist beschränkt. + body: > + Bitte geben Sie die Zugangsdaten ein, die Sie für dieses Videos erhalten haben. + Beachten Sie, dass diese nicht Ihren Logindaten für dieses Portal entsprechen. + label: + id: Kennung + password: Passwort + submit: Freischalten + invalid-credentials: Ungültige Zugangsdaten. playlist: deleted-playlist-block: Die hier referenzierte Playlist wurde gelöscht. diff --git a/frontend/src/i18n/locales/en.yaml b/frontend/src/i18n/locales/en.yaml index fc01b817b..38691a73a 100644 --- a/frontend/src/i18n/locales/en.yaml +++ b/frontend/src/i18n/locales/en.yaml @@ -177,6 +177,17 @@ video: embed: Embed rss: RSS show-qr-code: Show QR code + password: + heading: Protected Video + sub-heading: Access to this video is restricted. + body: > + Please enter the username and the password you have received to access this video; + please note that these are not your login credentials. + label: + id: Identifier + password: Password + submit: Verify + invalid-credentials: Invalid credentials. playlist: deleted-playlist-block: The playlist referenced here was deleted. diff --git a/frontend/src/routes/Embed.tsx b/frontend/src/routes/Embed.tsx index 191da02d4..f33660084 100644 --- a/frontend/src/routes/Embed.tsx +++ b/frontend/src/routes/Embed.tsx @@ -2,7 +2,8 @@ import { ReactNode, Suspense } from "react"; import { LuFrown, LuAlertTriangle } from "react-icons/lu"; import { Translation, useTranslation } from "react-i18next"; import { - graphql, GraphQLTaggedNode, PreloadedQuery, useFragment, usePreloadedQuery, + graphql, useFragment, usePreloadedQuery, useQueryLoader, + GraphQLTaggedNode, PreloadedQuery, } from "react-relay"; import { unreachable } from "@opencast/appkit"; @@ -18,6 +19,8 @@ import { EmbedQuery } from "./__generated__/EmbedQuery.graphql"; import { EmbedDirectOpencastQuery } from "./__generated__/EmbedDirectOpencastQuery.graphql"; import { EmbedEventData$key } from "./__generated__/EmbedEventData.graphql"; import { PlayerContextProvider } from "../ui/player/PlayerContext"; +import { authorizedDataQuery, ProtectedPlayer } from "./Video"; +import { VideoAuthorizedDataQuery } from "./__generated__/VideoAuthorizedDataQuery.graphql"; export const EmbedVideoRoute = makeRoute({ url: ({ videoId }: { videoId: string }) => `/~embed/!v/${keyOfId(videoId)}`, @@ -90,6 +93,7 @@ const embedEventFragment = graphql` __typename ... on NotAllowed { dummy } ... on AuthorizedEvent { + id title created isLive @@ -97,6 +101,8 @@ const embedEventFragment = graphql` creators metadata description + canWrite + hasPassword series { title opencastId } syncedData { updated @@ -127,6 +133,8 @@ const Embed: React.FC<EmbedProps> = ({ query, queryRef }) => { fragmentRef.event, ); const { t } = useTranslation(); + const [queryReference, loadQuery] + = useQueryLoader<VideoAuthorizedDataQuery>(authorizedDataQuery); if (!event) { return <PlayerPlaceholder> @@ -153,14 +161,16 @@ const Embed: React.FC<EmbedProps> = ({ query, queryRef }) => { </PlayerPlaceholder>; } - if (!event.authorizedData) { - return <>nop</>; // TODO - } - - return <Player event={{ - ...event, - authorizedData: event.authorizedData, - }} />; + return event.authorizedData + ? <Player event={{ + ...event, + authorizedData: event.authorizedData, + }} /> + : <ProtectedPlayer embedded {...{ + queryReference, + event, + loadQuery, + }}/>; }; export const BlockEmbedRoute = makeRoute({ diff --git a/frontend/src/routes/Login.tsx b/frontend/src/routes/Login.tsx index 5b8f206b1..82d88631d 100644 --- a/frontend/src/routes/Login.tsx +++ b/frontend/src/routes/Login.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useId, useState } from "react"; +import React, { PropsWithChildren, ReactNode, useId, useState } from "react"; import { useTranslation } from "react-i18next"; import { graphql, usePreloadedQuery } from "react-relay"; import type { PreloadedQuery } from "react-relay"; @@ -22,6 +22,7 @@ import { Breadcrumbs } from "../ui/Breadcrumbs"; import { OUTER_CONTAINER_MARGIN } from "../layout"; import { COLORS } from "../color"; import { focusStyle } from "../ui"; +import { IconType } from "react-icons"; export const REDIRECT_STORAGE_KEY = "tobira-redirect-after-login"; @@ -118,24 +119,16 @@ const BackButton: React.FC = () => { ><LuChevronLeft />{t("general.action.back")}</Link>; }; -type FormData = { +export type FormData = { userid: string; password: string; }; +export type AuthenticationFormState = "idle" | "pending" | "success"; + const LoginBox: React.FC = () => { const { t, i18n } = useTranslation(); - const isDark = useColorScheme().scheme === "dark"; - const { register, handleSubmit, watch, formState: { errors } } = useForm<FormData>(); - const userId = watch("userid", ""); - const password = watch("password", ""); - const userFieldId = useId(); - const passwordFieldId = useId(); - - const validation = { required: t("general.form.this-field-is-required") }; - - type State = "idle" | "pending" | "success"; - const [state, setState] = useState<State>("idle"); + const [state, setState] = useState<AuthenticationFormState>("idle"); const [loginError, setLoginError] = useState<string | null>(null); const onSubmit = async (data: FormData) => { @@ -171,15 +164,20 @@ const LoginBox: React.FC = () => { }; return ( - <div css={{ - width: 400, - maxWidth: "100%", - marginTop: 24, - padding: 32, - border: `1px solid ${COLORS.neutral25}`, - borderRadius: 8, - ...isDark && { backgroundColor: COLORS.neutral10 }, - }}> + <AuthenticationForm + {...{ onSubmit, state }} + error={loginError} + SubmitIcon={LuLogIn} + labels={{ + user: CONFIG.auth.userIdLabel + ? translatedConfig(CONFIG.auth.userIdLabel, i18n) + : t("login-page.user-id"), + password: CONFIG.auth.passwordLabel + ? translatedConfig(CONFIG.auth.passwordLabel, i18n) + : t("login-page.password"), + submit: t("user.login"), + }} + > {CONFIG.auth.loginPageNote && ( <div css={{ backgroundColor: COLORS.neutral10, @@ -188,7 +186,53 @@ const LoginBox: React.FC = () => { padding: "8px 16px", }}>{translatedConfig(CONFIG.auth.loginPageNote, i18n)}</div> )} + </AuthenticationForm> + ); +}; +type AuthenticationFormProps = PropsWithChildren & { + onSubmit: (data: FormData) => Promise<void> | void; + state: AuthenticationFormState; + error: string | null; + className?: string; + SubmitIcon: IconType; + labels: { + user: string; + password: string; + submit: string; + }; +} + +export const AuthenticationForm: React.FC<AuthenticationFormProps> = ({ + onSubmit, + state, + error, + className, + SubmitIcon, + labels, + children, +}) => { + const { t } = useTranslation(); + const isDark = useColorScheme().scheme === "dark"; + const { register, handleSubmit, watch, formState: { errors } } = useForm<FormData>(); + const userId = watch("userid", ""); + const password = watch("password", ""); + const userFieldId = useId(); + const passwordFieldId = useId(); + + const validation = { required: t("general.form.this-field-is-required") }; + + return ( + <div {...{ className }} css={{ + width: 400, + maxWidth: "100%", + marginTop: 24, + padding: 32, + border: `1px solid ${COLORS.neutral25}`, + borderRadius: 8, + ...isDark && { backgroundColor: COLORS.neutral10 }, + }}> + {children} <form onSubmit={handleSubmit(onSubmit)} noValidate @@ -200,9 +244,7 @@ const LoginBox: React.FC = () => { <div> <Field isEmpty={userId === ""}> <label htmlFor={userFieldId}> - {CONFIG.auth.userIdLabel - ? translatedConfig(CONFIG.auth.userIdLabel, i18n) - : t("login-page.user-id")} + {labels.user} </label> <input id={userFieldId} @@ -219,9 +261,7 @@ const LoginBox: React.FC = () => { <div> <Field isEmpty={password === ""}> <label htmlFor={passwordFieldId}> - {CONFIG.auth.passwordLabel - ? translatedConfig(CONFIG.auth.passwordLabel, i18n) - : t("login-page.password")} + {labels.password} </label> <input id={passwordFieldId} @@ -253,8 +293,8 @@ const LoginBox: React.FC = () => { ...focusStyle({ offset: 1 }), }} > - <LuLogIn size={20} /> - {t("user.login")} + <SubmitIcon size={20} /> + {labels.submit} {match(state, { "idle": () => null, "pending": () => <Spinner size={20} />, @@ -262,7 +302,7 @@ const LoginBox: React.FC = () => { })} </ProtoButton> - {loginError && <div><Card kind="error" iconPos="top">{loginError}</Card></div>} + {error && <Card kind="error" iconPos="top">{error}</Card>} </form> </div> ); diff --git a/frontend/src/routes/Video.tsx b/frontend/src/routes/Video.tsx index 8ab4c296f..ad62811e5 100644 --- a/frontend/src/routes/Video.tsx +++ b/frontend/src/routes/Video.tsx @@ -1,15 +1,22 @@ import React, { ReactElement, ReactNode, useEffect, useRef, useState } from "react"; -import { graphql, GraphQLTaggedNode, PreloadedQuery, useFragment } from "react-relay/hooks"; +import { + graphql, + GraphQLTaggedNode, + PreloadedQuery, + useFragment, + usePreloadedQuery, + useQueryLoader, + UseQueryLoaderLoadQueryOptions, +} from "react-relay/hooks"; import { useTranslation } from "react-i18next"; import { OperationType } from "relay-runtime"; import { - LuCode, LuDownload, LuInfo, LuLink, LuQrCode, LuRss, LuSettings, LuShare2, + LuCode, LuDownload, LuInfo, LuLink, LuQrCode, LuRss, LuSettings, LuShare2, LuUnlock, } from "react-icons/lu"; import { QRCodeCanvas } from "qrcode.react"; import { - match, unreachable, ProtoButton, - useColorScheme, Floating, FloatingContainer, FloatingTrigger, WithTooltip, screenWidthAtMost, - Card, Button, + match, unreachable, screenWidthAtMost, screenWidthAbove, useColorScheme, + Floating, FloatingContainer, FloatingTrigger, WithTooltip, Card, Button, ProtoButton, } from "@opencast/appkit"; import { VideoObject, WithContext } from "schema-dts"; @@ -18,7 +25,7 @@ import { InitialLoading, RootLoader } from "../layout/Root"; import { NotFound } from "./NotFound"; import { Nav } from "../layout/Navigation"; import { WaitingPage } from "../ui/Waiting"; -import { getPlayerAspectRatio, InlinePlayer, PlayerPlaceholder } from "../ui/player"; +import { getPlayerAspectRatio, InlinePlayer, Player, PlayerPlaceholder } from "../ui/player"; import { SeriesBlockFromSeries } from "../ui/Blocks/Series"; import { makeRoute, MatchedRoute } from "../rauta"; import { isValidRealmPath } from "./Realm"; @@ -74,6 +81,12 @@ import { DirectSeriesRoute } from "./Series"; import { EmbedVideoRoute } from "./Embed"; import { ManageVideoDetailsRoute } from "./manage/Video/Details"; import { PlaylistBlockFromPlaylist } from "../ui/Blocks/Playlist"; +import { AuthenticationFormState, FormData, AuthenticationForm } from "./Login"; +import { + VideoAuthorizedDataQuery, + VideoAuthorizedDataQuery$variables, +} from "./__generated__/VideoAuthorizedDataQuery.graphql"; +import { AuthorizedBlockEvent } from "../ui/Blocks/Video"; // =========================================================================================== @@ -367,6 +380,7 @@ const eventFragment = graphql` opencastId metadata canWrite + hasPassword syncedData { updated duration @@ -389,6 +403,25 @@ const eventFragment = graphql` } `; +export const authorizedDataQuery = graphql` + query VideoAuthorizedDataQuery( + $eventId: ID!, + $seriesUser: String, + $seriesPassword: String, + ) { + event: eventById(id: $eventId) { + ...on AuthorizedEvent { + id + authorizedData(user: $seriesUser, password: $seriesPassword) { + tracks { uri flavor mimetype resolution isMaster } + captions { uri lang } + segments { uri startTime } + thumbnail + } + } + } + } +`; // =========================================================================================== @@ -407,6 +440,8 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath const rerender = useForceRerender(); const event = useFragment(eventFragment, eventRef); const realm = useFragment(realmFragment, realmRef); + const [queryReference, loadQuery] + = useQueryLoader<VideoAuthorizedDataQuery>(authorizedDataQuery); if (event.__typename === "NotAllowed") { return <ErrorPage title={t("api-remote-errors.view.event")} />; @@ -459,7 +494,11 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath css={{ margin: "-4px auto 0" }} onEventStateChange={rerender} /> - : <PreviewPlayerPlaceholder /> + : <ProtectedPlayer {...{ + queryReference, + event, + loadQuery, + }}/> } <Metadata id={event.id} event={event} /> </PlayerContextProvider> @@ -483,22 +522,218 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath </>; }; -const PreviewPlayerPlaceholder: React.FC = () => { +type ProtectedPlayerProps = { + queryReference?: PreloadedQuery<VideoAuthorizedDataQuery> | null; + loadQuery: ( + variables: VideoAuthorizedDataQuery$variables, + options?: UseQueryLoaderLoadQueryOptions, + ) => void; + event: Event | AuthorizedBlockEvent; + embedded?: boolean; +} + +export const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ + queryReference, + event, + loadQuery, + embedded, +}) => queryReference + ? <PlayerOrPreview {...{ + queryReference, + event, + loadQuery, + embedded, + }} /> + : <PreviewPlaceholder {...{ event, loadQuery, embedded }} />; + +const PlayerOrPreview: React.FC<ProtectedPlayerProps> = ({ + queryReference, + event, + loadQuery, + embedded, +}) => { + if (!queryReference) { + return null; + } + const data = usePreloadedQuery(authorizedDataQuery, queryReference); + const rerender = useForceRerender(); + + return data.event?.authorizedData && event.syncedData + ? (embedded + ? <Player event={{ + ...event, + syncedData: event.syncedData, + authorizedData: data.event.authorizedData, + }} /> + : <InlinePlayer + event={{ + ...event, + syncedData: event.syncedData, + authorizedData: data.event.authorizedData, + }} + css={{ margin: "-4px auto 0" }} + onEventStateChange={rerender} + /> + ) + : <PreviewPlaceholder + invalid={data.event?.authorizedData == null} + {...{ event, loadQuery, embedded }} + />; + +}; + +type PlaceholderProps = ProtectedPlayerProps & { + invalid?: boolean; +} + +const PreviewPlaceholder: React.FC<PlaceholderProps> = ({ + event, + loadQuery, + invalid, + embedded, +}) => { + const { t } = useTranslation(); + + return event.hasPassword + ? <ProtectedVideoPlaceholder {...{ event, loadQuery, invalid, embedded }} /> + : <div css={{ height: "unset" }}> + <PlayerPlaceholder> + <p css={{ + maxWidth: "80ch", + textWrap: "balance", + padding: 32, + }}> + <LuInfo /> + <div>{t("video.preview-only")}</div> + </p> + </PlayerPlaceholder> + </div>; +}; + +const ProtectedVideoPlaceholder: React.FC<PlaceholderProps> = ({ + event, + loadQuery, + invalid, + embedded, +}) => { + const { t } = useTranslation(undefined, { keyPrefix: "video.password" }); + const isDark = useColorScheme().scheme === "dark"; + const [state, setState] = useState<AuthenticationFormState>("idle"); + + const embeddedStyles = { + height: "100%", + alignItems: "center", + justifyContent: "center", + }; + + const onSubmit = (data: FormData) => { + setState("pending"); + loadQuery({ eventId: event.id, seriesUser: data.userid, seriesPassword: data.password }); + }; + + return ( + <div css={{ + display: "flex", + flexDirection: "column", + color: isDark ? COLORS.neutral80 : COLORS.neutral15, + backgroundColor: isDark ? COLORS.neutral15 : COLORS.neutral80, + [screenWidthAtMost(BREAKPOINT_MEDIUM)]: { + alignItems: "center", + }, + ...embedded && embeddedStyles, + }}> + <h2 css={{ + margin: 32, + marginBottom: 0, + [screenWidthAbove(BREAKPOINT_MEDIUM)]: { + textAlign: "left", + }, + }}>{t("heading")}</h2> + <div css={{ + display: "flex", + [screenWidthAtMost(BREAKPOINT_MEDIUM)]: { + flexDirection: "column-reverse", + }, + }}> + <div css={{ + display: "flex", + flexDirection: "column", + alignItems: "center", + }}> + <AuthenticationForm + {...{ onSubmit, state }} + error={null} + SubmitIcon={LuUnlock} + labels={{ + user: t("label.id"), + password: t("label.password"), + submit: t("label.submit"), + }} + css={{ + "&": { backgroundColor: "transparent" }, + margin: 0, + border: 0, + width: "unset", + minWidth: 300, + "div > label, div > input": { + ...!isDark && { + backgroundColor: COLORS.neutral15, + }, + }, + }} + /> + {invalid && ( + <Card + kind="error" + iconPos="left" + css={{ + width: "fit-content", + marginBottom: 32, + }} + > + {t("invalid-credentials")} + </Card> + )} + </div> + <AuthenticationFormText /> + </div> + </div> + ); +}; + +const AuthenticationFormText: React.FC = () => { const { t } = useTranslation(); + const isDark = useColorScheme().scheme === "dark"; return <div css={{ - maxHeight: "calc(100vh - var(--header-height) - 18px - ${MAIN_PADDING}px - 38px - 60px)", - aspectRatio: "16 / 9", - width: "100%", + textAlign: "left", + maxWidth: "60ch", + padding: 32, + paddingLeft: 8, + fontSize: 14, + "&& p": { + color: isDark ? COLORS.neutral80 : COLORS.neutral15, + }, + [screenWidthAtMost(BREAKPOINT_MEDIUM)]: { + padding: "6px 18px 0px", + textAlign: "center", + textWrap: "balance", + }, }}> - <PlayerPlaceholder> - <LuInfo /> - <div>{t("video.preview-only")}</div> - </PlayerPlaceholder> + <p> + <span css={{ + [screenWidthAtMost(BREAKPOINT_MEDIUM)]: { + display: "none", + }, + }}> + <b>{t("video.password.sub-heading")}</b> + <br/> + </span> + {t("video.password.body")} + </p> </div>; }; - type Event = Extract<NonNullable<VideoPageEventData$data>, { __typename: "AuthorizedEvent" }>; type SyncedEvent = SyncedOpencastEntity<Event>; diff --git a/frontend/src/ui/Blocks/Video.tsx b/frontend/src/ui/Blocks/Video.tsx index 383914f29..ad5795780 100644 --- a/frontend/src/ui/Blocks/Video.tsx +++ b/frontend/src/ui/Blocks/Video.tsx @@ -1,16 +1,23 @@ -import { graphql, useFragment } from "react-relay"; +import { graphql, useFragment, useQueryLoader } from "react-relay"; import { Card, unreachable } from "@opencast/appkit"; import { InlinePlayer } from "../player"; -import { VideoBlockData$key } from "./__generated__/VideoBlockData.graphql"; +import { VideoBlockData$data, VideoBlockData$key } from "./__generated__/VideoBlockData.graphql"; import { Title } from ".."; import { useTranslation } from "react-i18next"; import { isSynced, keyOfId } from "../../util"; import { Link } from "../../router"; import { LuArrowRightCircle } from "react-icons/lu"; import { PlayerContextProvider } from "../player/PlayerContext"; +import { + VideoAuthorizedDataQuery, +} from "../../routes/__generated__/VideoAuthorizedDataQuery.graphql"; +import { authorizedDataQuery, ProtectedPlayer } from "../../routes/Video"; +export type BlockEvent = VideoBlockData$data["event"]; +export type AuthorizedBlockEvent = Extract<BlockEvent, { __typename: "AuthorizedEvent" }>; + type Props = { fragRef: VideoBlockData$key; basePath: string; @@ -18,6 +25,8 @@ type Props = { export const VideoBlock: React.FC<Props> = ({ fragRef, basePath }) => { const { t } = useTranslation(); + const [queryReference, loadQuery] + = useQueryLoader<VideoAuthorizedDataQuery>(authorizedDataQuery); const { event, showTitle, showLink } = useFragment(graphql` fragment VideoBlockData on VideoBlock { event { @@ -32,6 +41,8 @@ export const VideoBlock: React.FC<Props> = ({ fragRef, basePath }) => { creators metadata description + canWrite + hasPassword series { title opencastId } syncedData { duration @@ -63,23 +74,25 @@ export const VideoBlock: React.FC<Props> = ({ fragRef, basePath }) => { return unreachable(); } - if (!event.authorizedData) { - return <>nop</>; // TODO - } - return <div css={{ maxWidth: 800 }}> {showTitle && <Title title={event.title} />} - {isSynced(event) - ? <PlayerContextProvider> - <InlinePlayer + <PlayerContextProvider> + {event.authorizedData && isSynced(event) + ? <InlinePlayer event={{ ...event, authorizedData: event.authorizedData, }} - css={{ maxWidth: 800 }} + css={{ margin: "-4px auto 0" }} /> - </PlayerContextProvider> - : <Card kind="info">{t("video.not-ready.title")}</Card>} + : <ProtectedPlayer {...{ + queryReference, + event, + loadQuery, + }} /> + } + </PlayerContextProvider> + {showLink && <Link to={`${basePath}/${keyOfId(event.id)}`} css={{ diff --git a/frontend/src/ui/player/index.tsx b/frontend/src/ui/player/index.tsx index 528afd607..b02cbdeb1 100644 --- a/frontend/src/ui/player/index.tsx +++ b/frontend/src/ui/player/index.tsx @@ -277,8 +277,8 @@ export const PlayerPlaceholder: React.FC<PropsWithChildren> = ({ children }) => strokeWidth: 1.5, ...isDark && { color: COLORS.neutral80 }, }, - div: { - ...isDark && { color: COLORS.neutral80 }, + p: { + color: isDark ? COLORS.neutral80 : COLORS.neutral15, }, [screenWidthAtMost(BREAKPOINT_MEDIUM)]: { "& > *": { From 2bd5bcba1baf38ad57a8ea23f9b8ff275b3744d0 Mon Sep 17 00:00:00 2001 From: Ole Wieners <olewieners@yahoo.com> Date: Thu, 12 Sep 2024 19:16:28 +0200 Subject: [PATCH 08/26] Make event authentication "sticky" "Sticky" meaning that once the credentials are verified, they won't need to be entered again until the user logs out. Anonymous users will need to re-enter the credentials once their browser session ends. This will also make sure that thumbnails in series blocks and search results are shown after a user has been authenticated for a given event. --- frontend/src/layout/header/UserBox.tsx | 4 + frontend/src/routes/Embed.tsx | 27 +++-- frontend/src/routes/Search.tsx | 1 + frontend/src/routes/Video.tsx | 160 +++++++++++++++++-------- frontend/src/ui/Blocks/Video.tsx | 10 +- frontend/src/ui/Video.tsx | 11 +- frontend/src/util/index.ts | 51 ++++++++ 7 files changed, 201 insertions(+), 63 deletions(-) diff --git a/frontend/src/layout/header/UserBox.tsx b/frontend/src/layout/header/UserBox.tsx index e542bac19..706f711d5 100644 --- a/frontend/src/layout/header/UserBox.tsx +++ b/frontend/src/layout/header/UserBox.tsx @@ -175,6 +175,10 @@ const LoggedIn: React.FC<LoggedInProps> = ({ user }) => { return; } + Object.keys(window.localStorage) + .filter(item => item.startsWith("tobira-video-credentials-")) + .forEach(item => window.localStorage.removeItem(item)); + setLogoutState("pending"); fetch("/~session", { method: "DELETE", keepalive: true }) .then(() => { diff --git a/frontend/src/routes/Embed.tsx b/frontend/src/routes/Embed.tsx index f33660084..51e2d3708 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, isSynced, keyOfId } from "../util"; +import { eventId, getCredentials, isSynced, keyOfId } from "../util"; import { GlobalErrorBoundary } from "../util/err"; import { loadQuery } from "../relay"; import { makeRoute, MatchedRoute } from "../rauta"; @@ -30,15 +30,19 @@ export const EmbedVideoRoute = makeRoute({ if (params === null) { return null; } - const videoId = decodeURIComponent(params[1]); + const id = eventId(decodeURIComponent(params[1])); const query = graphql` - query EmbedQuery($id: ID!) { + query EmbedQuery($id: ID!, $eventUser: String, $eventPassword: String) { event: eventById(id: $id) { ... EmbedEventData } } - `; + `; + + const queryRef = loadQuery<EmbedQuery>(query, { + id, + ...getCredentials("event" + id), + }); - const queryRef = loadQuery<EmbedQuery>(query, { id: eventId(videoId) }); return matchedEmbedRoute(query, queryRef); }, @@ -54,13 +58,20 @@ export const EmbedOpencastVideoRoute = makeRoute({ } const query = graphql` - query EmbedDirectOpencastQuery($id: String!) { + query EmbedDirectOpencastQuery( + $id: String!, + $eventUser: String, + $eventPassword: String) + { event: eventByOpencastId(id: $id) { ... EmbedEventData } } `; const videoId = decodeURIComponent(matches[1]); - const queryRef = loadQuery<EmbedDirectOpencastQuery>(query, { id: videoId }); + const queryRef = loadQuery<EmbedDirectOpencastQuery>(query, { + id: videoId, + ...getCredentials(videoId), + }); return matchedEmbedRoute(query, queryRef); }, @@ -110,7 +121,7 @@ const embedEventFragment = graphql` endTime duration } - authorizedData { + authorizedData(user: $eventUser, password: $eventPassword) { thumbnail tracks { uri flavor mimetype resolution isMaster } captions { uri lang } diff --git a/frontend/src/routes/Search.tsx b/frontend/src/routes/Search.tsx index 64a86ec95..40da89aa0 100644 --- a/frontend/src/routes/Search.tsx +++ b/frontend/src/routes/Search.tsx @@ -527,6 +527,7 @@ const SearchEvent: React.FC<EventItem> = ({ image: <Link to={link} tabIndex={-1}> <Thumbnail event={{ + id, title, isLive, created, diff --git a/frontend/src/routes/Video.tsx b/frontend/src/routes/Video.tsx index ad62811e5..c18efe658 100644 --- a/frontend/src/routes/Video.tsx +++ b/frontend/src/routes/Video.tsx @@ -42,12 +42,13 @@ import { eventId, keyOfId, playlistId, + getCredentials, } from "../util"; import { BREAKPOINT_SMALL, BREAKPOINT_MEDIUM } from "../GlobalStyle"; import { LinkButton } from "../ui/LinkButton"; import CONFIG from "../config"; import { Link, useRouter } from "../router"; -import { useUser } from "../User"; +import { isRealUser, useUser } from "../User"; import { b64regex } from "./util"; import { ErrorPage } from "../ui/error"; import { CopyableInput, InputWithCheckbox, TimeInput } from "../ui/Input"; @@ -103,9 +104,16 @@ export const VideoRoute = makeRoute({ return null; } const { realmPath, videoId, listId } = params; + const id = eventId(videoId); const query = graphql` - query VideoPageInRealmQuery($id: ID!, $realmPath: String!, $listId: ID!) { + query VideoPageInRealmQuery( + $id: ID!, + $realmPath: String!, + $listId: ID!, + $eventUser: String, + $eventPassword: String, + ) { ... UserData event: eventById(id: $id) { ... VideoPageEventData @@ -122,9 +130,10 @@ export const VideoRoute = makeRoute({ `; const queryRef = loadQuery<VideoPageInRealmQuery>(query, { - id: eventId(videoId), + id, realmPath, listId, + ...getCredentials(videoId), }); return { @@ -167,7 +176,13 @@ export const OpencastVideoRoute = makeRoute({ const id = videoId.substring(1); const query = graphql` - query VideoPageByOcIdInRealmQuery($id: String!, $realmPath: String!, $listId: ID!) { + query VideoPageByOcIdInRealmQuery( + $id: String!, + $realmPath: String!, + $listId: ID!, + $eventUser: String, + $eventPassword: String, + ) { ... UserData event: eventByOpencastId(id: $id) { ... VideoPageEventData @@ -187,6 +202,7 @@ export const OpencastVideoRoute = makeRoute({ id, realmPath, listId, + ...getCredentials(id), }); return { @@ -215,39 +231,6 @@ export const OpencastVideoRoute = makeRoute({ }, }); -type VideoParams = { - realmPath: string; - videoId: string; - listId: string; -} | null; - -const getVideoDetailsFromUrl = (url: URL, regEx: string): VideoParams => { - const urlPath = url.pathname.replace(/^\/|\/$/g, ""); - const listId = makeListId(url.searchParams.get("list")); - 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, listId }; -}; - -const makeListId = (id: string | null) => id ? playlistId(id) : ""; - const ForwardToDirectRoute: React.FC<{ videoId: string }> = ({ videoId }) => { const router = useRouter(); useEffect(() => router.goto(DirectVideoRoute.url({ videoId }), true)); @@ -271,7 +254,12 @@ export const DirectVideoRoute = makeRoute({ } const query = graphql` - query VideoPageDirectLinkQuery($id: ID!, $listId: ID!) { + query VideoPageDirectLinkQuery( + $id: ID!, + $listId: ID!, + $eventUser: String, + $eventPassword: String + ) { ... UserData event: eventById(id: $id) { ... VideoPageEventData } realm: rootRealm { @@ -285,6 +273,7 @@ export const DirectVideoRoute = makeRoute({ const queryRef = loadQuery<VideoPageDirectLinkQuery>(query, { id: eventId(videoId), listId: makeListId(url.searchParams.get("list")), + ...getCredentials(videoId), }); return matchedDirectRoute(query, queryRef); @@ -302,7 +291,12 @@ export const DirectOpencastVideoRoute = makeRoute({ } const query = graphql` - query VideoPageDirectOpencastLinkQuery($id: String!, $listId: ID!) { + query VideoPageDirectOpencastLinkQuery( + $id: String!, + $listId: ID!, + $eventUser: String, + $eventPassword: String + ) { ... UserData event: eventByOpencastId(id: $id) { ... VideoPageEventData } realm: rootRealm { @@ -312,16 +306,53 @@ export const DirectOpencastVideoRoute = makeRoute({ playlist: playlistById(id: $listId) { ...PlaylistBlockPlaylistData } } `; - const videoId = decodeURIComponent(matches[1]); + const id = decodeURIComponent(matches[1]); const queryRef = loadQuery<VideoPageDirectOpencastLinkQuery>(query, { - id: videoId, + id, listId: makeListId(url.searchParams.get("list")), + ...getCredentials(id), }); return matchedDirectRoute(query, queryRef); }, }); +// =========================================================================================== +// ===== Helper functions +// =========================================================================================== + +const makeListId = (id: string | null) => id ? playlistId(id) : ""; + +type VideoParams = { + realmPath: string; + videoId: string; + listId: string; +} | null; + +const getVideoDetailsFromUrl = (url: URL, regEx: string): VideoParams => { + const urlPath = url.pathname.replace(/^\/|\/$/g, ""); + const listId = makeListId(url.searchParams.get("list")); + 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, listId }; +}; interface DirectRouteQuery extends OperationType { response: UserData$key & { @@ -387,7 +418,7 @@ const eventFragment = graphql` startTime endTime } - authorizedData { + authorizedData(user: $eventUser, password: $eventPassword) { tracks { uri flavor mimetype resolution isMaster } captions { uri lang } segments { uri startTime } @@ -406,13 +437,13 @@ const eventFragment = graphql` export const authorizedDataQuery = graphql` query VideoAuthorizedDataQuery( $eventId: ID!, - $seriesUser: String, - $seriesPassword: String, + $eventUser: String, + $eventPassword: String, ) { event: eventById(id: $eventId) { ...on AuthorizedEvent { id - authorizedData(user: $seriesUser, password: $seriesPassword) { + authorizedData(user: $eventUser, password: $eventPassword) { tracks { uri flavor mimetype resolution isMaster } captions { uri lang } segments { uri startTime } @@ -455,7 +486,6 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath } const breadcrumbs = realm.isMainRoot ? [] : realmBreadcrumbs(t, realm.ancestors.concat(realm)); - const { hasStarted, hasEnded } = getEventTimeInfo(event); const isCurrentlyLive = hasStarted === true && hasEnded === false; @@ -479,8 +509,6 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath // but it's not clear what for. }; - - return <> <Breadcrumbs path={breadcrumbs} tail={event.title} /> <script type="application/ld+json">{JSON.stringify(structuredData)}</script> @@ -618,6 +646,7 @@ const ProtectedVideoPlaceholder: React.FC<PlaceholderProps> = ({ }) => { const { t } = useTranslation(undefined, { keyPrefix: "video.password" }); const isDark = useColorScheme().scheme === "dark"; + const user = useUser(); const [state, setState] = useState<AuthenticationFormState>("idle"); const embeddedStyles = { @@ -628,7 +657,40 @@ const ProtectedVideoPlaceholder: React.FC<PlaceholderProps> = ({ const onSubmit = (data: FormData) => { setState("pending"); - loadQuery({ eventId: event.id, seriesUser: data.userid, seriesPassword: data.password }); + + const credentials = JSON.stringify({ + eventUser: data.userid, + eventPassword: data.password, + }); + + // To make the authentication "sticky", the credentials are stored in browser storage. + // + // If the user is logged in, local storage is used so the browser remembers them as long + // as the user stays logged in. + // If the user is not logged in however, the session storage is used, which is reset when + // the current tab or window is closed. This way we can be relatively sure that the next + // user will need to enter the credentials again in order to access a protected video. + // + // Furthermore, since the video route can be accessed via both kinds, this needs to store + // both Tobira ID and Opencast ID. Both are queried when a video route is accessed, but the + // check for already stored credentials is done in the same query, when only the single ID + // from the url is known. + // The check will return a result for either ID regardless of its kind, as long as one of + // them is stored. + if (isRealUser(user)) { + window.localStorage + .setItem(`tobira-video-credentials-${keyOfId(event.id)}`, credentials); + window.localStorage + .setItem(`tobira-video-credentials-${event.opencastId}`, credentials); + } else { + window.sessionStorage + .setItem(`tobira-video-credentials-${keyOfId(event.id)}`, credentials); + window.sessionStorage + .setItem(`tobira-video-credentials-${event.opencastId}`, credentials); + } + + + loadQuery({ eventId: event.id, eventUser: data.userid, eventPassword: data.password }); }; return ( diff --git a/frontend/src/ui/Blocks/Video.tsx b/frontend/src/ui/Blocks/Video.tsx index ad5795780..0af2ec0a3 100644 --- a/frontend/src/ui/Blocks/Video.tsx +++ b/frontend/src/ui/Blocks/Video.tsx @@ -5,7 +5,7 @@ import { InlinePlayer } from "../player"; import { VideoBlockData$data, VideoBlockData$key } from "./__generated__/VideoBlockData.graphql"; import { Title } from ".."; import { useTranslation } from "react-i18next"; -import { isSynced, keyOfId } from "../../util"; +import { isSynced, keyOfId, useAuthenticatedDataQuery } from "../../util"; import { Link } from "../../router"; import { LuArrowRightCircle } from "react-icons/lu"; import { PlayerContextProvider } from "../player/PlayerContext"; @@ -74,14 +74,18 @@ export const VideoBlock: React.FC<Props> = ({ fragRef, basePath }) => { return unreachable(); } + const authenticatedData = useAuthenticatedDataQuery(keyOfId(event.id)); + const authorizedData = event.authorizedData ?? authenticatedData.event?.authorizedData; + + return <div css={{ maxWidth: 800 }}> {showTitle && <Title title={event.title} />} <PlayerContextProvider> - {event.authorizedData && isSynced(event) + {authorizedData && isSynced(event) ? <InlinePlayer event={{ ...event, - authorizedData: event.authorizedData, + authorizedData, }} css={{ margin: "-4px auto 0" }} /> diff --git a/frontend/src/ui/Video.tsx b/frontend/src/ui/Video.tsx index 2908a966e..a79b7819d 100644 --- a/frontend/src/ui/Video.tsx +++ b/frontend/src/ui/Video.tsx @@ -12,11 +12,13 @@ import { import { useColorScheme } from "@opencast/appkit"; import { COLORS } from "../color"; +import { keyOfId, useAuthenticatedDataQuery } from "../util"; type ThumbnailProps = JSX.IntrinsicElements["div"] & { /** The event of which a thumbnail should be shown */ event: { + id: string; title: string; isLive: boolean; created: string; @@ -51,6 +53,9 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ }) => { const { t } = useTranslation(); const isDark = useColorScheme().scheme === "dark"; + const authenticatedData = useAuthenticatedDataQuery(keyOfId(event.id)); + const authorizedThumbnail = event.authorizedData?.thumbnail + ?? authenticatedData.event?.authorizedData?.thumbnail; const isUpcoming = isUpcomingLiveEvent(event.syncedData?.startTime ?? null, event.isLive); const audioOnly = event.authorizedData ? ( @@ -61,16 +66,16 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ : false; let inner; - if (event.authorizedData?.thumbnail != null && !deletionIsPending) { + if (authorizedThumbnail && !deletionIsPending) { // We have a proper thumbnail. inner = <ThumbnailImg - src={event.authorizedData.thumbnail} + src={authorizedThumbnail} alt={t("video.thumbnail-for", { video: event.title })} />; } else { inner = <ThumbnailReplacement {...{ audioOnly, isUpcoming, isDark, deletionIsPending }} - previewOnly={!event.authorizedData} + previewOnly={!(event.authorizedData && "tracks" in event.authorizedData)} />; } diff --git a/frontend/src/util/index.ts b/frontend/src/util/index.ts index ad1e811ec..32978cf28 100644 --- a/frontend/src/util/index.ts +++ b/frontend/src/util/index.ts @@ -2,9 +2,15 @@ import { i18n } from "i18next"; import { MutableRefObject, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { bug, match } from "@opencast/appkit"; +import { OperationType } from "relay-runtime"; +import { useLazyLoadQuery } from "react-relay"; import CONFIG, { TranslatedString } from "../config"; import { TimeUnit } from "../ui/Input"; +import { authorizedDataQuery } from "../routes/Video"; +import { + VideoAuthorizedDataQuery$data, +} from "../routes/__generated__/VideoAuthorizedDataQuery.graphql"; /** @@ -215,3 +221,48 @@ export const secondsToTimeString = (seconds: number): string => { }; export type ExtraMetadata = Record<string, Record<string, string[]>>; + +export type SeriesCredentials = { + user: string; + password: string; +} | null; + +interface AuthenticatedData extends OperationType { + response: VideoAuthorizedDataQuery$data; +} + +/** + * 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. + */ +export const useAuthenticatedDataQuery = (id: string) => { + const credentials = getCredentials(keyOfId(id)); + return 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" } + ); +}; + +/** + * Returns stored credentials of events. + * + * 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. + */ +export const getCredentials = (eventId: string): SeriesCredentials => { + const credentials = window.localStorage.getItem(`tobira-video-credentials-${eventId}`) + ?? window.sessionStorage.getItem(`tobira-video-credentials-${eventId}`); + + return credentials && JSON.parse(credentials); +}; + From 44255d7eb2cec75103f8a6c148fdc8f8260e6e3e Mon Sep 17 00:00:00 2001 From: Ole Wieners <olewieners@yahoo.com> Date: Mon, 9 Sep 2024 15:30:58 +0200 Subject: [PATCH 09/26] Add eth-password interpretation The ETH sends their series credentials as SHA1 encoded strings including a username and password. This commit adds a config option that causes these credentials to be separated and stored in Tobira's DB when enabled. To authenticate, users will need to enter these credentials, which will then be hashed and checked against the ones we have from the ETH. --- .deployment/templates/config.toml | 1 + backend/Cargo.lock | 12 +++++++ backend/Cargo.toml | 1 + backend/src/api/model/event.rs | 46 +++++++++++++----------- backend/src/auth/mod.rs | 18 +++++++++- backend/src/db/types.rs | 7 ++++ backend/src/sync/harvest/mod.rs | 48 ++++++++++++++++++++++---- backend/src/sync/mod.rs | 5 +++ docs/docs/setup/config.toml | 6 ++++ frontend/src/layout/header/UserBox.tsx | 3 +- frontend/src/routes/Video.tsx | 10 +++--- frontend/src/util/index.ts | 10 +++--- util/dev-config/config.toml | 1 + 13 files changed, 130 insertions(+), 38 deletions(-) diff --git a/.deployment/templates/config.toml b/.deployment/templates/config.toml index c01c54795..653c563ab 100644 --- a/.deployment/templates/config.toml +++ b/.deployment/templates/config.toml @@ -50,6 +50,7 @@ host = "https://tobira-test-oc.ethz.ch" user = "admin" password = "{{ opencast_admin_password }}" poll_period = "1min" +interpret_eth_passwords = true [theme] logo.large.path = "/opt/tobira/{{ id }}/logo-large.svg" diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 2cc18fe24..e12a8554d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2525,6 +2525,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.8" @@ -2901,6 +2912,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha1", "static_assertions", "subtp", "tap", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 7b88a93b4..ce5569495 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -84,6 +84,7 @@ tracing-subscriber = "0.3.18" reqwest = "0.12.4" subtp = "0.2.0" xmlparser = "0.13.6" +sha1 = "0.10.6" [target.'cfg(target_os = "linux")'.dependencies] procfs = "0.16.0" diff --git a/backend/src/api/model/event.rs b/backend/src/api/model/event.rs index fb8bdf446..1bae88e02 100644 --- a/backend/src/api/model/event.rs +++ b/backend/src/api/model/event.rs @@ -4,6 +4,7 @@ use postgres_types::ToSql; use serde::{Serialize, Deserialize}; use tokio_postgres::Row; use juniper::{GraphQLObject, graphql_object}; +use sha1::{Sha1, Digest}; use crate::{ api::{ @@ -13,7 +14,7 @@ use crate::{ model::{acl::{self, Acl}, realm::Realm, series::Series}, }, db::{ - types::{EventCaption, EventSegment, EventState, EventTrack, ExtraMetadata, Key}, + types::{EventCaption, EventSegment, EventState, EventTrack, ExtraMetadata, Key, Credentials}, util::{impl_from_db, select}, }, prelude::*, @@ -38,6 +39,7 @@ pub(crate) struct AuthorizedEvent { pub(crate) read_roles: Vec<String>, pub(crate) write_roles: Vec<String>, pub(crate) preview_roles: Vec<String>, + pub(crate) credentials: Option<Credentials>, pub(crate) synced_data: Option<SyncedEventData>, pub(crate) authorized_data: Option<AuthorizedEventData>, @@ -70,7 +72,7 @@ impl_from_db!( title, description, duration, creators, thumbnail, metadata, created, updated, start_time, end_time, tracks, captions, segments, - read_roles, write_roles, preview_roles, + read_roles, write_roles, preview_roles, credentials, tobira_deletion_timestamp, }, }, @@ -88,6 +90,7 @@ impl_from_db!( read_roles: row.read_roles::<Vec<String>>(), write_roles: row.write_roles::<Vec<String>>(), preview_roles: row.preview_roles::<Vec<String>>(), + credentials: row.credentials(), tobira_deletion_timestamp: row.tobira_deletion_timestamp(), synced_data: match row.state::<EventState>() { EventState::Ready => Some(SyncedEventData { @@ -227,16 +230,26 @@ impl AuthorizedEvent { } /// Returns the authorized event data if the user has read access or is authenticated for the event. - async fn authorized_data(&self, context: &Context, user: Option<String>, password: Option<String>) -> Option<&AuthorizedEventData> { - // TODO: replace with hashed credentials from db, add actual comparison check with hashed user inputs - let expected_user = "plane"; - let expected_pw = "bird"; + async fn authorized_data( + &self, + context: &Context, + user: Option<String>, + password: Option<String>, + ) -> Option<&AuthorizedEventData> { + let sha1_matches = |input: &str, encoded: &str| { + let (algo, hash) = encoded.split_once(':').expect("invalid credentials in DB"); + match algo { + "sha1" => hash == hex::encode_upper(Sha1::digest(input)), + _ => unreachable!("unsupported hash algo"), + } + }; - let matches = self.has_password(context).await.unwrap_or(false) - && user.map_or(false, |u| u == expected_user) - && password.map_or(false, |p| p == expected_pw); + let credentials_match = self.credentials.as_ref().map_or(false, |credentials| { + user.map_or(false, |u| sha1_matches(&u, &credentials.name)) + && password.map_or(false, |p| sha1_matches(&p, &credentials.password)) + }); - if context.auth.overlaps_roles(&self.read_roles) || matches { + if context.auth.overlaps_roles(&self.read_roles) || credentials_match { self.authorized_data.as_ref() } else { None @@ -280,8 +293,8 @@ impl AuthorizedEvent { /// Whether this event is password protected. - async fn has_password(&self, context: &Context) -> ApiResult<bool> { - self.has_password(context).await + async fn has_password(&self) -> bool { + self.credentials.is_some() } async fn acl(&self, context: &Context) -> ApiResult<Acl> { @@ -340,15 +353,6 @@ impl AuthorizedEvent { Self::load_by_any_id_impl("opencast_id", &oc_id, context).await } - /// Whether this event is password protected. - async fn has_password(&self, context: &Context) -> ApiResult<bool> { - let query = format!("select credentials is not null from all_events where id = $1"); - context.db.query_one(&query, &[&self.key]) - .await? - .get::<_, bool>(0) - .pipe(Ok) - } - pub(crate) async fn load_by_any_id_impl( col: &str, id: &(dyn ToSql + Sync), diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs index 6109663e6..238c0c4bd 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -5,6 +5,7 @@ use cookie::Cookie; use deadpool_postgres::Client; use hyper::{http::HeaderValue, HeaderMap, Request, StatusCode}; use once_cell::sync::Lazy; +use regex::Regex; use secrecy::ExposeSecret; use serde::Deserialize; use tokio_postgres::Error as PgError; @@ -38,7 +39,22 @@ pub(crate) use self::{ /// administrator. pub(crate) const ROLE_ADMIN: &str = "ROLE_ADMIN"; -const ROLE_ANONYMOUS: &str = "ROLE_ANONYMOUS"; +/// (**ETH SPECIAL FEATURE**) +/// Role used to define username and password for series (used in events). +/// In Tobira, these are stored separately during sync and the role isn't used +/// afterwards. Therefore it should be filtered out. +pub(crate) static ETH_ROLE_CREDENTIALS_RE: Lazy<Regex> = Lazy::new(|| Regex::new( + r"^ROLE_GROUP_([a-fA-F0-9]{40})_([a-fA-F0-9]{40})$" +).unwrap()); + +/// (**ETH SPECIAL FEATURE**) +/// Role used in Admin UI to show the above credentials for some series. +/// This is not used in Tobira and should be filtered out. +pub(crate) static ETH_ROLE_PASSWORD_RE: Lazy<Regex> = Lazy::new(|| Regex::new( + r"^ROLE_PWD_[a-zA-Z0-9+/]*={0,2}$" +).unwrap()); + +pub(crate) const ROLE_ANONYMOUS: &str = "ROLE_ANONYMOUS"; const ROLE_USER: &str = "ROLE_USER"; const SESSION_COOKIE: &str = "tobira-session"; diff --git a/backend/src/db/types.rs b/backend/src/db/types.rs index 8b50a0165..405ca60b3 100644 --- a/backend/src/db/types.rs +++ b/backend/src/db/types.rs @@ -242,3 +242,10 @@ impl<'a> FromSql<'a> for CustomActions { <serde_json::Value as FromSql>::accepts(ty) } } + +#[derive(Debug, ToSql, FromSql)] +#[postgres(name = "credentials")] +pub(crate) struct Credentials { + pub(crate) name: String, + pub(crate) password: String, +} diff --git a/backend/src/sync/harvest/mod.rs b/backend/src/sync/harvest/mod.rs index 83ae03958..9221b7f43 100644 --- a/backend/src/sync/harvest/mod.rs +++ b/backend/src/sync/harvest/mod.rs @@ -6,12 +6,12 @@ use std::{ use tokio_postgres::types::ToSql; use crate::{ - auth::ROLE_ADMIN, + auth::{ROLE_ADMIN, ROLE_ANONYMOUS, ETH_ROLE_CREDENTIALS_RE, ETH_ROLE_PASSWORD_RE}, config::Config, db::{ self, DbConnection, - types::{EventCaption, EventSegment, EventState, EventTrack, SeriesState}, + types::{EventCaption, EventSegment, EventState, EventTrack, Credentials, SeriesState}, }, prelude::*, }; @@ -88,7 +88,7 @@ pub(crate) async fn run( // everything worked out alright. let last_updated = harvest_data.items.last().map(|item| item.updated()); let mut transaction = db.transaction().await?; - store_in_db(harvest_data.items, &sync_status, &mut transaction).await?; + store_in_db(harvest_data.items, &sync_status, &mut transaction, config).await?; SyncStatus::update_harvested_until(harvest_data.includes_items_until, &*transaction).await?; transaction.commit().await?; @@ -132,6 +132,7 @@ async fn store_in_db( items: Vec<HarvestItem>, sync_status: &SyncStatus, db: &mut deadpool_postgres::Transaction<'_>, + config: &Config, ) -> Result<()> { let before = Instant::now(); let mut upserted_events = 0; @@ -180,11 +181,36 @@ async fn store_in_db( }, }; + // (**ETH SPECIAL FEATURE**) + let credentials = config.sync.interpret_eth_passwords + .then(|| hashed_eth_credentials(&acl.read)) + .flatten(); + + // (**ETH SPECIAL FEATURE**) + // When an ETH event is password protected, read access doesn't suffice to view a video - everyone + // without write access needs to authenticate. So we need to shift all read roles down to preview, so + // users with what was previously read access are only allowed to preview and authenticate. + // `read_roles` now needs to be an exact copy of `write_roles`, and not a superset. + // With this, checks that allow actual read access will still succeed for users that also have write + // access. + // Additionally, since ETH requires that everyone with the link should be able to authenticate + // regardless of ACL inclusion, `ROLE_ANONYMOUS` is added to the preview roles. + if credentials.is_some() { + (acl.preview, acl.read) = (acl.read, acl.write.clone()); + acl.preview.push(ROLE_ANONYMOUS.into()); + } + + let filter_role = |role: &String| -> bool { + !(role == ROLE_ADMIN || (config.sync.interpret_eth_passwords + && (ETH_ROLE_CREDENTIALS_RE.is_match(role) || ETH_ROLE_PASSWORD_RE.is_match(role)) + )) + }; + // We always handle the admin role in a special way, so no need // to store it for every single event. - acl.preview.retain(|role| role != ROLE_ADMIN); - acl.read.retain(|role| role != ROLE_ADMIN); - acl.write.retain(|role| role != ROLE_ADMIN); + acl.preview.retain(filter_role); + acl.read.retain(filter_role); + acl.write.retain(filter_role); for (_, roles) in &mut acl.custom_actions.0 { roles.retain(|role| role != ROLE_ADMIN); @@ -219,6 +245,7 @@ async fn store_in_db( ("captions", &captions), ("segments", &segments), ("slide_text", &slide_text), + ("credentials", &credentials), ]).await?; trace!("Inserted or updated event {} ({})", opencast_id, title); @@ -381,6 +408,15 @@ fn check_affected_rows_removed(rows_affected: u64, entity: &str, opencast_id: &s } } +fn hashed_eth_credentials(read_roles: &[String]) -> Option<Credentials> { + read_roles.iter().find_map(|role| { + ETH_ROLE_CREDENTIALS_RE.captures(role).map(|captures| Credentials { + name: format!("sha1:{}", &captures[1]), + password: format!("sha1:{}", &captures[2]), + }) + }) +} + /// Inserts a new row or updates an existing one if the value in `unique_col` /// already exists. Returns the value of the `id` column, which is assumed to /// be `i64`. diff --git a/backend/src/sync/mod.rs b/backend/src/sync/mod.rs index 92409374b..4e55dcd8a 100644 --- a/backend/src/sync/mod.rs +++ b/backend/src/sync/mod.rs @@ -72,6 +72,11 @@ pub(crate) struct SyncConfig { #[config(default = "30s", deserialize_with = crate::config::deserialize_duration)] pub(crate) poll_period: Duration, + /// Whether SHA1-hashed series passwords (as assignable by ETH's admin UI + /// build) are interpreted in Tobira. + #[config(default = false)] + pub(crate) interpret_eth_passwords: bool, + /// Number of concurrent tasks with which Tobira downloads assets from /// Opencast. The default should be a good sweet spot. Decrease to reduce /// load on Opencast, increase to speed up download a bit. diff --git a/docs/docs/setup/config.toml b/docs/docs/setup/config.toml index b78eae74d..496084637 100644 --- a/docs/docs/setup/config.toml +++ b/docs/docs/setup/config.toml @@ -466,6 +466,12 @@ # Default value: "30s" #poll_period = "30s" +# Whether SHA1-hashed series passwords (as assignable by ETH's admin UI +# build) are interpreted in Tobira. +# +# Default value: false +#interpret_eth_passwords = false + # Number of concurrent tasks with which Tobira downloads assets from # Opencast. The default should be a good sweet spot. Decrease to reduce # load on Opencast, increase to speed up download a bit. diff --git a/frontend/src/layout/header/UserBox.tsx b/frontend/src/layout/header/UserBox.tsx index 706f711d5..400ea1701 100644 --- a/frontend/src/layout/header/UserBox.tsx +++ b/frontend/src/layout/header/UserBox.tsx @@ -25,6 +25,7 @@ import { UploadRoute } from "../../routes/Upload"; import { ManageRoute } from "../../routes/manage"; import { ManageVideosRoute } from "../../routes/manage/Video"; import { LoginLink } from "../../routes/util"; +import { CREDENTIALS_STORAGE_KEY } from "../../routes/Video"; @@ -176,7 +177,7 @@ const LoggedIn: React.FC<LoggedInProps> = ({ user }) => { } Object.keys(window.localStorage) - .filter(item => item.startsWith("tobira-video-credentials-")) + .filter(item => item.startsWith(CREDENTIALS_STORAGE_KEY)) .forEach(item => window.localStorage.removeItem(item)); setLogoutState("pending"); diff --git a/frontend/src/routes/Video.tsx b/frontend/src/routes/Video.tsx index c18efe658..4ccf5624a 100644 --- a/frontend/src/routes/Video.tsx +++ b/frontend/src/routes/Video.tsx @@ -574,6 +574,8 @@ export const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ }} /> : <PreviewPlaceholder {...{ event, loadQuery, embedded }} />; +export const CREDENTIALS_STORAGE_KEY = "tobira-video-credentials-"; + const PlayerOrPreview: React.FC<ProtectedPlayerProps> = ({ queryReference, event, @@ -679,14 +681,14 @@ const ProtectedVideoPlaceholder: React.FC<PlaceholderProps> = ({ // them is stored. if (isRealUser(user)) { window.localStorage - .setItem(`tobira-video-credentials-${keyOfId(event.id)}`, credentials); + .setItem(CREDENTIALS_STORAGE_KEY + keyOfId(event.id), credentials); window.localStorage - .setItem(`tobira-video-credentials-${event.opencastId}`, credentials); + .setItem(CREDENTIALS_STORAGE_KEY + event.opencastId, credentials); } else { window.sessionStorage - .setItem(`tobira-video-credentials-${keyOfId(event.id)}`, credentials); + .setItem(CREDENTIALS_STORAGE_KEY + keyOfId(event.id), credentials); window.sessionStorage - .setItem(`tobira-video-credentials-${event.opencastId}`, credentials); + .setItem(CREDENTIALS_STORAGE_KEY + event.opencastId, credentials); } diff --git a/frontend/src/util/index.ts b/frontend/src/util/index.ts index 32978cf28..e44337827 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 } from "../routes/Video"; +import { authorizedDataQuery, CREDENTIALS_STORAGE_KEY } from "../routes/Video"; import { VideoAuthorizedDataQuery$data, } from "../routes/__generated__/VideoAuthorizedDataQuery.graphql"; @@ -222,7 +222,7 @@ export const secondsToTimeString = (seconds: number): string => { export type ExtraMetadata = Record<string, Record<string, string[]>>; -export type SeriesCredentials = { +export type Credentials = { user: string; password: string; } | null; @@ -259,9 +259,9 @@ export const useAuthenticatedDataQuery = (id: string) => { * 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. */ -export const getCredentials = (eventId: string): SeriesCredentials => { - const credentials = window.localStorage.getItem(`tobira-video-credentials-${eventId}`) - ?? window.sessionStorage.getItem(`tobira-video-credentials-${eventId}`); +export const getCredentials = (eventId: string): Credentials => { + const credentials = window.localStorage.getItem(CREDENTIALS_STORAGE_KEY + eventId) + ?? window.sessionStorage.getItem(CREDENTIALS_STORAGE_KEY + eventId); return credentials && JSON.parse(credentials); }; diff --git a/util/dev-config/config.toml b/util/dev-config/config.toml index 94150bb7b..17dd0671d 100644 --- a/util/dev-config/config.toml +++ b/util/dev-config/config.toml @@ -42,6 +42,7 @@ host = "http://localhost:8081" user = "admin" password = "opencast" preferred_harvest_size = 3 +interpret_eth_passwords = true [theme] logo.large.path = "logo-large.svg" From ef097da055c35db206a7ca5aa38aa178a3c19203 Mon Sep 17 00:00:00 2001 From: Ole Wieners <olewieners@yahoo.com> Date: Thu, 12 Sep 2024 13:17:57 +0200 Subject: [PATCH 10/26] Hide text results for events with passwords This is not a good solution as it relies on a frontend check. Ideally this would be done in backend. This same applies for thumbnails in search results. --- backend/src/api/model/search/event.rs | 8 +++++--- frontend/src/routes/Search.tsx | 21 +++++++++++++++++---- frontend/src/schema.graphql | 1 + frontend/src/ui/Video.tsx | 2 +- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/backend/src/api/model/search/event.rs b/backend/src/api/model/search/event.rs index ed6f5d8aa..c3a4211b0 100644 --- a/backend/src/api/model/search/event.rs +++ b/backend/src/api/model/search/event.rs @@ -30,6 +30,7 @@ pub(crate) struct SearchEvent { pub text_matches: Vec<TextMatch>, pub matches: SearchEventMatches, pub has_password: bool, + pub user_is_authorized: bool, } #[derive(Debug, GraphQLObject, Default)] @@ -102,10 +103,10 @@ impl SearchEvent { matches: SearchEventMatches, context: &Context, ) -> Self { + let read_roles = decode_acl(&src.read_roles); + let user_is_authorized = context.auth.overlaps_roles(read_roles); let thumbnail = { - let read_roles = decode_acl(&src.read_roles); - - if context.auth.overlaps_roles(read_roles) { + if user_is_authorized { src.thumbnail } else { None @@ -132,6 +133,7 @@ impl SearchEvent { text_matches, matches, has_password: src.has_password, + user_is_authorized, } } } diff --git a/frontend/src/routes/Search.tsx b/frontend/src/routes/Search.tsx index 40da89aa0..a8d32e753 100644 --- a/frontend/src/routes/Search.tsx +++ b/frontend/src/routes/Search.tsx @@ -49,7 +49,13 @@ import { MissingRealmName } from "./util"; import { ellipsisOverflowCss, focusStyle } from "../ui"; import { COLORS } from "../color"; import { BREAKPOINT_MEDIUM } from "../GlobalStyle"; -import { eventId, isExperimentalFlagSet, keyOfId, secondsToTimeString } from "../util"; +import { + eventId, + getCredentials, + isExperimentalFlagSet, + keyOfId, + secondsToTimeString, +} from "../util"; import { DirectVideoRoute, VideoRoute } from "./Video"; import { DirectSeriesRoute, SeriesRoute } from "./Series"; import { PartOfSeriesLink } from "../ui/Blocks/VideoList"; @@ -156,6 +162,8 @@ const query = graphql` startTime endTime created + hasPassword + userIsAuthorized hostRealms { path ancestorNames } textMatches { start @@ -515,6 +523,8 @@ const SearchEvent: React.FC<EventItem> = ({ hostRealms, textMatches, matches, + hasPassword, + userIsAuthorized, }) => { // TODO: decide what to do in the case of more than two host realms. Direct // link should be avoided. @@ -522,6 +532,9 @@ const SearchEvent: React.FC<EventItem> = ({ ? DirectVideoRoute.url({ videoId: id }) : VideoRoute.url({ realmPath: hostRealms[0].path, videoID: id }); + // TODO: This check should be done in backend. + const showMatches = userIsAuthorized || (hasPassword && getCredentials(keyOfId(id))); + return ( <Item key={id} breakpoint={BREAKPOINT_MEDIUM} link={link}>{{ image: <Link to={link} tabIndex={-1}> @@ -617,7 +630,7 @@ const SearchEvent: React.FC<EventItem> = ({ {...{ seriesId }} />} {/* Show timeline with matches if there are any */} - {textMatches.length > 0 && ( + {textMatches.length > 0 && showMatches && ( <TextMatchTimeline {...{ id, duration, link, textMatches }} /> )} </div>, @@ -630,11 +643,11 @@ type TextMatchTimelineProps = Pick<EventItem, "id" | "duration" | "textMatches"> }; const slidePreviewQuery = graphql` - query SearchSlidePreviewQuery($id: ID!) { + query SearchSlidePreviewQuery($id: ID!, $user: String, $password: String) { eventById(id: $id) { ...on AuthorizedEvent { id - authorizedData { + authorizedData(user: $user, password: $password) { segments { startTime uri } } } diff --git a/frontend/src/schema.graphql b/frontend/src/schema.graphql index ad3d00644..9868e8177 100644 --- a/frontend/src/schema.graphql +++ b/frontend/src/schema.graphql @@ -103,6 +103,7 @@ type SearchEvent implements Node { textMatches: [TextMatch!]! matches: SearchEventMatches! hasPassword: Boolean! + userIsAuthorized: Boolean! } input ChildIndex { diff --git a/frontend/src/ui/Video.tsx b/frontend/src/ui/Video.tsx index a79b7819d..1c1ad72f9 100644 --- a/frontend/src/ui/Video.tsx +++ b/frontend/src/ui/Video.tsx @@ -75,7 +75,7 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ } else { inner = <ThumbnailReplacement {...{ audioOnly, isUpcoming, isDark, deletionIsPending }} - previewOnly={!(event.authorizedData && "tracks" in event.authorizedData)} + previewOnly={!event.authorizedData} />; } From e2164ab8640d55b3051788421eb928fcfd58b298 Mon Sep 17 00:00:00 2001 From: Ole Wieners <olewieners@yahoo.com> Date: Wed, 16 Oct 2024 17:27:19 +0200 Subject: [PATCH 11/26] Add migration and sync for series credentials The ETH passwords are inherited from their series, so it is necessary to keep them in sync. --- backend/src/auth/mod.rs | 7 ++++ backend/src/db/migrations.rs | 1 + .../migrations/40-eth-series-credentials.sql | 37 +++++++++++++++++++ backend/src/sync/harvest/mod.rs | 22 ++++++----- 4 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 backend/src/db/migrations/40-eth-series-credentials.sql diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs index 238c0c4bd..7a7671252 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -12,6 +12,7 @@ use tokio_postgres::Error as PgError; use crate::{ api::err::{not_authorized, ApiError}, + config::Config, db::util::select, http::{response, Context, Response}, prelude::*, @@ -54,6 +55,12 @@ pub(crate) static ETH_ROLE_PASSWORD_RE: Lazy<Regex> = Lazy::new(|| Regex::new( r"^ROLE_PWD_[a-zA-Z0-9+/]*={0,2}$" ).unwrap()); +pub(crate) fn is_special_eth_role(role: &String, config: &Config) -> bool { + config.sync.interpret_eth_passwords && ( + ETH_ROLE_CREDENTIALS_RE.is_match(role) || ETH_ROLE_PASSWORD_RE.is_match(role) + ) +} + pub(crate) const ROLE_ANONYMOUS: &str = "ROLE_ANONYMOUS"; const ROLE_USER: &str = "ROLE_USER"; diff --git a/backend/src/db/migrations.rs b/backend/src/db/migrations.rs index 7b271cea9..4f6328413 100644 --- a/backend/src/db/migrations.rs +++ b/backend/src/db/migrations.rs @@ -372,4 +372,5 @@ static MIGRATIONS: Lazy<BTreeMap<u64, Migration>> = include_migrations![ 37: "redo-search-triggers-and-listed", 38: "event-texts", 39: "event-preview-roles-and-password", + 40: "eth-series-credentials", ]; diff --git a/backend/src/db/migrations/40-eth-series-credentials.sql b/backend/src/db/migrations/40-eth-series-credentials.sql new file mode 100644 index 000000000..9719ccc29 --- /dev/null +++ b/backend/src/db/migrations/40-eth-series-credentials.sql @@ -0,0 +1,37 @@ +-- Adds a credentials column to series that hold series specific passwords and usernames. +-- These need to be synced with events that are part of the series. +-- This is specific for authentication requirements of the ETH and is only useful when the +-- `interpret_eth_passwords` configuration is enabled. + +alter table series add column credentials credentials; + +-- When the credentials of a series change, each event that is part of it also needs to be updated. +create function sync_series_credentials() returns trigger language plpgsql as $$ +begin + update all_events set credentials = series.credentials + from series where all_events.series = series.id and series.id = new.id; + return new; +end; +$$; + +create trigger sync_series_credentials_on_change +after update on series +for each row +when (old.credentials is distinct from new.credentials) +execute function sync_series_credentials(); + +-- Tobira uploads do not automatically get the credentials of their assigned series, so this needs +-- to be done with an additional function and a trigger. +create function sync_credentials_before_event_insert() returns trigger language plpgsql as $$ +begin + select series.credentials into new.credentials + from series + where series.id = new.series; + return new; +end; +$$; + +create trigger sync_series_credentials_before_event_insert +before insert on all_events +for each row +execute function sync_credentials_before_event_insert(); diff --git a/backend/src/sync/harvest/mod.rs b/backend/src/sync/harvest/mod.rs index 9221b7f43..c6a3658e4 100644 --- a/backend/src/sync/harvest/mod.rs +++ b/backend/src/sync/harvest/mod.rs @@ -6,12 +6,10 @@ use std::{ use tokio_postgres::types::ToSql; use crate::{ - auth::{ROLE_ADMIN, ROLE_ANONYMOUS, ETH_ROLE_CREDENTIALS_RE, ETH_ROLE_PASSWORD_RE}, + auth::{is_special_eth_role, ROLE_ADMIN, ROLE_ANONYMOUS, ETH_ROLE_CREDENTIALS_RE}, config::Config, db::{ - self, - DbConnection, - types::{EventCaption, EventSegment, EventState, EventTrack, Credentials, SeriesState}, + self, types::{Credentials, EventCaption, EventSegment, EventState, EventTrack, SeriesState}, DbConnection }, prelude::*, }; @@ -201,9 +199,7 @@ async fn store_in_db( } let filter_role = |role: &String| -> bool { - !(role == ROLE_ADMIN || (config.sync.interpret_eth_passwords - && (ETH_ROLE_CREDENTIALS_RE.is_match(role) || ETH_ROLE_PASSWORD_RE.is_match(role)) - )) + role != ROLE_ADMIN && !is_special_eth_role(role, config) }; // We always handle the admin role in a special way, so no need @@ -265,10 +261,17 @@ async fn store_in_db( title, description, updated, - acl, + mut acl, created, - metadata + metadata, } => { + // (**ETH SPECIAL FEATURE**) + let series_credentials = config.sync.interpret_eth_passwords + .then(|| hashed_eth_credentials(&acl.read)) + .flatten(); + acl.read.retain(|role| !is_special_eth_role(role, config)); + acl.write.retain(|role| !is_special_eth_role(role, config)); + // We first simply upsert the series. let new_id = upsert(db, "series", "opencast_id", &[ ("opencast_id", &opencast_id), @@ -280,6 +283,7 @@ async fn store_in_db( ("updated", &updated), ("created", &created), ("metadata", &metadata), + ("credentials", &series_credentials), ]).await?; // But now we have to fix the foreign key for any events that From 4f6d742b3ad1ae7155d87d0e798593204457862b Mon Sep 17 00:00:00 2001 From: Ole Wieners <olewieners@yahoo.com> Date: Thu, 17 Oct 2024 17:44:32 +0200 Subject: [PATCH 12/26] Fix infinite spinner and flashing screen This replaces some relay logic with relay's `fetchQuery` in order to be able to directly react to the query result or failure. I suspect this should also be possible with `loadQuery` and/or `usePreloadedQuery` but I couldn't figure it out. There was sth going on with the previous implementation that was causing the whole page to reload when submitting correct credentials, while entering the wrong credentials caused the loading indicator to spin indefinetely - huh? I don't know what exactly caused this. Anyway, the new solution also makes the whole video route file a little smaller and saves two middle-man components as well as some props. I think it's an overall improvement in terms of readability and maintainability (and fixes the afore mentioned issues). --- frontend/src/routes/Embed.tsx | 13 +- frontend/src/routes/Video.tsx | 298 ++++++++++++------------------- frontend/src/ui/Blocks/Video.tsx | 18 +- frontend/src/ui/Video.tsx | 2 +- frontend/src/ui/player/index.tsx | 3 + 5 files changed, 130 insertions(+), 204 deletions(-) diff --git a/frontend/src/routes/Embed.tsx b/frontend/src/routes/Embed.tsx index 51e2d3708..e143d061d 100644 --- a/frontend/src/routes/Embed.tsx +++ b/frontend/src/routes/Embed.tsx @@ -2,7 +2,7 @@ import { ReactNode, Suspense } from "react"; import { LuFrown, LuAlertTriangle } from "react-icons/lu"; import { Translation, useTranslation } from "react-i18next"; import { - graphql, useFragment, usePreloadedQuery, useQueryLoader, + graphql, useFragment, usePreloadedQuery, GraphQLTaggedNode, PreloadedQuery, } from "react-relay"; import { unreachable } from "@opencast/appkit"; @@ -19,8 +19,7 @@ import { EmbedQuery } from "./__generated__/EmbedQuery.graphql"; import { EmbedDirectOpencastQuery } from "./__generated__/EmbedDirectOpencastQuery.graphql"; import { EmbedEventData$key } from "./__generated__/EmbedEventData.graphql"; import { PlayerContextProvider } from "../ui/player/PlayerContext"; -import { authorizedDataQuery, ProtectedPlayer } from "./Video"; -import { VideoAuthorizedDataQuery } from "./__generated__/VideoAuthorizedDataQuery.graphql"; +import { PreviewPlaceholder } from "./Video"; export const EmbedVideoRoute = makeRoute({ url: ({ videoId }: { videoId: string }) => `/~embed/!v/${keyOfId(videoId)}`, @@ -144,8 +143,6 @@ const Embed: React.FC<EmbedProps> = ({ query, queryRef }) => { fragmentRef.event, ); const { t } = useTranslation(); - const [queryReference, loadQuery] - = useQueryLoader<VideoAuthorizedDataQuery>(authorizedDataQuery); if (!event) { return <PlayerPlaceholder> @@ -177,11 +174,7 @@ const Embed: React.FC<EmbedProps> = ({ query, queryRef }) => { ...event, authorizedData: event.authorizedData, }} /> - : <ProtectedPlayer embedded {...{ - queryReference, - event, - loadQuery, - }}/>; + : <PreviewPlaceholder embedded {...{ event }}/>; }; export const BlockEmbedRoute = makeRoute({ diff --git a/frontend/src/routes/Video.tsx b/frontend/src/routes/Video.tsx index 4ccf5624a..ea822920e 100644 --- a/frontend/src/routes/Video.tsx +++ b/frontend/src/routes/Video.tsx @@ -1,15 +1,7 @@ import React, { ReactElement, ReactNode, useEffect, useRef, useState } from "react"; -import { - graphql, - GraphQLTaggedNode, - PreloadedQuery, - useFragment, - usePreloadedQuery, - useQueryLoader, - UseQueryLoaderLoadQueryOptions, -} from "react-relay/hooks"; +import { graphql, GraphQLTaggedNode, PreloadedQuery, useFragment } from "react-relay/hooks"; import { useTranslation } from "react-i18next"; -import { OperationType } from "relay-runtime"; +import { fetchQuery, OperationType } from "relay-runtime"; import { LuCode, LuDownload, LuInfo, LuLink, LuQrCode, LuRss, LuSettings, LuShare2, LuUnlock, } from "react-icons/lu"; @@ -20,12 +12,12 @@ import { } from "@opencast/appkit"; import { VideoObject, WithContext } from "schema-dts"; -import { loadQuery } from "../relay"; +import { environment, loadQuery } from "../relay"; import { InitialLoading, RootLoader } from "../layout/Root"; import { NotFound } from "./NotFound"; import { Nav } from "../layout/Navigation"; import { WaitingPage } from "../ui/Waiting"; -import { getPlayerAspectRatio, InlinePlayer, Player, PlayerPlaceholder } from "../ui/player"; +import { getPlayerAspectRatio, InlinePlayer, PlayerPlaceholder } from "../ui/player"; import { SeriesBlockFromSeries } from "../ui/Blocks/Series"; import { makeRoute, MatchedRoute } from "../rauta"; import { isValidRealmPath } from "./Realm"; @@ -85,7 +77,7 @@ import { PlaylistBlockFromPlaylist } from "../ui/Blocks/Playlist"; import { AuthenticationFormState, FormData, AuthenticationForm } from "./Login"; import { VideoAuthorizedDataQuery, - VideoAuthorizedDataQuery$variables, + VideoAuthorizedDataQuery$data, } from "./__generated__/VideoAuthorizedDataQuery.graphql"; import { AuthorizedBlockEvent } from "../ui/Blocks/Video"; @@ -440,7 +432,7 @@ export const authorizedDataQuery = graphql` $eventUser: String, $eventPassword: String, ) { - event: eventById(id: $eventId) { + authorizedEvent: eventById(id: $eventId) { ...on AuthorizedEvent { id authorizedData(user: $eventUser, password: $eventPassword) { @@ -471,8 +463,6 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath const rerender = useForceRerender(); const event = useFragment(eventFragment, eventRef); const realm = useFragment(realmFragment, realmRef); - const [queryReference, loadQuery] - = useQueryLoader<VideoAuthorizedDataQuery>(authorizedDataQuery); if (event.__typename === "NotAllowed") { return <ErrorPage title={t("api-remote-errors.view.event")} />; @@ -522,11 +512,7 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath css={{ margin: "-4px auto 0" }} onEventStateChange={rerender} /> - : <ProtectedPlayer {...{ - queryReference, - event, - loadQuery, - }}/> + : <PreviewPlaceholder {...{ event }}/> } <Metadata id={event.id} event={event} /> </PlayerContextProvider> @@ -551,81 +537,15 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath }; type ProtectedPlayerProps = { - queryReference?: PreloadedQuery<VideoAuthorizedDataQuery> | null; - loadQuery: ( - variables: VideoAuthorizedDataQuery$variables, - options?: UseQueryLoaderLoadQueryOptions, - ) => void; event: Event | AuthorizedBlockEvent; embedded?: boolean; } -export const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ - queryReference, - event, - loadQuery, - embedded, -}) => queryReference - ? <PlayerOrPreview {...{ - queryReference, - event, - loadQuery, - embedded, - }} /> - : <PreviewPlaceholder {...{ event, loadQuery, embedded }} />; - -export const CREDENTIALS_STORAGE_KEY = "tobira-video-credentials-"; - -const PlayerOrPreview: React.FC<ProtectedPlayerProps> = ({ - queryReference, - event, - loadQuery, - embedded, -}) => { - if (!queryReference) { - return null; - } - const data = usePreloadedQuery(authorizedDataQuery, queryReference); - const rerender = useForceRerender(); - - return data.event?.authorizedData && event.syncedData - ? (embedded - ? <Player event={{ - ...event, - syncedData: event.syncedData, - authorizedData: data.event.authorizedData, - }} /> - : <InlinePlayer - event={{ - ...event, - syncedData: event.syncedData, - authorizedData: data.event.authorizedData, - }} - css={{ margin: "-4px auto 0" }} - onEventStateChange={rerender} - /> - ) - : <PreviewPlaceholder - invalid={data.event?.authorizedData == null} - {...{ event, loadQuery, embedded }} - />; - -}; - -type PlaceholderProps = ProtectedPlayerProps & { - invalid?: boolean; -} - -const PreviewPlaceholder: React.FC<PlaceholderProps> = ({ - event, - loadQuery, - invalid, - embedded, -}) => { +export const PreviewPlaceholder: React.FC<ProtectedPlayerProps> = ({ event, embedded }) => { const { t } = useTranslation(); return event.hasPassword - ? <ProtectedVideoPlaceholder {...{ event, loadQuery, invalid, embedded }} /> + ? <ProtectedPlayer {...{ event, embedded }} /> : <div css={{ height: "unset" }}> <PlayerPlaceholder> <p css={{ @@ -640,16 +560,16 @@ const PreviewPlaceholder: React.FC<PlaceholderProps> = ({ </div>; }; -const ProtectedVideoPlaceholder: React.FC<PlaceholderProps> = ({ - event, - loadQuery, - invalid, - embedded, -}) => { +export type AuthorizedData = VideoAuthorizedDataQuery$data["authorizedEvent"]; +export const CREDENTIALS_STORAGE_KEY = "tobira-video-credentials-"; + +const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ event, embedded }) => { const { t } = useTranslation(undefined, { keyPrefix: "video.password" }); const isDark = useColorScheme().scheme === "dark"; const user = useUser(); - const [state, setState] = useState<AuthenticationFormState>("idle"); + const [authState, setAuthState] = useState<AuthenticationFormState>("idle"); + const [authError, setAuthError] = useState<string | null>(null); + const [authData, setAuthData] = useState<AuthorizedData | null>(null); const embeddedStyles = { height: "100%", @@ -658,111 +578,129 @@ const ProtectedVideoPlaceholder: React.FC<PlaceholderProps> = ({ }; const onSubmit = (data: FormData) => { - setState("pending"); - const credentials = JSON.stringify({ eventUser: data.userid, eventPassword: data.password, }); - // To make the authentication "sticky", the credentials are stored in browser storage. - // - // If the user is logged in, local storage is used so the browser remembers them as long - // as the user stays logged in. - // If the user is not logged in however, the session storage is used, which is reset when - // the current tab or window is closed. This way we can be relatively sure that the next - // user will need to enter the credentials again in order to access a protected video. - // - // Furthermore, since the video route can be accessed via both kinds, this needs to store - // both Tobira ID and Opencast ID. Both are queried when a video route is accessed, but the - // check for already stored credentials is done in the same query, when only the single ID - // from the url is known. - // The check will return a result for either ID regardless of its kind, as long as one of - // them is stored. - if (isRealUser(user)) { - window.localStorage - .setItem(CREDENTIALS_STORAGE_KEY + keyOfId(event.id), credentials); - window.localStorage - .setItem(CREDENTIALS_STORAGE_KEY + event.opencastId, credentials); - } else { - window.sessionStorage - .setItem(CREDENTIALS_STORAGE_KEY + keyOfId(event.id), credentials); - window.sessionStorage - .setItem(CREDENTIALS_STORAGE_KEY + event.opencastId, credentials); - } + fetchQuery<VideoAuthorizedDataQuery>(environment, authorizedDataQuery, { + eventId: event.id, + eventUser: data.userid, + eventPassword: data.password, + }).subscribe({ + start: () => setAuthState("pending"), + next: ({ authorizedEvent }) => { + if (!authorizedEvent?.authorizedData) { + setAuthError(t("invalid-credentials")); + setAuthState("idle"); + return; + } + setAuthError(null); + setAuthData({ authorizedData: authorizedEvent.authorizedData }); + setAuthState("success"); + + // To make the authentication "sticky", the credentials are stored in browser + // storage. If the user is logged in, local storage is used so the browser + // stores them as long as the user stays logged in. + // If the user is not logged in however, the session storage is used, which is + // reset when the current tab or window is closed. This way we can be relatively + // sure that the next user will need to enter the credentials again in order to + // access a protected video. + // + // Furthermore, since the video route can be accessed via both kinds, this needs to + // store both Tobira ID and Opencast ID. Both are queried when a video route is + // accessed, but the check for already stored credentials is done in the same + // query, when only the single ID from the url is known. + // 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); - loadQuery({ eventId: event.id, eventUser: data.userid, eventPassword: data.password }); + }, + error: (error: Error) => { + setAuthError(error.message); + setAuthState("idle"); + }, + }); }; - return ( - <div css={{ - display: "flex", - flexDirection: "column", - color: isDark ? COLORS.neutral80 : COLORS.neutral15, - backgroundColor: isDark ? COLORS.neutral15 : COLORS.neutral80, - [screenWidthAtMost(BREAKPOINT_MEDIUM)]: { - alignItems: "center", - }, - ...embedded && embeddedStyles, - }}> - <h2 css={{ - margin: 32, - marginBottom: 0, - [screenWidthAbove(BREAKPOINT_MEDIUM)]: { - textAlign: "left", - }, - }}>{t("heading")}</h2> + return authData?.authorizedData && event.syncedData + ? <InlinePlayer event={{ + ...event, + authorizedData: authData.authorizedData, + syncedData: event.syncedData, + }} /> + : ( <div css={{ display: "flex", + flexDirection: "column", + color: isDark ? COLORS.neutral80 : COLORS.neutral15, + backgroundColor: isDark ? COLORS.neutral15 : COLORS.neutral80, [screenWidthAtMost(BREAKPOINT_MEDIUM)]: { - flexDirection: "column-reverse", + alignItems: "center", }, + ...embedded && embeddedStyles, }}> + <h2 css={{ + margin: 32, + marginBottom: 0, + [screenWidthAbove(BREAKPOINT_MEDIUM)]: { + textAlign: "left", + }, + }}>{t("heading")}</h2> <div css={{ display: "flex", - flexDirection: "column", - alignItems: "center", + [screenWidthAtMost(BREAKPOINT_MEDIUM)]: { + flexDirection: "column-reverse", + }, }}> - <AuthenticationForm - {...{ onSubmit, state }} - error={null} - SubmitIcon={LuUnlock} - labels={{ - user: t("label.id"), - password: t("label.password"), - submit: t("label.submit"), - }} - css={{ - "&": { backgroundColor: "transparent" }, - margin: 0, - border: 0, - width: "unset", - minWidth: 300, - "div > label, div > input": { - ...!isDark && { - backgroundColor: COLORS.neutral15, - }, - }, - }} - /> - {invalid && ( - <Card - kind="error" - iconPos="left" + <div css={{ + display: "flex", + flexDirection: "column", + alignItems: "center", + }}> + <AuthenticationForm + {...{ onSubmit }} + state={authState} + error={null} + SubmitIcon={LuUnlock} + labels={{ + user: t("label.id"), + password: t("label.password"), + submit: t("label.submit"), + }} css={{ - width: "fit-content", - marginBottom: 32, + "&": { backgroundColor: "transparent" }, + margin: 0, + border: 0, + width: "unset", + minWidth: 300, + "div > label, div > input": { + ...!isDark && { + backgroundColor: COLORS.neutral15, + }, + }, }} - > - {t("invalid-credentials")} - </Card> - )} + /> + {authError && ( + <Card + kind="error" + iconPos="left" + css={{ + width: "fit-content", + marginBottom: 32, + }} + > + {authError} + </Card> + )} + </div> + <AuthenticationFormText /> </div> - <AuthenticationFormText /> </div> - </div> - ); + ); }; const AuthenticationFormText: React.FC = () => { diff --git a/frontend/src/ui/Blocks/Video.tsx b/frontend/src/ui/Blocks/Video.tsx index 0af2ec0a3..47d6d28e7 100644 --- a/frontend/src/ui/Blocks/Video.tsx +++ b/frontend/src/ui/Blocks/Video.tsx @@ -1,4 +1,4 @@ -import { graphql, useFragment, useQueryLoader } from "react-relay"; +import { graphql, useFragment } from "react-relay"; import { Card, unreachable } from "@opencast/appkit"; import { InlinePlayer } from "../player"; @@ -9,10 +9,7 @@ import { isSynced, keyOfId, useAuthenticatedDataQuery } from "../../util"; import { Link } from "../../router"; import { LuArrowRightCircle } from "react-icons/lu"; import { PlayerContextProvider } from "../player/PlayerContext"; -import { - VideoAuthorizedDataQuery, -} from "../../routes/__generated__/VideoAuthorizedDataQuery.graphql"; -import { authorizedDataQuery, ProtectedPlayer } from "../../routes/Video"; +import { PreviewPlaceholder } from "../../routes/Video"; export type BlockEvent = VideoBlockData$data["event"]; @@ -25,8 +22,6 @@ type Props = { export const VideoBlock: React.FC<Props> = ({ fragRef, basePath }) => { const { t } = useTranslation(); - const [queryReference, loadQuery] - = useQueryLoader<VideoAuthorizedDataQuery>(authorizedDataQuery); const { event, showTitle, showLink } = useFragment(graphql` fragment VideoBlockData on VideoBlock { event { @@ -75,7 +70,8 @@ export const VideoBlock: React.FC<Props> = ({ fragRef, basePath }) => { } const authenticatedData = useAuthenticatedDataQuery(keyOfId(event.id)); - const authorizedData = event.authorizedData ?? authenticatedData.event?.authorizedData; + const authorizedData = event.authorizedData + ?? authenticatedData.authorizedEvent?.authorizedData; return <div css={{ maxWidth: 800 }}> @@ -89,11 +85,7 @@ export const VideoBlock: React.FC<Props> = ({ fragRef, basePath }) => { }} css={{ margin: "-4px auto 0" }} /> - : <ProtectedPlayer {...{ - queryReference, - event, - loadQuery, - }} /> + : <PreviewPlaceholder {...{ event }} /> } </PlayerContextProvider> diff --git a/frontend/src/ui/Video.tsx b/frontend/src/ui/Video.tsx index 1c1ad72f9..d0e45c513 100644 --- a/frontend/src/ui/Video.tsx +++ b/frontend/src/ui/Video.tsx @@ -55,7 +55,7 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ const isDark = useColorScheme().scheme === "dark"; const authenticatedData = useAuthenticatedDataQuery(keyOfId(event.id)); const authorizedThumbnail = event.authorizedData?.thumbnail - ?? authenticatedData.event?.authorizedData?.thumbnail; + ?? authenticatedData.authorizedEvent?.authorizedData?.thumbnail; const isUpcoming = isUpcomingLiveEvent(event.syncedData?.startTime ?? null, event.isLive); const audioOnly = event.authorizedData ? ( diff --git a/frontend/src/ui/player/index.tsx b/frontend/src/ui/player/index.tsx index b02cbdeb1..78b0f3a6d 100644 --- a/frontend/src/ui/player/index.tsx +++ b/frontend/src/ui/player/index.tsx @@ -127,6 +127,9 @@ const delayTill = (date: Date): number => { * in order to work correctly. */ export const InlinePlayer: React.FC<PlayerProps> = ({ className, event, ...playerProps }) => { + if (!event.authorizedData) { + return null; + } const aspectRatio = getPlayerAspectRatio(event.authorizedData.tracks); const isDark = useColorScheme().scheme === "dark"; const ref = useRef<HTMLDivElement>(null); From f30f21d6e919be5fe7dc2426e0f712308d59d4ed Mon Sep 17 00:00:00 2001 From: Ole Wieners <olewieners@yahoo.com> Date: Sat, 19 Oct 2024 12:55:55 +0200 Subject: [PATCH 13/26] 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<EmbedQuery>(query, { id, - ...getCredentials("event" + id), + ...getCredentials("event", id), }); @@ -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); @@ -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<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 }}/>; }; 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<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}>{{ @@ -544,6 +546,9 @@ const SearchEvent: React.FC<EventItem> = ({ 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<VideoPageDirectLinkQuery>(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<VideoPageDirectOpencastLinkQuery>(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<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; @@ -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} /> @@ -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); 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<EditVideoBlockProps> = ({ block: blockRef ... on AuthorizedEvent { id title - series { title } + series { id title } created isLive creators @@ -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}> @@ -138,6 +143,7 @@ const EventSelector: React.FC<EventSelectorProps> = ({ onChange, onBlur, default items { id title + seriesId seriesTitle creators thumbnail @@ -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: () => {}, 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<Props> = ({ fragRef, basePath }) => { description canWrite hasPassword - series { title opencastId } + series { title id opencastId } syncedData { duration updated @@ -69,9 +69,11 @@ 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 }}> @@ -79,10 +81,7 @@ export const VideoBlock: React.FC<Props> = ({ fragRef, basePath }) => { <PlayerContextProvider> {authorizedData && isSynced(event) ? <InlinePlayer - event={{ - ...event, - authorizedData, - }} + event={{ ...event, authorizedData }} css={{ margin: "-4px auto 0" }} /> : <PreviewPlaceholder {...{ 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<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 ? ( 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<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; From 35e0a07b3ebfb165893bc0a01fe906a98915e1a6 Mon Sep 17 00:00:00 2001 From: Ole Wieners <olewieners@yahoo.com> Date: Sun, 20 Oct 2024 15:44:15 +0200 Subject: [PATCH 14/26] Add authenticated data context to video pages and realms Before this change, authenticating a video on video pages or in video blocks would not rerender series blocks, i.e. the thumbnails would not reflect the fact that the videos of these blocks were unlocked. This adds a shared state through context so these blocks are now rerendered and correctly show the videos as unlocked. --- frontend/src/routes/Embed.tsx | 9 ++-- frontend/src/routes/Realm.tsx | 10 ++-- frontend/src/routes/Video.tsx | 91 +++++++++++++++++++++++------------ 3 files changed, 72 insertions(+), 38 deletions(-) diff --git a/frontend/src/routes/Embed.tsx b/frontend/src/routes/Embed.tsx index 3a6f1930d..b389664a5 100644 --- a/frontend/src/routes/Embed.tsx +++ b/frontend/src/routes/Embed.tsx @@ -1,4 +1,4 @@ -import { ReactNode, Suspense } from "react"; +import { ReactNode, Suspense, useState } from "react"; import { LuFrown, LuAlertTriangle } from "react-icons/lu"; import { Translation, useTranslation } from "react-i18next"; import { @@ -19,7 +19,7 @@ import { EmbedQuery } from "./__generated__/EmbedQuery.graphql"; import { EmbedDirectOpencastQuery } from "./__generated__/EmbedDirectOpencastQuery.graphql"; import { EmbedEventData$key } from "./__generated__/EmbedEventData.graphql"; import { PlayerContextProvider } from "../ui/player/PlayerContext"; -import { PreviewPlaceholder } from "./Video"; +import { AuthenticatedDataContext, AuthorizedData, PreviewPlaceholder } from "./Video"; export const EmbedVideoRoute = makeRoute({ url: ({ videoId }: { videoId: string }) => `/~embed/!v/${keyOfId(videoId)}`, @@ -143,6 +143,7 @@ const Embed: React.FC<EmbedProps> = ({ query, queryRef }) => { fragmentRef.event, ); const { t } = useTranslation(); + const [authenticatedData, setAuthenticatedData] = useState<AuthorizedData | null>(null); if (!event) { return <PlayerPlaceholder> @@ -177,7 +178,9 @@ const Embed: React.FC<EmbedProps> = ({ query, queryRef }) => { return authorizedData ? <Player event={{ ...event, authorizedData }} /> - : <PreviewPlaceholder embedded {...{ event }}/>; + : <AuthenticatedDataContext.Provider value={{ authenticatedData, setAuthenticatedData }}> + <PreviewPlaceholder embedded {...{ event }}/>; + </AuthenticatedDataContext.Provider>; }; export const BlockEmbedRoute = makeRoute({ diff --git a/frontend/src/routes/Realm.tsx b/frontend/src/routes/Realm.tsx index d0faed771..306447f2f 100644 --- a/frontend/src/routes/Realm.tsx +++ b/frontend/src/routes/Realm.tsx @@ -27,6 +27,7 @@ import { COLORS } from "../color"; import { useMenu } from "../layout/MenuState"; import { ManageNav } from "./manage"; import { BREAKPOINT as NAV_BREAKPOINT } from "../layout/Navigation"; +import { AuthorizedData, AuthenticatedDataContext } from "./Video"; // eslint-disable-next-line @typescript-eslint/quotes @@ -145,6 +146,7 @@ const RealmPage: React.FC<Props> = ({ realm }) => { const { t } = useTranslation(); const siteTitle = useTranslatedConfig(CONFIG.siteTitle); const breadcrumbs = realmBreadcrumbs(t, realm.ancestors); + const [authenticatedData, setAuthenticatedData] = useState<AuthorizedData | null>(null); const title = realm.isMainRoot ? siteTitle : realm.name; useTitle(title, realm.isMainRoot); @@ -166,9 +168,11 @@ const RealmPage: React.FC<Props> = ({ realm }) => { {realm.isUserRealm && <UserRealmNote realm={realm} />} </div> )} - {realm.blocks.length === 0 && realm.isMainRoot - ? <WelcomeMessage /> - : <Blocks realm={realm} />} + <AuthenticatedDataContext.Provider value={{ authenticatedData, setAuthenticatedData }}> + {realm.blocks.length === 0 && realm.isMainRoot + ? <WelcomeMessage /> + : <Blocks realm={realm} />} + </AuthenticatedDataContext.Provider> </>; }; diff --git a/frontend/src/routes/Video.tsx b/frontend/src/routes/Video.tsx index bb9cc950c..5fb9e06d7 100644 --- a/frontend/src/routes/Video.tsx +++ b/frontend/src/routes/Video.tsx @@ -1,4 +1,14 @@ -import React, { ReactElement, ReactNode, useEffect, useRef, useState } from "react"; +import React, { + createContext, + Dispatch, + ReactElement, + ReactNode, + SetStateAction, + useContext, + useEffect, + useRef, + useState, +} from "react"; import { graphql, GraphQLTaggedNode, PreloadedQuery, useFragment } from "react-relay/hooks"; import { useTranslation } from "react-i18next"; import { fetchQuery, OperationType } from "relay-runtime"; @@ -9,6 +19,7 @@ import { QRCodeCanvas } from "qrcode.react"; import { match, unreachable, screenWidthAtMost, screenWidthAbove, useColorScheme, Floating, FloatingContainer, FloatingTrigger, WithTooltip, Card, Button, ProtoButton, + bug, } from "@opencast/appkit"; import { VideoObject, WithContext } from "schema-dts"; @@ -453,6 +464,13 @@ export const authorizedDataQuery = graphql` // ===== Components // =========================================================================================== +export type AuthorizedData = VideoAuthorizedDataQuery$data["authorizedEvent"]; +type AuthenticatedDataContext = { + authenticatedData: AuthorizedData; + setAuthenticatedData: Dispatch<SetStateAction<AuthorizedData>>; +} +export const AuthenticatedDataContext = createContext<AuthenticatedDataContext | null>(null); + type Props = { eventRef: NonNullable<VideoPageEventData$key>; realmRef: NonNullable<VideoPageRealmData$key>; @@ -465,6 +483,7 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath const rerender = useForceRerender(); const event = useFragment(eventFragment, eventRef); const realm = useFragment(realmFragment, realmRef); + const [authenticatedData, setAuthenticatedData] = useState<AuthorizedData | null>(null); if (event.__typename === "NotAllowed") { return <ErrorPage title={t("api-remote-errors.view.event")} />; @@ -520,34 +539,36 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath return <> <Breadcrumbs path={breadcrumbs} tail={event.title} /> <script type="application/ld+json">{JSON.stringify(structuredData)}</script> - <PlayerContextProvider> - {authorizedData - ? <InlinePlayer - event={{ ...event, authorizedData }} - css={{ margin: "-4px auto 0" }} - onEventStateChange={rerender} - /> - : <PreviewPlaceholder {...{ event }}/> - } - <Metadata id={event.id} event={event} /> - </PlayerContextProvider> + <AuthenticatedDataContext.Provider value={{ authenticatedData, setAuthenticatedData }}> + <PlayerContextProvider> + {authorizedData + ? <InlinePlayer + event={{ ...event, authorizedData }} + css={{ margin: "-4px auto 0" }} + onEventStateChange={rerender} + /> + : <PreviewPlaceholder {...{ event }}/> + } + <Metadata id={event.id} event={event} /> + </PlayerContextProvider> - <div css={{ height: 80 }} /> + <div css={{ height: 80 }} /> - {playlistRef - ? <PlaylistBlockFromPlaylist - moreOfTitle - basePath={basePath} - fragRef={playlistRef} - activeEventId={event.id} - /> - : event.series && <SeriesBlockFromSeries - basePath={basePath} - fragRef={event.series} - title={t("video.more-from-series", { series: event.series.title })} - activeEventId={event.id} - /> - } + {playlistRef + ? <PlaylistBlockFromPlaylist + moreOfTitle + basePath={basePath} + fragRef={playlistRef} + activeEventId={event.id} + /> + : event.series && <SeriesBlockFromSeries + basePath={basePath} + fragRef={event.series} + title={t("video.more-from-series", { series: event.series.title })} + activeEventId={event.id} + /> + } + </AuthenticatedDataContext.Provider> </>; }; @@ -575,7 +596,6 @@ export const PreviewPlaceholder: React.FC<ProtectedPlayerProps> = ({ event, embe </div>; }; -export type AuthorizedData = VideoAuthorizedDataQuery$data["authorizedEvent"]; export const CREDENTIALS_STORAGE_KEY = "tobira-video-credentials-"; const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ event, embedded }) => { @@ -584,7 +604,7 @@ const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ event, embedded }) => const user = useUser(); const [authState, setAuthState] = useState<AuthenticationFormState>("idle"); const [authError, setAuthError] = useState<string | null>(null); - const [authData, setAuthData] = useState<AuthorizedData | null>(null); + const authenticatedDataContext = useContext(AuthenticatedDataContext); const embeddedStyles = { height: "100%", @@ -611,8 +631,15 @@ const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ event, embedded }) => return; } + if (authenticatedDataContext) { + authenticatedDataContext.setAuthenticatedData({ + authorizedData: authorizedEvent.authorizedData, + }); + } else { + bug("Authenticated data context is not initialized"); + } + setAuthError(null); - setAuthData({ authorizedData: authorizedEvent.authorizedData }); setAuthState("success"); // To make the authentication "sticky", the credentials are stored in browser @@ -646,10 +673,10 @@ const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ event, embedded }) => }); }; - return authData?.authorizedData && event.syncedData + return authenticatedDataContext?.authenticatedData?.authorizedData && event.syncedData ? <InlinePlayer event={{ ...event, - authorizedData: authData.authorizedData, + authorizedData: authenticatedDataContext.authenticatedData.authorizedData, syncedData: event.syncedData, }} /> : ( From 176fa0b3962e7708ee941b2e9378185a62a1fce5 Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt <lukas.kalbertodt@gmail.com> Date: Tue, 12 Nov 2024 11:33:39 +0100 Subject: [PATCH 15/26] Stop merging `read_roles` into `preview_roles` See https://github.com/elan-ev/tobira/pull/1244#discussion_r1783044285 I thought quite a bit about this and I think both options would have been fine, but I decided to change this for one reason in particular: currently, `read_roles` and `write_roles` reflect exactly what Opencast tells us. We of course also have columns with calculated values, but we should keep it fairly clear what columns are straight from OC and which ones are "our columns". Most importantly: if we ever would need to distinguish between read and preview, this would require a resync. This would be unfortunate, so I'd rather store the exact OC information so that we have it, just in case. I then decided to just adjust the few checks to check both columns, but we could also have added another column that holds the combination of the two. That's an implementation detail and we can still change it later. But yes, the more complicated checks don't seem like a problem to me. --- backend/src/api/model/event.rs | 12 ++++--- backend/src/api/model/search/mod.rs | 34 ++++++++++++------- .../39-event-preview-roles-and-password.sql | 23 ++----------- frontend/src/schema.graphql | 5 +-- 4 files changed, 32 insertions(+), 42 deletions(-) diff --git a/backend/src/api/model/event.rs b/backend/src/api/model/event.rs index 1bae88e02..0c6a4a9cc 100644 --- a/backend/src/api/model/event.rs +++ b/backend/src/api/model/event.rs @@ -219,8 +219,7 @@ impl AuthorizedEvent { fn write_roles(&self) -> &[String] { &self.write_roles } - /// This includes all read roles (and by extension write roles, - /// as they are a subset of read roles). + /// This doesn't contain `ROLE_ADMIN` as that is included implicitly. fn preview_roles(&self) -> &[String] { &self.preview_roles } @@ -365,7 +364,7 @@ impl AuthorizedEvent { .await? .map(|row| { let event = Self::from_row_start(&row); - if context.auth.overlaps_roles(&event.preview_roles) { + if event.can_be_previewed(context) { Event::Event(event) } else { Event::NotAllowed(NotAllowed) @@ -386,7 +385,7 @@ impl AuthorizedEvent { context.db .query_mapped(&query, dbargs![&series_key], |row| { let event = Self::from_row_start(&row); - if !context.auth.overlaps_roles(&event.preview_roles) { + if !event.can_be_previewed(context) { return VideoListEntry::NotAllowed(NotAllowed); } @@ -396,6 +395,11 @@ impl AuthorizedEvent { .pipe(Ok) } + fn can_be_previewed(&self, context: &Context) -> bool { + context.auth.overlaps_roles(&self.preview_roles) + || context.auth.overlaps_roles(&self.read_roles) + } + pub(crate) async fn delete(id: Id, context: &Context) -> ApiResult<RemovedEvent> { let event = Self::load_by_id(id, context) .await? diff --git a/backend/src/api/model/search/mod.rs b/backend/src/api/model/search/mod.rs index ace73503a..a7fdf41f2 100644 --- a/backend/src/api/model/search/mod.rs +++ b/backend/src/api/model/search/mod.rs @@ -166,7 +166,7 @@ pub(crate) async fn perform( let selection = search::Event::select(); let query = format!("select {selection} from search_events \ where id = (select id from events where opencast_id = $1) \ - and (preview_roles || 'ROLE_ADMIN'::text) && $2"); + and (preview_roles || read_roles || 'ROLE_ADMIN'::text) && $2"); let items: Vec<NodeValue> = context.db .query_opt(&query, &[&uuid_query, &context.auth.roles_vec()]) .await? @@ -188,7 +188,10 @@ pub(crate) async fn perform( // Prepare the event search let filter = Filter::And( std::iter::once(Filter::Leaf("listed = true".into())) - .chain(acl_filter("preview_roles", context)) + .chain([Filter::Or([ + acl_filter("preview_roles", context), + acl_filter("read_roles", context), + ].into_iter().flatten().collect())]) // Filter out live events that are already over. .chain([Filter::Or([ Filter::Leaf("is_live = false ".into()), @@ -332,7 +335,13 @@ pub(crate) async fn all_events( if writable_only { writable } else { - Filter::or([Filter::listed_and_readable("preview_roles", context), writable]) + Filter::or([ + Filter::or([ + Filter::acl_access("read_roles", context), + Filter::acl_access("preview_roles", context), + ]).and_listed(context), + writable, + ]) } }).to_string(); @@ -429,7 +438,10 @@ pub(crate) async fn all_playlists( if writable_only { writable } else { - Filter::or([Filter::listed_and_readable("read_roles", context), writable]) + Filter::or([ + Filter::acl_access("read_roles", context).and_listed(context), + writable, + ]) } }).to_string(); @@ -489,17 +501,13 @@ impl Filter { Self::Leaf("listed = true".into()) } - /// Returns a filter checking that the current user has read access and that - /// the item is listed. If the user has the privilege to find unlisted - /// item, the second check is not performed. - /// "Readable" in this context can mean either "preview-able" in case of events - /// or actual "readable" in case of playlists, as they do not have preview roles. - fn listed_and_readable(roles_field: &str, context: &Context) -> Self { - let readable = Self::acl_access(roles_field, context); + /// If the user can find unlisted items, just returns `self`. Otherweise, + /// `self` is ANDed with `Self::listed()`. + fn and_listed(self, context: &Context) -> Self { if context.auth.can_find_unlisted_items(&context.config.auth) { - readable + self } else { - Self::and([readable, Self::listed()]) + Self::and([self, Self::listed()]) } } diff --git a/backend/src/db/migrations/39-event-preview-roles-and-password.sql b/backend/src/db/migrations/39-event-preview-roles-and-password.sql index e19141cbc..0e3b3754d 100644 --- a/backend/src/db/migrations/39-event-preview-roles-and-password.sql +++ b/backend/src/db/migrations/39-event-preview-roles-and-password.sql @@ -14,30 +14,11 @@ alter table all_events add column credentials credentials, add column preview_roles text[] not null default '{}'; --- For convenience, all read roles are also copied over to preview roles. --- Removing any roles from read will however _not_ remove them from preview, as they --- might also have been added separately and we can't really account for that. -update all_events set preview_roles = read_roles; -create function sync_preview_roles() -returns trigger language plpgsql as $$ -begin - new.preview_roles := ( - select array_agg(distinct role) from unnest(new.preview_roles || new.read_roles) as role - ); - return new; -end; -$$; - -create trigger sync_preview_roles_on_change -before insert or update of read_roles, preview_roles on all_events -for each row -execute function sync_preview_roles(); - --- replace outdated view to include preview_roles +-- replace outdated view to include new columnes create or replace view events as select * from all_events where tobira_deletion_timestamp is null; --- add `preview_roles` to `search_events` view as well +-- add `preview_roles` and `has_password` to `search_events` view drop view search_events; create view search_events as select diff --git a/frontend/src/schema.graphql b/frontend/src/schema.graphql index 9868e8177..c5527ca67 100644 --- a/frontend/src/schema.graphql +++ b/frontend/src/schema.graphql @@ -315,10 +315,7 @@ type AuthorizedEvent implements Node { readRoles: [String!]! "This doesn't contain `ROLE_ADMIN` as that is included implicitly." writeRoles: [String!]! - """ - This includes all read roles (and by extension write roles, - as they are a subset of read roles). - """ + "This doesn't contain `ROLE_ADMIN` as that is included implicitly." previewRoles: [String!]! syncedData: SyncedEventData "Returns the authorized event data if the user has read access or is authenticated for the event." From 34b61d7be9b300bb9c07bad895dfc593ea949892 Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt <lukas.kalbertodt@gmail.com> Date: Wed, 13 Nov 2024 13:58:51 +0100 Subject: [PATCH 16/26] Rework `Filter` helper in search The `perform` method still used lots of manual filter creation, which is now replaced. I also replaced `Filter::None` with `Filter::True`, which more precisely describes its role. Before, one could easily run into a bug where `None`s from `or` rules are just filtered out, but in all cases of `None` usage, it should rather make the whole `or` evaluate to `true`. Finally, three more helper functions were added to avoid repeating `*_roles` a bunch of times. --- backend/src/api/model/search/mod.rs | 120 ++++++++++++++++------------ 1 file changed, 69 insertions(+), 51 deletions(-) diff --git a/backend/src/api/model/search/mod.rs b/backend/src/api/model/search/mod.rs index a7fdf41f2..f92c58ffb 100644 --- a/backend/src/api/model/search/mod.rs +++ b/backend/src/api/model/search/mod.rs @@ -186,21 +186,22 @@ pub(crate) async fn perform( // Prepare the event search - let filter = Filter::And( - std::iter::once(Filter::Leaf("listed = true".into())) - .chain([Filter::Or([ - acl_filter("preview_roles", context), - acl_filter("read_roles", context), - ].into_iter().flatten().collect())]) - // Filter out live events that are already over. - .chain([Filter::Or([ - Filter::Leaf("is_live = false ".into()), - Filter::Leaf(format!("end_time_timestamp >= {}", Utc::now().timestamp()).into()), - ].into())]) - .chain(filters.start.map(|start| Filter::Leaf(format!("created_timestamp >= {}", start.timestamp()).into()))) - .chain(filters.end.map(|end| Filter::Leaf(format!("created_timestamp <= {}", end.timestamp()).into()))) - .collect() - ).to_string(); + let filter = Filter::and([ + Filter::listed(), + Filter::preview_or_read_access(context), + // Filter out live events that already ended + Filter::or([ + Filter::Leaf("is_live = false ".into()), + Filter::Leaf(format!("end_time_timestamp >= {}", Utc::now().timestamp()).into()), + ]), + // Apply user selected date filters + filters.start + .map(|start| Filter::Leaf(format!("created_timestamp >= {}", start.timestamp()).into())) + .unwrap_or(Filter::True), + filters.end + .map(|end| Filter::Leaf(format!("created_timestamp <= {}", end.timestamp()).into())) + .unwrap_or(Filter::True), + ]).to_string(); let event_query = context.search.event_index.search() .with_query(user_query) .with_limit(15) @@ -327,19 +328,16 @@ pub(crate) async fn all_events( return Err(context.not_logged_in_error()); } - let filter = Filter::make_or_none_for_admins(context, || { + let filter = Filter::make_or_true_for_admins(context, || { // All users can always find all events they have write access to. If // `writable_only` is false, this API also returns events that are // listed and that the user can read. - let writable = Filter::acl_access("write_roles", context); + let writable = Filter::write_access(context); if writable_only { writable } else { Filter::or([ - Filter::or([ - Filter::acl_access("read_roles", context), - Filter::acl_access("preview_roles", context), - ]).and_listed(context), + Filter::preview_or_read_access(context).and_listed(context), writable, ]) } @@ -379,8 +377,8 @@ pub(crate) async fn all_series( return Err(context.not_logged_in_error()); } - let filter = Filter::make_or_none_for_admins(context, || { - let writable = Filter::acl_access("write_roles", context); + let filter = Filter::make_or_true_for_admins(context, || { + let writable = Filter::write_access(context); // All users can always find all items they have write access to, // regardless whether they are listed or not. @@ -391,7 +389,7 @@ pub(crate) async fn all_series( // Since series read_roles are not used for access control, we only need // to check whether we can return unlisted videos. if context.auth.can_find_unlisted_items(&context.config.auth) { - Filter::None + Filter::True } else { Filter::or([writable, Filter::listed()]) } @@ -430,16 +428,16 @@ pub(crate) async fn all_playlists( return Err(context.not_logged_in_error()); } - let filter = Filter::make_or_none_for_admins(context, || { + let filter = Filter::make_or_true_for_admins(context, || { // All users can always find all playlists they have write access to. If // `writable_only` is false, this API also returns playlists that are // listed and that the user can read. - let writable = Filter::acl_access("write_roles", context); + let writable = Filter::write_access(context); if writable_only { writable } else { Filter::or([ - Filter::acl_access("read_roles", context).and_listed(context), + Filter::read_access(context).and_listed(context), writable, ]) } @@ -461,39 +459,46 @@ pub(crate) async fn all_playlists( Ok(PlaylistSearchOutcome::Results(SearchResults { items, total_hits, duration: elapsed_time() })) } -// TODO: replace usages of this and remove this. -fn acl_filter(action: &str, context: &Context) -> Option<Filter> { - // If the user is admin, we just skip the filter alltogether as the admin - // can see anything anyway. - if context.auth.is_admin() { - return None; - } - - Some(Filter::acl_access(action, context)) -} enum Filter { // TODO: try to avoid Vec if not necessary. Oftentimes there are only two operands. + + /// Must not contain `Filter::None`, which is handled by `Filter::and`. And(Vec<Filter>), + + /// Must not contain `Filter::None`, which is handled by `Filter::or`. Or(Vec<Filter>), Leaf(Cow<'static, str>), - /// No filter. Formats to empty string and is filtered out if inside the - /// `And` or `Or` operands. - None, + /// A constant `true`. Inside `Or`, results in the whole `Or` expression + /// being replaced by `True`. Inside `And`, this is just filtered out and + /// the remaining operands are evaluated. If formated on its own, empty + /// string is emitted. + True, } impl Filter { - fn make_or_none_for_admins(context: &Context, f: impl FnOnce() -> Self) -> Self { - if context.auth.is_admin() { Self::None } else { f() } + fn make_or_true_for_admins(context: &Context, f: impl FnOnce() -> Self) -> Self { + if context.auth.is_admin() { Self::True } else { f() } } fn or(operands: impl IntoIterator<Item = Self>) -> Self { - Self::Or(operands.into_iter().collect()) + let mut v = Vec::new(); + for op in operands { + if matches!(op, Self::True) { + return Self::True; + } + v.push(op); + } + Self::Or(v) } fn and(operands: impl IntoIterator<Item = Self>) -> Self { - Self::And(operands.into_iter().collect()) + Self::And( + operands.into_iter() + .filter(|op| !matches!(op, Self::True)) + .collect(), + ) } /// Returns the filter "listed = true". @@ -511,10 +516,25 @@ impl Filter { } } + fn read_access(context: &Context) -> Self { + Self::make_or_true_for_admins(context, || Self::acl_access_raw("read_roles", context)) + } + + fn write_access(context: &Context) -> Self { + Self::make_or_true_for_admins(context, || Self::acl_access_raw("write_roles", context)) + } + + fn preview_or_read_access(context: &Context) -> Self { + Self::make_or_true_for_admins(context, || Self::or([ + Self::acl_access_raw("read_roles", context), + Self::acl_access_raw("preview_roles", context), + ])) + } + /// Returns a filter checking if `roles_field` has any overlap with the /// current user roles. Encodes all roles as hex to work around Meili's - /// lack of case-sensitive comparison. - fn acl_access(roles_field: &str, context: &Context) -> Self { + /// lack of case-sensitive comparison. Does not handle the ROLE_ADMIN case. + fn acl_access_raw(roles_field: &str, context: &Context) -> Self { use std::io::Write; const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef"; @@ -541,10 +561,8 @@ impl Filter { impl fmt::Display for Filter { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fn join(f: &mut fmt::Formatter, operands: &[Filter], sep: &str) -> fmt::Result { - if operands.iter().all(|op| matches!(op, Filter::None)) { - return Ok(()); - } - + // We are guaranteed by `and` and `or` methods that there are no + // `Self::True`s in here. write!(f, "(")?; for (i, operand) in operands.iter().enumerate() { if i > 0 { @@ -559,7 +577,7 @@ impl fmt::Display for Filter { Self::And(operands) => join(f, operands, "AND"), Self::Or(operands) => join(f, operands, "OR"), Self::Leaf(s) => write!(f, "{s}"), - Self::None => Ok(()), + Self::True => Ok(()), } } } From a0d82b3d766a03e25330592c20054f97dc172ee4 Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt <lukas.kalbertodt@gmail.com> Date: Thu, 14 Nov 2024 19:01:32 +0100 Subject: [PATCH 17/26] Remove DB trigger to transfer series credentials to event From a recent meeting, we concluded that we should just do what the event ACL tell us. The admins need to make sure the effective (i.e. after applying merge mode) event ACLs contain the password roles, if that video should be password protected. Even if that requirement still changes in the future, I think it should be done in the harvest code, not as a DB trigger. Further, just always transferring series credentials to events is also wrong in our more flexible model (that should cover more use cases than the ETH). Finally, the trigger as written was not complete as it was missing the "read roles become preview roles" logic, which is already present in code. I decided to still store series credentials, as that's useful for a few reasons, e.g. implementing this feature later on without resyncing. But this tiny SQL command didn't warrant its own migration, so I combined both. --- backend/src/db/migrations.rs | 3 +- ...l => 39-preview-roles-and-credentials.sql} | 3 ++ .../migrations/40-eth-series-credentials.sql | 37 ------------------- 3 files changed, 4 insertions(+), 39 deletions(-) rename backend/src/db/migrations/{39-event-preview-roles-and-password.sql => 39-preview-roles-and-credentials.sql} (97%) delete mode 100644 backend/src/db/migrations/40-eth-series-credentials.sql diff --git a/backend/src/db/migrations.rs b/backend/src/db/migrations.rs index 4f6328413..74b73c47d 100644 --- a/backend/src/db/migrations.rs +++ b/backend/src/db/migrations.rs @@ -371,6 +371,5 @@ static MIGRATIONS: Lazy<BTreeMap<u64, Migration>> = include_migrations![ 36: "playlist-blocks", 37: "redo-search-triggers-and-listed", 38: "event-texts", - 39: "event-preview-roles-and-password", - 40: "eth-series-credentials", + 39: "preview-roles-and-credentials", ]; diff --git a/backend/src/db/migrations/39-event-preview-roles-and-password.sql b/backend/src/db/migrations/39-preview-roles-and-credentials.sql similarity index 97% rename from backend/src/db/migrations/39-event-preview-roles-and-password.sql rename to backend/src/db/migrations/39-preview-roles-and-credentials.sql index 0e3b3754d..565f53b39 100644 --- a/backend/src/db/migrations/39-event-preview-roles-and-password.sql +++ b/backend/src/db/migrations/39-preview-roles-and-credentials.sql @@ -14,6 +14,9 @@ alter table all_events add column credentials credentials, add column preview_roles text[] not null default '{}'; +alter table series + add column credentials credentials; + -- replace outdated view to include new columnes create or replace view events as select * from all_events where tobira_deletion_timestamp is null; diff --git a/backend/src/db/migrations/40-eth-series-credentials.sql b/backend/src/db/migrations/40-eth-series-credentials.sql deleted file mode 100644 index 9719ccc29..000000000 --- a/backend/src/db/migrations/40-eth-series-credentials.sql +++ /dev/null @@ -1,37 +0,0 @@ --- Adds a credentials column to series that hold series specific passwords and usernames. --- These need to be synced with events that are part of the series. --- This is specific for authentication requirements of the ETH and is only useful when the --- `interpret_eth_passwords` configuration is enabled. - -alter table series add column credentials credentials; - --- When the credentials of a series change, each event that is part of it also needs to be updated. -create function sync_series_credentials() returns trigger language plpgsql as $$ -begin - update all_events set credentials = series.credentials - from series where all_events.series = series.id and series.id = new.id; - return new; -end; -$$; - -create trigger sync_series_credentials_on_change -after update on series -for each row -when (old.credentials is distinct from new.credentials) -execute function sync_series_credentials(); - --- Tobira uploads do not automatically get the credentials of their assigned series, so this needs --- to be done with an additional function and a trigger. -create function sync_credentials_before_event_insert() returns trigger language plpgsql as $$ -begin - select series.credentials into new.credentials - from series - where series.id = new.series; - return new; -end; -$$; - -create trigger sync_series_credentials_before_event_insert -before insert on all_events -for each row -execute function sync_credentials_before_event_insert(); From 8e7d50e5ee79eda0ffcb67c7f22d67ac0bc5077f Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt <lukas.kalbertodt@gmail.com> Date: Thu, 14 Nov 2024 11:57:57 +0100 Subject: [PATCH 18/26] Stop sending search text matches for videos with only preview access --- backend/src/api/model/search/event.rs | 46 ++++++++++++--------------- frontend/src/routes/Search.tsx | 10 +----- 2 files changed, 22 insertions(+), 34 deletions(-) diff --git a/backend/src/api/model/search/event.rs b/backend/src/api/model/search/event.rs index c3a4211b0..c92214000 100644 --- a/backend/src/api/model/search/event.rs +++ b/backend/src/api/model/search/event.rs @@ -69,7 +69,9 @@ impl Node for SearchEvent { impl SearchEvent { pub(crate) fn without_matches(src: search::Event, context: &Context) -> Self { - Self::new_inner(src, vec![], SearchEventMatches::default(), context) + let read_roles = decode_acl(&src.read_roles); + let user_can_read = context.auth.overlaps_roles(read_roles); + Self::new_inner(src, vec![], SearchEventMatches::default(), user_can_read) } pub(crate) fn new(hit: meilisearch_sdk::SearchResult<search::Event>, context: &Context) -> Self { @@ -77,16 +79,20 @@ impl SearchEvent { let src = hit.result; let mut text_matches = Vec::new(); - src.slide_texts.resolve_matches( - match_ranges_for(match_positions, "slide_texts.texts"), - &mut text_matches, - TextAssetType::SlideText, - ); - src.caption_texts.resolve_matches( - match_ranges_for(match_positions, "caption_texts.texts"), - &mut text_matches, - TextAssetType::Caption, - ); + let read_roles = decode_acl(&src.read_roles); + let user_can_read = context.auth.overlaps_roles(read_roles); + if user_can_read { + src.slide_texts.resolve_matches( + match_ranges_for(match_positions, "slide_texts.texts"), + &mut text_matches, + TextAssetType::SlideText, + ); + src.caption_texts.resolve_matches( + match_ranges_for(match_positions, "caption_texts.texts"), + &mut text_matches, + TextAssetType::Caption, + ); + } let matches = SearchEventMatches { title: field_matches_for(match_positions, "title"), @@ -94,25 +100,15 @@ impl SearchEvent { series_title: field_matches_for(match_positions, "series_title"), }; - Self::new_inner(src, text_matches, matches, context) + Self::new_inner(src, text_matches, matches, user_can_read) } fn new_inner( src: search::Event, text_matches: Vec<TextMatch>, matches: SearchEventMatches, - context: &Context, + user_can_read: bool, ) -> Self { - let read_roles = decode_acl(&src.read_roles); - let user_is_authorized = context.auth.overlaps_roles(read_roles); - let thumbnail = { - if user_is_authorized { - src.thumbnail - } else { - None - } - }; - Self { id: Id::search_event(src.id.0), series_id: src.series_id.map(|id| Id::search_series(id.0)), @@ -120,7 +116,7 @@ impl SearchEvent { title: src.title, description: src.description, creators: src.creators, - thumbnail, + thumbnail: if user_can_read { src.thumbnail } else { None }, duration: src.duration as f64, created: src.created, start_time: src.start_time, @@ -133,7 +129,7 @@ impl SearchEvent { text_matches, matches, has_password: src.has_password, - user_is_authorized, + user_is_authorized: user_can_read, } } } diff --git a/frontend/src/routes/Search.tsx b/frontend/src/routes/Search.tsx index 70bd086ee..d3efeed09 100644 --- a/frontend/src/routes/Search.tsx +++ b/frontend/src/routes/Search.tsx @@ -51,7 +51,6 @@ import { COLORS } from "../color"; import { BREAKPOINT_MEDIUM } from "../GlobalStyle"; import { eventId, - getCredentials, isExperimentalFlagSet, keyOfId, secondsToTimeString, @@ -162,7 +161,6 @@ const query = graphql` startTime endTime created - hasPassword userIsAuthorized hostRealms { path ancestorNames } textMatches { @@ -523,7 +521,6 @@ const SearchEvent: React.FC<EventItem> = ({ hostRealms, textMatches, matches, - hasPassword, userIsAuthorized, }) => { // TODO: decide what to do in the case of more than two host realms. Direct @@ -532,11 +529,6 @@ const SearchEvent: React.FC<EventItem> = ({ ? DirectVideoRoute.url({ videoId: id }) : VideoRoute.url({ realmPath: hostRealms[0].path, videoID: id }); - // TODO: This check should be done in backend. - const showMatches = userIsAuthorized || ( - hasPassword && getCredentials("event", eventId(keyOfId(id))) - ); - return ( <Item key={id} breakpoint={BREAKPOINT_MEDIUM} link={link}>{{ image: <Link to={link} tabIndex={-1}> @@ -635,7 +627,7 @@ const SearchEvent: React.FC<EventItem> = ({ {...{ seriesId }} />} {/* Show timeline with matches if there are any */} - {textMatches.length > 0 && showMatches && ( + {textMatches.length > 0 && ( <TextMatchTimeline {...{ id, duration, link, textMatches }} /> )} </div>, From 64ecc2cf9a646d181e82914428718356c48c736f Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt <lukas.kalbertodt@gmail.com> Date: Thu, 14 Nov 2024 19:11:50 +0100 Subject: [PATCH 19/26] Remove superfluous custom operation type --- frontend/src/util/index.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/frontend/src/util/index.ts b/frontend/src/util/index.ts index 633161a87..e276fc702 100644 --- a/frontend/src/util/index.ts +++ b/frontend/src/util/index.ts @@ -2,14 +2,13 @@ import { i18n } from "i18next"; import { MutableRefObject, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { bug, match } from "@opencast/appkit"; -import { OperationType } from "relay-runtime"; import { useLazyLoadQuery } from "react-relay"; import CONFIG, { TranslatedString } from "../config"; import { TimeUnit } from "../ui/Input"; import { AuthorizedData, authorizedDataQuery, CREDENTIALS_STORAGE_KEY } from "../routes/Video"; import { - VideoAuthorizedDataQuery$data, + VideoAuthorizedDataQuery, } from "../routes/__generated__/VideoAuthorizedDataQuery.graphql"; @@ -227,9 +226,6 @@ export type Credentials = { password: string; } | null; -interface AuthenticatedData extends OperationType { - response: VideoAuthorizedDataQuery$data; -} /** * Returns `authorizedData` of password protected events by fetching it from the API, @@ -247,7 +243,7 @@ export const useAuthenticatedDataQuery = ( const credentials = getCredentials("event", eventId(keyOfId(eventID))) ?? ( seriesID && getCredentials("series", seriesId(keyOfId(seriesID))) ); - const authenticatedData = useLazyLoadQuery<AuthenticatedData>( + const authenticatedData = useLazyLoadQuery<VideoAuthorizedDataQuery>( authorizedDataQuery, { eventId: eventId(keyOfId(eventID)), ...credentials }, // This will only query the data for events with stored credentials and/or yet unknown From 571c25b09ef8b3f6f1ace4629542096f764840d2 Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt <lukas.kalbertodt@gmail.com> Date: Thu, 14 Nov 2024 19:09:04 +0100 Subject: [PATCH 20/26] Remove password query requests from `<Thumbnail>` Instead, the locked icon is shown for all password-protected videos, regardless of whether they are unlocked. --- frontend/src/ui/Video.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/frontend/src/ui/Video.tsx b/frontend/src/ui/Video.tsx index 467ee6970..0a80fe5ac 100644 --- a/frontend/src/ui/Video.tsx +++ b/frontend/src/ui/Video.tsx @@ -12,7 +12,6 @@ import { import { useColorScheme } from "@opencast/appkit"; import { COLORS } from "../color"; -import { useAuthenticatedDataQuery } from "../util"; type ThumbnailProps = JSX.IntrinsicElements["div"] & { @@ -56,9 +55,6 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ }) => { const { t } = useTranslation(); const isDark = useColorScheme().scheme === "dark"; - const authenticatedData = useAuthenticatedDataQuery(event.id, event.series?.id); - const authorizedThumbnail = event.authorizedData?.thumbnail - ?? authenticatedData?.thumbnail; const isUpcoming = isUpcomingLiveEvent(event.syncedData?.startTime ?? null, event.isLive); const audioOnly = event.authorizedData ? ( @@ -69,10 +65,10 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ : false; let inner; - if (authorizedThumbnail && !deletionIsPending) { + if (event.authorizedData?.thumbnail && !deletionIsPending) { // We have a proper thumbnail. inner = <ThumbnailImg - src={authorizedThumbnail} + src={event.authorizedData.thumbnail} alt={t("video.thumbnail-for", { video: event.title })} />; } else { From 40594309b3bf71ac9e4dbd9afaacd6d9ca1cdfe9 Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt <lukas.kalbertodt@gmail.com> Date: Mon, 18 Nov 2024 16:06:56 +0100 Subject: [PATCH 21/26] Get rid of `AuthenticatedDataContext` by using relay store So, quick recap of what was done before this commit: - Initial GQL query can include credentials to immediately unlock `authorizedData`, but lets just consider the route where it doesn't. - `useAuthenticatedDataQuery` was called which sent a request based on whether the `authorizedData` was already present and on whether credentials did exist (by cleverly using `fetchPolicy`). In our case it first did nothing as no credentials are there. - The `authenticatedData` as stored inside a React context, and not in `event`. - The password form, when submitted would send a GQL request. On success the `setAuthData` setter from the context was called, meaning that all components using the contexts were rerendered. Generally nothing terrible going on here, but: `authorizedData` is really a part of the event and having a separate store, instead of just saving and retrieving it from the Relay store feels weird. There was also a bug where only one context was used per realm, which lead to all video blocks showing the same video if one was unlocked. This commit changes this. The context is completely removed and the data is stored in the relay store, making this more idiomatic. But oh boy, it was not easy to get here. Relay docs are just terrible for me to dig through. So one thing seemed clear fairly quickly: the idiomatic way is `useRefetchableFragment` for this. With that, one can only refetch a small part of the initial query, but with potentially different variables. Perfect! Problem is that the `refetch` function's callback function does not get the fetched data. So it is hard to check if the credentials were correct (and to distinguish other network errors). So my solution at the end is to keep the manual `fetchQuery` as that allows us to check the result in a callback. Then, on success, the `refetch` is called with "store-only" to just rerender the components. One of the big time sinks for me was to understand that the manual query we use with `fetchQuery` needs to be exactly the same as what `refetch` will execute. Specifically: use `node()` and not `eventById`. Refetching only works with fragments, so another layer of fragments needed to be introduced. I added a helper function to combine the event with its `authorizedData` for that. This commit is best viewed with whitespace-diff disabled. --- frontend/src/i18n/locales/en.yaml | 1 + frontend/src/routes/Embed.tsx | 32 +-- frontend/src/routes/Realm.tsx | 10 +- frontend/src/routes/Video.tsx | 390 ++++++++++++++++-------------- frontend/src/ui/Blocks/Video.tsx | 26 +- frontend/src/util/index.ts | 33 +-- 6 files changed, 232 insertions(+), 260 deletions(-) diff --git a/frontend/src/i18n/locales/en.yaml b/frontend/src/i18n/locales/en.yaml index 38691a73a..9b0757e6e 100644 --- a/frontend/src/i18n/locales/en.yaml +++ b/frontend/src/i18n/locales/en.yaml @@ -180,6 +180,7 @@ video: password: heading: Protected Video sub-heading: Access to this video is restricted. + no-preview-permission: $t(api-remote-errors.view.event) body: > Please enter the username and the password you have received to access this video; please note that these are not your login credentials. diff --git a/frontend/src/routes/Embed.tsx b/frontend/src/routes/Embed.tsx index b389664a5..93f232d2e 100644 --- a/frontend/src/routes/Embed.tsx +++ b/frontend/src/routes/Embed.tsx @@ -1,4 +1,4 @@ -import { ReactNode, Suspense, useState } from "react"; +import { ReactNode, Suspense } from "react"; import { LuFrown, LuAlertTriangle } from "react-icons/lu"; import { Translation, useTranslation } from "react-i18next"; import { @@ -7,7 +7,7 @@ import { } from "react-relay"; import { unreachable } from "@opencast/appkit"; -import { eventId, getCredentials, isSynced, keyOfId, useAuthenticatedDataQuery } from "../util"; +import { eventId, getCredentials, isSynced, keyOfId } from "../util"; import { GlobalErrorBoundary } from "../util/err"; import { loadQuery } from "../relay"; import { makeRoute, MatchedRoute } from "../rauta"; @@ -19,7 +19,7 @@ import { EmbedQuery } from "./__generated__/EmbedQuery.graphql"; import { EmbedDirectOpencastQuery } from "./__generated__/EmbedDirectOpencastQuery.graphql"; import { EmbedEventData$key } from "./__generated__/EmbedEventData.graphql"; import { PlayerContextProvider } from "../ui/player/PlayerContext"; -import { AuthenticatedDataContext, AuthorizedData, PreviewPlaceholder } from "./Video"; +import { PreviewPlaceholder, useEventWithAuthData } from "./Video"; export const EmbedVideoRoute = makeRoute({ url: ({ videoId }: { videoId: string }) => `/~embed/!v/${keyOfId(videoId)}`, @@ -120,12 +120,8 @@ const embedEventFragment = graphql` endTime duration } - authorizedData(user: $eventUser, password: $eventPassword) { - thumbnail - tracks { uri flavor mimetype resolution isMaster } - captions { uri lang } - segments { uri startTime } - } + ... VideoPageAuthorizedData + @arguments(eventUser: $eventUser, eventPassword: $eventPassword) } } `; @@ -138,12 +134,12 @@ type EmbedProps = { const Embed: React.FC<EmbedProps> = ({ query, queryRef }) => { const fragmentRef = usePreloadedQuery(query, queryRef); - const event = useFragment<EmbedEventData$key>( + const protoEvent = useFragment<EmbedEventData$key>( embedEventFragment, fragmentRef.event, ); + const [event, refetch] = useEventWithAuthData(protoEvent); const { t } = useTranslation(); - const [authenticatedData, setAuthenticatedData] = useState<AuthorizedData | null>(null); if (!event) { return <PlayerPlaceholder> @@ -170,17 +166,9 @@ const Embed: React.FC<EmbedProps> = ({ query, queryRef }) => { </PlayerPlaceholder>; } - const authorizedData = useAuthenticatedDataQuery( - event.id, - event.series?.id, - { authorizedData: event.authorizedData }, - ); - - return authorizedData - ? <Player event={{ ...event, authorizedData }} /> - : <AuthenticatedDataContext.Provider value={{ authenticatedData, setAuthenticatedData }}> - <PreviewPlaceholder embedded {...{ event }}/>; - </AuthenticatedDataContext.Provider>; + return event.authorizedData + ? <Player event={{ ...event, authorizedData: event.authorizedData }} /> + : <PreviewPlaceholder embedded {...{ event, refetch }}/>; }; export const BlockEmbedRoute = makeRoute({ diff --git a/frontend/src/routes/Realm.tsx b/frontend/src/routes/Realm.tsx index 306447f2f..d0faed771 100644 --- a/frontend/src/routes/Realm.tsx +++ b/frontend/src/routes/Realm.tsx @@ -27,7 +27,6 @@ import { COLORS } from "../color"; import { useMenu } from "../layout/MenuState"; import { ManageNav } from "./manage"; import { BREAKPOINT as NAV_BREAKPOINT } from "../layout/Navigation"; -import { AuthorizedData, AuthenticatedDataContext } from "./Video"; // eslint-disable-next-line @typescript-eslint/quotes @@ -146,7 +145,6 @@ const RealmPage: React.FC<Props> = ({ realm }) => { const { t } = useTranslation(); const siteTitle = useTranslatedConfig(CONFIG.siteTitle); const breadcrumbs = realmBreadcrumbs(t, realm.ancestors); - const [authenticatedData, setAuthenticatedData] = useState<AuthorizedData | null>(null); const title = realm.isMainRoot ? siteTitle : realm.name; useTitle(title, realm.isMainRoot); @@ -168,11 +166,9 @@ const RealmPage: React.FC<Props> = ({ realm }) => { {realm.isUserRealm && <UserRealmNote realm={realm} />} </div> )} - <AuthenticatedDataContext.Provider value={{ authenticatedData, setAuthenticatedData }}> - {realm.blocks.length === 0 && realm.isMainRoot - ? <WelcomeMessage /> - : <Blocks realm={realm} />} - </AuthenticatedDataContext.Provider> + {realm.blocks.length === 0 && realm.isMainRoot + ? <WelcomeMessage /> + : <Blocks realm={realm} />} </>; }; diff --git a/frontend/src/routes/Video.tsx b/frontend/src/routes/Video.tsx index 5fb9e06d7..cb99303db 100644 --- a/frontend/src/routes/Video.tsx +++ b/frontend/src/routes/Video.tsx @@ -1,15 +1,14 @@ import React, { - createContext, - Dispatch, ReactElement, ReactNode, - SetStateAction, - useContext, useEffect, useRef, useState, } from "react"; -import { graphql, GraphQLTaggedNode, PreloadedQuery, useFragment } from "react-relay/hooks"; +import { + graphql, GraphQLTaggedNode, PreloadedQuery, RefetchFnDynamic, useFragment, + useRefetchableFragment, +} from "react-relay/hooks"; import { useTranslation } from "react-i18next"; import { fetchQuery, OperationType } from "relay-runtime"; import { @@ -19,7 +18,7 @@ import { QRCodeCanvas } from "qrcode.react"; import { match, unreachable, screenWidthAtMost, screenWidthAbove, useColorScheme, Floating, FloatingContainer, FloatingTrigger, WithTooltip, Card, Button, ProtoButton, - bug, + notNullish, } from "@opencast/appkit"; import { VideoObject, WithContext } from "schema-dts"; @@ -46,7 +45,6 @@ import { keyOfId, playlistId, getCredentials, - useAuthenticatedDataQuery, credentialsStorageKey, } from "../util"; import { BREAKPOINT_SMALL, BREAKPOINT_MEDIUM } from "../GlobalStyle"; @@ -90,9 +88,11 @@ import { PlaylistBlockFromPlaylist } from "../ui/Blocks/Playlist"; import { AuthenticationFormState, FormData, AuthenticationForm } from "./Login"; import { VideoAuthorizedDataQuery, - VideoAuthorizedDataQuery$data, } from "./__generated__/VideoAuthorizedDataQuery.graphql"; import { AuthorizedBlockEvent } from "../ui/Blocks/Video"; +import { + VideoPageAuthorizedData$data, VideoPageAuthorizedData$key, +} from "./__generated__/VideoPageAuthorizedData.graphql"; // =========================================================================================== @@ -122,6 +122,7 @@ export const VideoRoute = makeRoute({ ... UserData event: eventById(id: $id) { ... VideoPageEventData + @arguments(eventUser: $eventUser, eventPassword: $eventPassword) ... on AuthorizedEvent { isReferencedByRealm(path: $realmPath) } @@ -146,11 +147,7 @@ export const VideoRoute = makeRoute({ {... { query, queryRef }} nav={data => data.realm ? <Nav fragRef={data.realm} /> : []} render={({ event, realm, playlist }) => { - if (!event) { - return <NotFound kind="video" />; - } - - if (!realm || !event.isReferencedByRealm) { + if (!realm || (event && !event.isReferencedByRealm)) { return <ForwardToDirectRoute videoId={videoId} />; } @@ -191,6 +188,7 @@ export const OpencastVideoRoute = makeRoute({ ... UserData event: eventByOpencastId(id: $id) { ... VideoPageEventData + @arguments(eventUser: $eventUser, eventPassword: $eventPassword) ... on AuthorizedEvent { isReferencedByRealm(path: $realmPath) } @@ -215,11 +213,7 @@ export const OpencastVideoRoute = makeRoute({ {... { query, queryRef }} nav={data => data.realm ? <Nav fragRef={data.realm} /> : []} render={({ event, realm, playlist }) => { - if (!event) { - return <NotFound kind="video" />; - } - - if (!realm || !event.isReferencedByRealm) { + if (!realm || (event && !event.isReferencedByRealm)) { return <ForwardToDirectOcRoute ocID={id} />; } @@ -266,7 +260,10 @@ export const DirectVideoRoute = makeRoute({ $eventPassword: String ) { ... UserData - event: eventById(id: $id) { ... VideoPageEventData } + event: eventById(id: $id) { + ... VideoPageEventData + @arguments(eventUser: $eventUser, eventPassword: $eventPassword) + } realm: rootRealm { ... VideoPageRealmData ... NavigationData @@ -303,7 +300,10 @@ export const DirectOpencastVideoRoute = makeRoute({ $eventPassword: String ) { ... UserData - event: eventByOpencastId(id: $id) { ... VideoPageEventData } + event: eventByOpencastId(id: $id) { + ... VideoPageEventData + @arguments(eventUser: $eventUser, eventPassword: $eventPassword) + } realm: rootRealm { ... VideoPageRealmData ... NavigationData @@ -376,18 +376,43 @@ const matchedDirectRoute = ( {... { query, queryRef }} noindex nav={data => data.realm ? <Nav fragRef={data.realm} /> : []} - render={({ event, realm, playlist }) => !event - ? <NotFound kind="video" /> - : <VideoPage - eventRef={event} - realmRef={realm ?? unreachable("root realm doesn't exist")} - playlistRef={playlist ?? null} - basePath="/!v" - />} + render={({ event, realm, playlist }) => <VideoPage + eventRef={event} + realmRef={realm ?? unreachable("root realm doesn't exist")} + playlistRef={playlist ?? null} + basePath="/!v" + />} />, dispose: () => queryRef.dispose(), }); +type RawEvent<T> = + | ({ __typename: "AuthorizedEvent"} & T) + | { __typename: "NotAllowed" } + | { __typename: "%other" }; + +export const useEventWithAuthData = <T, >( + event?: RawEvent<T & VideoPageAuthorizedData$key> | null, +): [ + RawEvent<T & { authorizedData: VideoPageAuthorizedData$data["authorizedData"] }> + | undefined | null, + RefetchFnDynamic<VideoPageInRealmQuery, VideoPageAuthorizedData$key>, +] => { + const [data, refetch] = useRefetchableFragment( + authorizedDataFragment, + !event || event.__typename !== "AuthorizedEvent" + ? null + : event as VideoPageAuthorizedData$key, + ); + + if (!event || event.__typename !== "AuthorizedEvent") { + return [event, refetch]; + } + + const patched = { ...event, authorizedData: notNullish(data).authorizedData }; + return [patched, refetch]; +}; + // =========================================================================================== // ===== GraphQL Fragments @@ -403,7 +428,12 @@ const realmFragment = graphql` `; const eventFragment = graphql` - fragment VideoPageEventData on Event { + fragment VideoPageEventData on Event + @argumentDefinitions( + eventUser: { type: "String", defaultValue: null }, + eventPassword: { type: "String", defaultValue: null }, + ) + { __typename ... on NotAllowed { dummy } # workaround ... on AuthorizedEvent { @@ -423,12 +453,8 @@ const eventFragment = graphql` startTime endTime } - authorizedData(user: $eventUser, password: $eventPassword) { - tracks { uri flavor mimetype resolution isMaster } - captions { uri lang } - segments { uri startTime } - thumbnail - } + ... VideoPageAuthorizedData + @arguments(eventUser: $eventUser, eventPassword: $eventPassword) series { id opencastId @@ -439,19 +465,41 @@ const eventFragment = graphql` } `; +const authorizedDataFragment = graphql` + fragment VideoPageAuthorizedData on AuthorizedEvent + @refetchable(queryName: "VideoAuthorizedDataRefetchQuery") + @argumentDefinitions( + eventUser: { type: "String", defaultValue: null }, + eventPassword: { type: "String", defaultValue: null }, + ) + { + authorizedData(user: $eventUser, password: $eventPassword) { + tracks { uri flavor mimetype resolution isMaster } + captions { uri lang } + segments { uri startTime } + thumbnail + } + } +`; + +// Custom query to refetch authorized event data manually. Unfortunately, using +// the fragment here is not enough, we need to also selected `authorizedData` +// manually. Without that, we could not access that field below to check if the +// credentials were correct. Normally, adding `@relay(mask: false)` to the +// fragment should also fix that, but that does not work for some reason. export const authorizedDataQuery = graphql` query VideoAuthorizedDataQuery( - $eventId: ID!, + $id: ID!, $eventUser: String, $eventPassword: String, ) { - authorizedEvent: eventById(id: $eventId) { + node(id: $id) { + __typename + id + ...VideoPageAuthorizedData + @arguments(eventUser: $eventUser, eventPassword: $eventPassword) ...on AuthorizedEvent { - id authorizedData(user: $eventUser, password: $eventPassword) { - tracks { uri flavor mimetype resolution isMaster } - captions { uri lang } - segments { uri startTime } thumbnail } } @@ -464,15 +512,8 @@ export const authorizedDataQuery = graphql` // ===== Components // =========================================================================================== -export type AuthorizedData = VideoAuthorizedDataQuery$data["authorizedEvent"]; -type AuthenticatedDataContext = { - authenticatedData: AuthorizedData; - setAuthenticatedData: Dispatch<SetStateAction<AuthorizedData>>; -} -export const AuthenticatedDataContext = createContext<AuthenticatedDataContext | null>(null); - type Props = { - eventRef: NonNullable<VideoPageEventData$key>; + eventRef: NonNullable<VideoPageEventData$key> | null | undefined; realmRef: NonNullable<VideoPageRealmData$key>; playlistRef: PlaylistBlockPlaylistData$key | null; basePath: string; @@ -481,9 +522,13 @@ type Props = { const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath }) => { const { t } = useTranslation(); const rerender = useForceRerender(); - const event = useFragment(eventFragment, eventRef); const realm = useFragment(realmFragment, realmRef); - const [authenticatedData, setAuthenticatedData] = useState<AuthorizedData | null>(null); + const protoEvent = useFragment(eventFragment, eventRef); + const [event, refetch] = useEventWithAuthData(protoEvent); + + if (!event) { + return <NotFound kind="video" />; + } if (event.__typename === "NotAllowed") { return <ErrorPage title={t("api-remote-errors.view.event")} />; @@ -495,23 +540,6 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath 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; @@ -539,49 +567,50 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath return <> <Breadcrumbs path={breadcrumbs} tail={event.title} /> <script type="application/ld+json">{JSON.stringify(structuredData)}</script> - <AuthenticatedDataContext.Provider value={{ authenticatedData, setAuthenticatedData }}> - <PlayerContextProvider> - {authorizedData - ? <InlinePlayer - event={{ ...event, authorizedData }} - css={{ margin: "-4px auto 0" }} - onEventStateChange={rerender} - /> - : <PreviewPlaceholder {...{ event }}/> - } - <Metadata id={event.id} event={event} /> - </PlayerContextProvider> - - <div css={{ height: 80 }} /> - - {playlistRef - ? <PlaylistBlockFromPlaylist - moreOfTitle - basePath={basePath} - fragRef={playlistRef} - activeEventId={event.id} - /> - : event.series && <SeriesBlockFromSeries - basePath={basePath} - fragRef={event.series} - title={t("video.more-from-series", { series: event.series.title })} - activeEventId={event.id} + <PlayerContextProvider> + {event.authorizedData + ? <InlinePlayer + event={{ ...event, authorizedData: event.authorizedData }} + css={{ margin: "-4px auto 0" }} + onEventStateChange={rerender} /> + : <PreviewPlaceholder {...{ event, refetch }}/> } - </AuthenticatedDataContext.Provider> + <Metadata id={event.id} event={event} /> + </PlayerContextProvider> + + <div css={{ height: 80 }} /> + + {playlistRef + ? <PlaylistBlockFromPlaylist + moreOfTitle + basePath={basePath} + fragRef={playlistRef} + activeEventId={event.id} + /> + : event.series && <SeriesBlockFromSeries + basePath={basePath} + fragRef={event.series} + title={t("video.more-from-series", { series: event.series.title })} + activeEventId={event.id} + /> + } </>; }; type ProtectedPlayerProps = { event: Event | AuthorizedBlockEvent; embedded?: boolean; + refetch: RefetchFnDynamic<VideoPageInRealmQuery, VideoPageAuthorizedData$key>; } -export const PreviewPlaceholder: React.FC<ProtectedPlayerProps> = ({ event, embedded }) => { +export const PreviewPlaceholder: React.FC<ProtectedPlayerProps> = ({ + event, embedded, refetch, +}) => { const { t } = useTranslation(); return event.hasPassword - ? <ProtectedPlayer {...{ event, embedded }} /> + ? <ProtectedPlayer {...{ event, embedded, refetch }} /> : <div css={{ height: "unset" }}> <PlayerPlaceholder> <p css={{ @@ -598,13 +627,12 @@ export const PreviewPlaceholder: React.FC<ProtectedPlayerProps> = ({ event, embe export const CREDENTIALS_STORAGE_KEY = "tobira-video-credentials-"; -const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ event, embedded }) => { +const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ event, embedded, refetch }) => { const { t } = useTranslation(undefined, { keyPrefix: "video.password" }); const isDark = useColorScheme().scheme === "dark"; const user = useUser(); const [authState, setAuthState] = useState<AuthenticationFormState>("idle"); const [authError, setAuthError] = useState<string | null>(null); - const authenticatedDataContext = useContext(AuthenticatedDataContext); const embeddedStyles = { height: "100%", @@ -613,34 +641,34 @@ const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ event, embedded }) => }; const onSubmit = (data: FormData) => { - const credentials = JSON.stringify({ + const credentialVars = { eventUser: data.userid, eventPassword: data.password, - }); - + }; fetchQuery<VideoAuthorizedDataQuery>(environment, authorizedDataQuery, { - eventId: event.id, - eventUser: data.userid, - eventPassword: data.password, + id: event.id, + ...credentialVars, }).subscribe({ start: () => setAuthState("pending"), - next: ({ authorizedEvent }) => { - if (!authorizedEvent?.authorizedData) { - setAuthError(t("invalid-credentials")); + next: ({ node }) => { + if (node?.__typename !== "AuthorizedEvent") { + setAuthError(t("no-preview-permission")); setAuthState("idle"); return; } - if (authenticatedDataContext) { - authenticatedDataContext.setAuthenticatedData({ - authorizedData: authorizedEvent.authorizedData, - }); - } else { - bug("Authenticated data context is not initialized"); + if (!node.authorizedData) { + setAuthError(t("invalid-credentials")); + setAuthState("idle"); + return; } - setAuthError(null); - setAuthState("success"); + // This refetches the fragment, which actually makes the + // components re-render with the new `authorizedData` value. + // This does not actually send a network request as all data is + // in the store (due to `fetchQuery` that was just executed). + refetch(credentialVars, { fetchPolicy: "store-only" }); + // To make the authentication "sticky", the credentials are stored in browser // storage. If the user is logged in, local storage is used so the browser @@ -656,6 +684,10 @@ const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ event, embedded }) => // query, when only the single ID from the url is known. // The check will return a result for either ID regardless of its kind, as long as // one of them is stored. + const credentials = JSON.stringify({ + eventUser: data.userid, + eventPassword: data.password, + }); const storage = isRealUser(user) ? window.localStorage : window.sessionStorage; storage.setItem(credentialsStorageKey("event", event.id), credentials); storage.setItem(credentialsStorageKey("oc-event", event.opencastId), credentials); @@ -673,81 +705,75 @@ const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ event, embedded }) => }); }; - return authenticatedDataContext?.authenticatedData?.authorizedData && event.syncedData - ? <InlinePlayer event={{ - ...event, - authorizedData: authenticatedDataContext.authenticatedData.authorizedData, - syncedData: event.syncedData, - }} /> - : ( + return ( + <div css={{ + display: "flex", + flexDirection: "column", + color: isDark ? COLORS.neutral80 : COLORS.neutral15, + backgroundColor: isDark ? COLORS.neutral15 : COLORS.neutral80, + [screenWidthAtMost(BREAKPOINT_MEDIUM)]: { + alignItems: "center", + }, + ...embedded && embeddedStyles, + }}> + <h2 css={{ + margin: 32, + marginBottom: 0, + [screenWidthAbove(BREAKPOINT_MEDIUM)]: { + textAlign: "left", + }, + }}>{t("heading")}</h2> <div css={{ display: "flex", - flexDirection: "column", - color: isDark ? COLORS.neutral80 : COLORS.neutral15, - backgroundColor: isDark ? COLORS.neutral15 : COLORS.neutral80, [screenWidthAtMost(BREAKPOINT_MEDIUM)]: { - alignItems: "center", + flexDirection: "column-reverse", }, - ...embedded && embeddedStyles, }}> - <h2 css={{ - margin: 32, - marginBottom: 0, - [screenWidthAbove(BREAKPOINT_MEDIUM)]: { - textAlign: "left", - }, - }}>{t("heading")}</h2> <div css={{ display: "flex", - [screenWidthAtMost(BREAKPOINT_MEDIUM)]: { - flexDirection: "column-reverse", - }, + flexDirection: "column", + alignItems: "center", }}> - <div css={{ - display: "flex", - flexDirection: "column", - alignItems: "center", - }}> - <AuthenticationForm - {...{ onSubmit }} - state={authState} - error={null} - SubmitIcon={LuUnlock} - labels={{ - user: t("label.id"), - password: t("label.password"), - submit: t("label.submit"), - }} - css={{ - "&": { backgroundColor: "transparent" }, - margin: 0, - border: 0, - width: "unset", - minWidth: 300, - "div > label, div > input": { - ...!isDark && { - backgroundColor: COLORS.neutral15, - }, + <AuthenticationForm + {...{ onSubmit }} + state={authState} + error={null} + SubmitIcon={LuUnlock} + labels={{ + user: t("label.id"), + password: t("label.password"), + submit: t("label.submit"), + }} + css={{ + "&": { backgroundColor: "transparent" }, + margin: 0, + border: 0, + width: "unset", + minWidth: 300, + "div > label, div > input": { + ...!isDark && { + backgroundColor: COLORS.neutral15, }, + }, + }} + /> + {authError && ( + <Card + kind="error" + iconPos="left" + css={{ + width: "fit-content", + marginBottom: 32, }} - /> - {authError && ( - <Card - kind="error" - iconPos="left" - css={{ - width: "fit-content", - marginBottom: 32, - }} - > - {authError} - </Card> - )} - </div> - <AuthenticationFormText /> + > + {authError} + </Card> + )} </div> + <AuthenticationFormText /> </div> - ); + </div> + ); }; const AuthenticationFormText: React.FC = () => { @@ -784,7 +810,9 @@ const AuthenticationFormText: React.FC = () => { }; type Event = Extract<NonNullable<VideoPageEventData$data>, { __typename: "AuthorizedEvent" }>; -type SyncedEvent = SyncedOpencastEntity<Event>; +type SyncedEvent = SyncedOpencastEntity<Event> & { + authorizedData?: VideoPageAuthorizedData$data["authorizedData"]; +}; type MetadataProps = { id: string; diff --git a/frontend/src/ui/Blocks/Video.tsx b/frontend/src/ui/Blocks/Video.tsx index 55b3d7332..5726a0ba6 100644 --- a/frontend/src/ui/Blocks/Video.tsx +++ b/frontend/src/ui/Blocks/Video.tsx @@ -5,11 +5,11 @@ import { InlinePlayer } from "../player"; import { VideoBlockData$data, VideoBlockData$key } from "./__generated__/VideoBlockData.graphql"; import { Title } from ".."; import { useTranslation } from "react-i18next"; -import { isSynced, keyOfId, useAuthenticatedDataQuery } from "../../util"; +import { isSynced, keyOfId } from "../../util"; import { Link } from "../../router"; import { LuArrowRightCircle } from "react-icons/lu"; import { PlayerContextProvider } from "../player/PlayerContext"; -import { PreviewPlaceholder } from "../../routes/Video"; +import { PreviewPlaceholder, useEventWithAuthData } from "../../routes/Video"; export type BlockEvent = VideoBlockData$data["event"]; @@ -22,7 +22,7 @@ type Props = { export const VideoBlock: React.FC<Props> = ({ fragRef, basePath }) => { const { t } = useTranslation(); - const { event, showTitle, showLink } = useFragment(graphql` + const { event: protoEvent, showTitle, showLink } = useFragment(graphql` fragment VideoBlockData on VideoBlock { event { __typename @@ -45,18 +45,14 @@ export const VideoBlock: React.FC<Props> = ({ fragRef, basePath }) => { startTime endTime } - authorizedData { - thumbnail - tracks { uri flavor mimetype resolution isMaster } - captions { uri lang } - segments { uri startTime } - } + ... VideoPageAuthorizedData } } showTitle showLink } `, fragRef); + const [event, refetch] = useEventWithAuthData(protoEvent); if (event == null) { return <Card kind="error">{t("video.deleted-video-block")}</Card>; @@ -69,22 +65,16 @@ export const VideoBlock: React.FC<Props> = ({ fragRef, basePath }) => { return unreachable(); } - 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) + {event.authorizedData && isSynced(event) ? <InlinePlayer - event={{ ...event, authorizedData }} + event={{ ...event, authorizedData: event.authorizedData }} css={{ margin: "-4px auto 0" }} /> - : <PreviewPlaceholder {...{ event }} /> + : <PreviewPlaceholder {...{ event, refetch }} /> } </PlayerContextProvider> diff --git a/frontend/src/util/index.ts b/frontend/src/util/index.ts index e276fc702..546f03212 100644 --- a/frontend/src/util/index.ts +++ b/frontend/src/util/index.ts @@ -2,14 +2,10 @@ import { i18n } from "i18next"; import { MutableRefObject, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { bug, match } from "@opencast/appkit"; -import { useLazyLoadQuery } from "react-relay"; import CONFIG, { TranslatedString } from "../config"; import { TimeUnit } from "../ui/Input"; -import { AuthorizedData, authorizedDataQuery, CREDENTIALS_STORAGE_KEY } from "../routes/Video"; -import { - VideoAuthorizedDataQuery, -} from "../routes/__generated__/VideoAuthorizedDataQuery.graphql"; +import { CREDENTIALS_STORAGE_KEY } from "../routes/Video"; /** @@ -227,33 +223,6 @@ export type Credentials = { } | null; -/** - * 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 and instead return the - * event's authorized data if that was already present and passed to this hook. - */ -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<VideoAuthorizedDataQuery>( - authorizedDataQuery, - { 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. * From e5d3a45b16d1a900fdaf99606c6a263d49e33710 Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt <lukas.kalbertodt@gmail.com> Date: Tue, 19 Nov 2024 13:11:26 +0100 Subject: [PATCH 22/26] Show correct "lock" thumbnails for preview-only videos in search --- frontend/src/routes/Search.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/routes/Search.tsx b/frontend/src/routes/Search.tsx index d3efeed09..f71bf0ff1 100644 --- a/frontend/src/routes/Search.tsx +++ b/frontend/src/routes/Search.tsx @@ -546,7 +546,7 @@ const SearchEvent: React.FC<EventItem> = ({ startTime, endTime, }, - authorizedData: { + authorizedData: !userIsAuthorized ? null : { thumbnail, audioOnly, }, From f2fdc08b10f36e9a4a3d943344be9f0c256e9196 Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt <lukas.kalbertodt@gmail.com> Date: Tue, 19 Nov 2024 13:54:14 +0100 Subject: [PATCH 23/26] Use series credentials to unlock events and improve creds type-safety My previous changes accidentally removed the feature that events are also unlocked by the credentials associated with their series. For that, a second request is necessary, unfortunately. I also reworked how credentials are loaded from localStorage as that was fishy before: `getCredentials` states it returns `Credentials` which has `user` and `password` fields. But in practice, the object stored in local storage always contained fields `eventUser` and `eventPassword`. And that was actually fine at runtime with all places that read that data. However, it's obviously not ideal to have types that don't match the runtime data, so I fixed that. The fields in local storage are now `user` and `password`. --- frontend/src/routes/Embed.tsx | 8 +++- frontend/src/routes/Video.tsx | 69 ++++++++++++++++++++++++++++------- frontend/src/util/index.ts | 15 +++++++- 3 files changed, 75 insertions(+), 17 deletions(-) diff --git a/frontend/src/routes/Embed.tsx b/frontend/src/routes/Embed.tsx index 93f232d2e..730057b37 100644 --- a/frontend/src/routes/Embed.tsx +++ b/frontend/src/routes/Embed.tsx @@ -37,9 +37,11 @@ export const EmbedVideoRoute = makeRoute({ } `; + const creds = getCredentials("event", id); const queryRef = loadQuery<EmbedQuery>(query, { id, - ...getCredentials("event", id), + eventUser: creds?.user, + eventPassword: creds?.password, }); @@ -67,9 +69,11 @@ export const EmbedOpencastVideoRoute = makeRoute({ `; const videoId = decodeURIComponent(matches[1]); + const creds = getCredentials("oc-event", videoId); const queryRef = loadQuery<EmbedDirectOpencastQuery>(query, { id: videoId, - ...getCredentials("oc-event", videoId), + eventUser: creds?.user, + eventPassword: creds?.password, }); return matchedEmbedRoute(query, queryRef); diff --git a/frontend/src/routes/Video.tsx b/frontend/src/routes/Video.tsx index cb99303db..bc711256b 100644 --- a/frontend/src/routes/Video.tsx +++ b/frontend/src/routes/Video.tsx @@ -46,6 +46,7 @@ import { playlistId, getCredentials, credentialsStorageKey, + Credentials, } from "../util"; import { BREAKPOINT_SMALL, BREAKPOINT_MEDIUM } from "../GlobalStyle"; import { LinkButton } from "../ui/LinkButton"; @@ -135,11 +136,13 @@ export const VideoRoute = makeRoute({ } `; + const creds = getCredentials("event", id); const queryRef = loadQuery<VideoPageInRealmQuery>(query, { id, realmPath, listId, - ...getCredentials("event", id), + eventUser: creds?.user, + eventPassword: creds?.password, }); return { @@ -201,11 +204,13 @@ export const OpencastVideoRoute = makeRoute({ } `; + const creds = getCredentials("oc-event", id); const queryRef = loadQuery<VideoPageByOcIdInRealmQuery>(query, { id, realmPath, listId, - ...getCredentials("oc-event", id), + eventUser: creds?.user, + eventPassword: creds?.password, }); return { @@ -272,10 +277,12 @@ export const DirectVideoRoute = makeRoute({ } `; const id = eventId(decodeURIComponent(params[1])); + const creds = getCredentials("event", id); const queryRef = loadQuery<VideoPageDirectLinkQuery>(query, { id, listId: makeListId(url.searchParams.get("list")), - ...getCredentials("event", id), + eventUser: creds?.user, + eventPassword: creds?.password, }); return matchedDirectRoute(query, queryRef); @@ -312,10 +319,12 @@ export const DirectOpencastVideoRoute = makeRoute({ } `; const id = decodeURIComponent(matches[1]); + const creds = getCredentials("oc-event", id); const queryRef = loadQuery<VideoPageDirectOpencastLinkQuery>(query, { id, listId: makeListId(url.searchParams.get("list")), - ...getCredentials("oc-event", id), + eventUser: creds?.user, + eventPassword: creds?.password, }); return matchedDirectRoute(query, queryRef); @@ -633,6 +642,7 @@ const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ event, embedded, refe const user = useUser(); const [authState, setAuthState] = useState<AuthenticationFormState>("idle"); const [authError, setAuthError] = useState<string | null>(null); + const [triedSeries, setTriedSeries] = useState(false); const embeddedStyles = { height: "100%", @@ -640,26 +650,29 @@ const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ event, embedded, refe justifyContent: "center", }; - const onSubmit = (data: FormData) => { + const tryCredentials = (creds: NonNullable<Credentials>, callbacks: { + start?: () => void; + error?: (e: Error) => void; + eventAuthError?: () => void; + incorrectCredentials?: () => void; + }) => { const credentialVars = { - eventUser: data.userid, - eventPassword: data.password, + eventUser: creds.user, + eventPassword: creds.password, }; fetchQuery<VideoAuthorizedDataQuery>(environment, authorizedDataQuery, { id: event.id, ...credentialVars, }).subscribe({ - start: () => setAuthState("pending"), + start: callbacks.start, next: ({ node }) => { if (node?.__typename !== "AuthorizedEvent") { - setAuthError(t("no-preview-permission")); - setAuthState("idle"); + callbacks.eventAuthError?.(); return; } if (!node.authorizedData) { - setAuthError(t("invalid-credentials")); - setAuthState("idle"); + callbacks.incorrectCredentials?.(); return; } @@ -685,8 +698,10 @@ const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ event, embedded, refe // The check will return a result for either ID regardless of its kind, as long as // one of them is stored. const credentials = JSON.stringify({ - eventUser: data.userid, - eventPassword: data.password, + // Explicitly listing fields here to keep storage format + // explicit and avoid accidentally changing it. + user: creds.user, + password: creds.password, }); const storage = isRealUser(user) ? window.localStorage : window.sessionStorage; storage.setItem(credentialsStorageKey("event", event.id), credentials); @@ -698,10 +713,36 @@ const ProtectedPlayer: React.FC<ProtectedPlayerProps> = ({ event, embedded, refe storage.setItem(credentialsStorageKey("series", event.series.id), credentials); } }, + error: callbacks.error, + }); + }; + + // We also try the credentials we have associated with the series. + // Unfortunately, we can only do that now and not in the beginning because + // we don't know the series ID from the start. + useEffect(() => { + const seriesCredentials = event.series && getCredentials("series", event.series.id); + if (!triedSeries && seriesCredentials) { + setTriedSeries(true); + tryCredentials(seriesCredentials, {}); + } + }); + + const onSubmit = (data: FormData) => { + tryCredentials({ user: data.userid, password: data.password }, { + start: () => setAuthState("pending"), error: (error: Error) => { setAuthError(error.message); setAuthState("idle"); }, + eventAuthError: () => { + setAuthError(t("no-preview-permission")); + setAuthState("idle"); + }, + incorrectCredentials: () => { + setAuthError(t("invalid-credentials")); + setAuthState("idle"); + }, }); }; diff --git a/frontend/src/util/index.ts b/frontend/src/util/index.ts index 546f03212..060454fb4 100644 --- a/frontend/src/util/index.ts +++ b/frontend/src/util/index.ts @@ -242,7 +242,20 @@ 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); + if (!credentials) { + return null; + } + + const parsed = JSON.parse(credentials); + if ("user" in parsed && typeof parsed.user === "string" + && "password" in parsed && typeof parsed.password === "string") { + return { + user: parsed.user, + password: parsed.password, + }; + } else { + return null; + } }; export const credentialsStorageKey = (kind: IdKind, id: string) => From 4031e079511d580a3c6acb21afa8cfa41d58c11f Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt <lukas.kalbertodt@gmail.com> Date: Tue, 19 Nov 2024 13:55:01 +0100 Subject: [PATCH 24/26] Bump search index version `hasPassword` was added to events in the previous commits, so we need to rebuild the index. --- backend/src/search/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/search/mod.rs b/backend/src/search/mod.rs index 89954a3ca..276e631db 100644 --- a/backend/src/search/mod.rs +++ b/backend/src/search/mod.rs @@ -42,7 +42,7 @@ pub(crate) use self::{ /// The version of search index schema. Increase whenever there is a change that /// requires an index rebuild. -const VERSION: u32 = 6; +const VERSION: u32 = 7; // ===== Configuration ============================================================================ From 8e50478fd2686b59bc7f94a03ce7891b042a1c76 Mon Sep 17 00:00:00 2001 From: Ole Wieners <olewieners@yahoo.com> Date: Thu, 21 Nov 2024 12:26:56 +0100 Subject: [PATCH 25/26] Modify db dump config --- .github/workflows/upload-db-dump.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/upload-db-dump.yml b/.github/workflows/upload-db-dump.yml index 44bb810a3..654f11781 100644 --- a/.github/workflows/upload-db-dump.yml +++ b/.github/workflows/upload-db-dump.yml @@ -35,6 +35,7 @@ jobs: run: | sed --in-place \ -e 's/host = "http:\/\/localhost:8081"/host = "https:\/\/tobira-test-oc.ethz.ch"/g' \ + -e '/password = "opencast"/a\interpret_eth_passwords = true' \ -e 's/password = "opencast"/password = "${{ secrets.TOBIRA_OPENCAST_ADMIN_PASSWORD }}"/g' \ -e 's/level = "trace"/level = "debug"/g' \ -e '/preferred_harvest_size/d' \ From eee097cdc1940ca2bf3a16ff710b6a627c30b26a Mon Sep 17 00:00:00 2001 From: Ole Wieners <olewieners@yahoo.com> Date: Wed, 27 Nov 2024 21:28:32 +0100 Subject: [PATCH 26/26] Move thumbnail back to synced data --- backend/src/api/common.rs | 6 +- backend/src/api/model/event.rs | 23 ++++--- backend/src/api/model/search/event.rs | 2 +- frontend/src/routes/Embed.tsx | 1 + frontend/src/routes/Search.tsx | 8 --- frontend/src/routes/Video.tsx | 8 +-- .../Realm/Content/Edit/EditMode/Video.tsx | 15 +---- frontend/src/routes/manage/Video/Shared.tsx | 3 +- frontend/src/routes/manage/Video/index.tsx | 15 ++++- frontend/src/schema.graphql | 3 +- frontend/src/ui/Blocks/Video.tsx | 2 +- frontend/src/ui/Blocks/VideoList.tsx | 9 ++- frontend/src/ui/Video.tsx | 66 +++++-------------- frontend/src/ui/player/Paella.tsx | 2 +- frontend/src/ui/player/index.tsx | 4 +- 15 files changed, 71 insertions(+), 96 deletions(-) diff --git a/backend/src/api/common.rs b/backend/src/api/common.rs index e45e7f179..cf348ee37 100644 --- a/backend/src/api/common.rs +++ b/backend/src/api/common.rs @@ -7,9 +7,9 @@ use crate::{ Context, err::{self, ApiResult}, model::{ - event::AuthorizedEvent, - series::Series, - realm::Realm, + event::AuthorizedEvent, + series::Series, + realm::Realm, playlist::AuthorizedPlaylist, search::{SearchEvent, SearchRealm, SearchSeries}, }, diff --git a/backend/src/api/model/event.rs b/backend/src/api/model/event.rs index 0c6a4a9cc..e51bb4b92 100644 --- a/backend/src/api/model/event.rs +++ b/backend/src/api/model/event.rs @@ -51,15 +51,15 @@ pub(crate) struct SyncedEventData { updated: DateTime<Utc>, start_time: Option<DateTime<Utc>>, end_time: Option<DateTime<Utc>>, - + thumbnail: Option<String>, /// Duration in milliseconds duration: i64, + audio_only: bool, } #[derive(Debug)] pub(crate) struct AuthorizedEventData { tracks: Vec<Track>, - thumbnail: Option<String>, captions: Vec<Caption>, segments: Vec<Segment>, } @@ -77,6 +77,7 @@ impl_from_db!( }, }, |row| { + let tracks: Vec<Track> = row.tracks::<Vec<EventTrack>>().into_iter().map(Track::from).collect(); Self { key: row.id(), series: row.series(), @@ -98,13 +99,14 @@ impl_from_db!( start_time: row.start_time(), end_time: row.end_time(), duration: row.duration(), + thumbnail: row.thumbnail(), + audio_only: tracks.iter().all(|t| t.resolution.is_none()), }), EventState::Waiting => None, }, authorized_data: match row.state::<EventState>() { EventState::Ready => Some(AuthorizedEventData { - thumbnail: row.thumbnail(), - tracks: row.tracks::<Vec<EventTrack>>().into_iter().map(Track::from).collect(), + tracks, captions: row.captions::<Vec<EventCaption>>() .into_iter() .map(Caption::from) @@ -165,6 +167,12 @@ impl SyncedEventData { fn duration(&self) -> f64 { self.duration as f64 } + fn thumbnail(&self) -> Option<&str> { + self.thumbnail.as_deref() + } + fn audio_only(&self) -> bool { + self.audio_only + } } /// Represents event data that is only accessible for users with read access @@ -174,9 +182,6 @@ impl AuthorizedEventData { fn tracks(&self) -> &[Track] { &self.tracks } - fn thumbnail(&self) -> Option<&str> { - self.thumbnail.as_deref() - } fn captions(&self) -> &[Caption] { &self.captions } @@ -231,8 +236,8 @@ impl AuthorizedEvent { /// Returns the authorized event data if the user has read access or is authenticated for the event. async fn authorized_data( &self, - context: &Context, - user: Option<String>, + context: &Context, + user: Option<String>, password: Option<String>, ) -> Option<&AuthorizedEventData> { let sha1_matches = |input: &str, encoded: &str| { diff --git a/backend/src/api/model/search/event.rs b/backend/src/api/model/search/event.rs index c92214000..5da38fbb9 100644 --- a/backend/src/api/model/search/event.rs +++ b/backend/src/api/model/search/event.rs @@ -116,7 +116,7 @@ impl SearchEvent { title: src.title, description: src.description, creators: src.creators, - thumbnail: if user_can_read { src.thumbnail } else { None }, + thumbnail: src.thumbnail, duration: src.duration as f64, created: src.created, start_time: src.start_time, diff --git a/frontend/src/routes/Embed.tsx b/frontend/src/routes/Embed.tsx index 730057b37..05752af07 100644 --- a/frontend/src/routes/Embed.tsx +++ b/frontend/src/routes/Embed.tsx @@ -123,6 +123,7 @@ const embedEventFragment = graphql` startTime endTime duration + thumbnail } ... VideoPageAuthorizedData @arguments(eventUser: $eventUser, eventPassword: $eventPassword) diff --git a/frontend/src/routes/Search.tsx b/frontend/src/routes/Search.tsx index f71bf0ff1..5040b01d2 100644 --- a/frontend/src/routes/Search.tsx +++ b/frontend/src/routes/Search.tsx @@ -161,7 +161,6 @@ const query = graphql` startTime endTime created - userIsAuthorized hostRealms { path ancestorNames } textMatches { start @@ -521,7 +520,6 @@ const SearchEvent: React.FC<EventItem> = ({ hostRealms, textMatches, matches, - userIsAuthorized, }) => { // TODO: decide what to do in the case of more than two host realms. Direct // link should be avoided. @@ -534,19 +532,13 @@ const SearchEvent: React.FC<EventItem> = ({ image: <Link to={link} tabIndex={-1}> <Thumbnail event={{ - id, title, isLive, created, - series: seriesId ? { - id: seriesId, - } : null, syncedData: { duration, startTime, endTime, - }, - authorizedData: !userIsAuthorized ? null : { thumbnail, audioOnly, }, diff --git a/frontend/src/routes/Video.tsx b/frontend/src/routes/Video.tsx index bc711256b..f497c8882 100644 --- a/frontend/src/routes/Video.tsx +++ b/frontend/src/routes/Video.tsx @@ -461,6 +461,7 @@ const eventFragment = graphql` duration startTime endTime + thumbnail } ... VideoPageAuthorizedData @arguments(eventUser: $eventUser, eventPassword: $eventPassword) @@ -486,13 +487,12 @@ const authorizedDataFragment = graphql` tracks { uri flavor mimetype resolution isMaster } captions { uri lang } segments { uri startTime } - thumbnail } } `; // Custom query to refetch authorized event data manually. Unfortunately, using -// the fragment here is not enough, we need to also selected `authorizedData` +// the fragment here is not enough, we need to also select `authorizedData` // manually. Without that, we could not access that field below to check if the // credentials were correct. Normally, adding `@relay(mask: false)` to the // fragment should also fix that, but that does not work for some reason. @@ -509,7 +509,7 @@ export const authorizedDataQuery = graphql` @arguments(eventUser: $eventUser, eventPassword: $eventPassword) ...on AuthorizedEvent { authorizedData(user: $eventUser, password: $eventPassword) { - thumbnail + __id } } } @@ -558,7 +558,7 @@ const VideoPage: React.FC<Props> = ({ eventRef, realmRef, playlistRef, basePath "@type": "VideoObject", name: event.title, description: event.description ?? undefined, - thumbnailUrl: event.authorizedData?.thumbnail ?? undefined, + thumbnailUrl: event.syncedData?.thumbnail ?? undefined, uploadDate: event.created, duration: toIsoDuration(event.syncedData.duration), ...event.isLive && event.syncedData.startTime && event.syncedData.endTime && { 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 35dadac04..da40e7e1b 100644 --- a/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx +++ b/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx @@ -47,8 +47,7 @@ export const EditVideoBlock: React.FC<EditVideoBlockProps> = ({ block: blockRef created isLive creators - syncedData { duration startTime endTime } - authorizedData { thumbnail } + syncedData { duration startTime endTime audioOnly } } } showTitle @@ -152,6 +151,7 @@ const EventSelector: React.FC<EventSelectorProps> = ({ onChange, onBlur, default duration startTime endTime + audioOnly } } } @@ -204,16 +204,7 @@ const formatOption = (event: Option, t: TFunction) => ( ? <MovingTruck /> : <Thumbnail css={{ width: 120, minWidth: 120 }} - event={{ - ...event, - syncedData: event.syncedData && { - ...event.syncedData, - }, - authorizedData: event.authorizedData && { - ...event.authorizedData, - audioOnly: false, // TODO - }, - }} + event={event} />} <div> <div>{event.title}</div> diff --git a/frontend/src/routes/manage/Video/Shared.tsx b/frontend/src/routes/manage/Video/Shared.tsx index 70bd5c0fd..1689ad53f 100644 --- a/frontend/src/routes/manage/Video/Shared.tsx +++ b/frontend/src/routes/manage/Video/Shared.tsx @@ -92,9 +92,10 @@ const query = graphql` updated startTime endTime + thumbnail + audioOnly } authorizedData { - thumbnail tracks { flavor resolution mimetype uri } } series { diff --git a/frontend/src/routes/manage/Video/index.tsx b/frontend/src/routes/manage/Video/index.tsx index 167d68cae..22e943fd6 100644 --- a/frontend/src/routes/manage/Video/index.tsx +++ b/frontend/src/routes/manage/Video/index.tsx @@ -76,13 +76,22 @@ const query = graphql` startIndex endIndex } items { - id title created description isLive tobiraDeletionTimestamp + id + title + created + description + isLive + tobiraDeletionTimestamp series { id } syncedData { - duration updated startTime endTime + duration + thumbnail + updated + startTime + endTime + audioOnly } authorizedData { - thumbnail tracks { resolution } } } diff --git a/frontend/src/schema.graphql b/frontend/src/schema.graphql index c5527ca67..88d766a48 100644 --- a/frontend/src/schema.graphql +++ b/frontend/src/schema.graphql @@ -215,6 +215,8 @@ type SyncedEventData implements Node { endTime: DateTimeUtc "Duration in ms." duration: Float! + thumbnail: String + audioOnly: Boolean! } input NewPlaylistBlock { @@ -766,7 +768,6 @@ union RemoveMountedSeriesOutcome = RemovedRealm | RemovedBlock """ type AuthorizedEventData implements Node { tracks: [Track!]! - thumbnail: String captions: [Caption!]! segments: [Segment!]! } diff --git a/frontend/src/ui/Blocks/Video.tsx b/frontend/src/ui/Blocks/Video.tsx index 5726a0ba6..a7fafec53 100644 --- a/frontend/src/ui/Blocks/Video.tsx +++ b/frontend/src/ui/Blocks/Video.tsx @@ -44,6 +44,7 @@ export const VideoBlock: React.FC<Props> = ({ fragRef, basePath }) => { updated startTime endTime + thumbnail } ... VideoPageAuthorizedData } @@ -65,7 +66,6 @@ export const VideoBlock: React.FC<Props> = ({ fragRef, basePath }) => { return unreachable(); } - return <div css={{ maxWidth: 800 }}> {showTitle && <Title title={event.title} />} <PlayerContextProvider> diff --git a/frontend/src/ui/Blocks/VideoList.tsx b/frontend/src/ui/Blocks/VideoList.tsx index 6a1c13dba..dd826335a 100644 --- a/frontend/src/ui/Blocks/VideoList.tsx +++ b/frontend/src/ui/Blocks/VideoList.tsx @@ -56,9 +56,14 @@ export const videoListEventFragment = graphql` isLive description series { title id } - syncedData { duration startTime endTime } - authorizedData { + syncedData { thumbnail + duration + startTime + endTime + audioOnly + } + authorizedData { tracks { resolution } } } diff --git a/frontend/src/ui/Video.tsx b/frontend/src/ui/Video.tsx index 0a80fe5ac..f0e4eca3f 100644 --- a/frontend/src/ui/Video.tsx +++ b/frontend/src/ui/Video.tsx @@ -1,14 +1,6 @@ import { PropsWithChildren, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - LuAlertTriangle, - LuFilm, - LuLock, - LuRadio, - LuTrash, - LuUserCircle, - LuVolume2, -} from "react-icons/lu"; +import { LuAlertTriangle, LuFilm, LuRadio, LuTrash, LuUserCircle, LuVolume2 } from "react-icons/lu"; import { useColorScheme } from "@opencast/appkit"; import { COLORS } from "../color"; @@ -17,27 +9,16 @@ import { COLORS } from "../color"; type ThumbnailProps = JSX.IntrinsicElements["div"] & { /** The event of which a thumbnail should be shown */ event: { - id: string; title: string; isLive: boolean; created: string; - series?: { - id: string; - } | null; syncedData?: { duration: number; + thumbnail?: string | null; startTime?: string | null; endTime?: string | null; + audioOnly?: boolean; } | null; - authorizedData?: { - thumbnail?: string | null; - } & ( - { - tracks: readonly { resolution?: readonly number[] | null }[]; - } | { - audioOnly: boolean; - } - ) | null; }; /** If `true`, an indicator overlay is shown */ @@ -56,31 +37,21 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ const { t } = useTranslation(); const isDark = useColorScheme().scheme === "dark"; const isUpcoming = isUpcomingLiveEvent(event.syncedData?.startTime ?? null, event.isLive); - const audioOnly = event.authorizedData - ? ( - "audioOnly" in event.authorizedData - ? event.authorizedData.audioOnly - : event.authorizedData.tracks.every(t => t.resolution == null) - ) - : false; let inner; - if (event.authorizedData?.thumbnail && !deletionIsPending) { + if (event.syncedData?.thumbnail && !deletionIsPending) { // We have a proper thumbnail. inner = <ThumbnailImg - src={event.authorizedData.thumbnail} + src={event.syncedData.thumbnail} alt={t("video.thumbnail-for", { video: event.title })} />; } else { - inner = <ThumbnailReplacement - {...{ audioOnly, isUpcoming, isDark, deletionIsPending }} - previewOnly={!event.authorizedData} - />; + inner = <ThumbnailReplacement audioOnly={event.syncedData?.audioOnly} {...{ + isUpcoming, isDark, deletionIsPending, + }}/>; } let overlay; - let innerOverlay; - let backgroundColor = "hsla(0, 0%, 0%, 0.75)"; if (deletionIsPending) { overlay = null; } else if (event.isLive) { @@ -90,7 +61,9 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ const endTime = event.syncedData?.endTime; const hasEnded = endTime == null ? null : new Date(endTime) < now; const hasStarted = startTime < now; + const currentlyLive = hasStarted && !hasEnded; + let innerOverlay; if (hasEnded) { innerOverlay = t("video.ended"); } else if (hasStarted) { @@ -98,18 +71,19 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ <LuRadio css={{ fontSize: 19, strokeWidth: 1.4 }} /> {t("video.live")} </>; - backgroundColor = "rgba(200, 0, 0, 0.9)"; } else { innerOverlay = t("video.upcoming"); } - } else if (event.syncedData) { - innerOverlay = formatDuration(event.syncedData.duration); - } - if (innerOverlay) { + const backgroundColor = currentlyLive ? "rgba(200, 0, 0, 0.9)" : "hsla(0, 0%, 0%, 0.75)"; + overlay = <ThumbnailOverlay {...{ backgroundColor }}> {innerOverlay} </ThumbnailOverlay>; + } else if (event.syncedData) { + overlay = <ThumbnailOverlay backgroundColor="hsla(0, 0%, 0%, 0.75)"> + {formatDuration(event.syncedData.duration)} + </ThumbnailOverlay>; } return <ThumbnailOverlayContainer {...rest}> @@ -120,14 +94,13 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ }; type ThumbnailReplacementProps = { - audioOnly: boolean; + audioOnly?: boolean; isDark: boolean; isUpcoming?: boolean; deletionIsPending?: boolean; - previewOnly?: boolean; } export const ThumbnailReplacement: React.FC<ThumbnailReplacementProps> = ( - { audioOnly, isDark, isUpcoming, deletionIsPending, previewOnly } + { audioOnly, isDark, isUpcoming, deletionIsPending } ) => { // We have no thumbnail. If the resolution is `null` as well, we are // dealing with an audio-only event and show an appropriate icon. @@ -138,9 +111,6 @@ export const ThumbnailReplacement: React.FC<ThumbnailReplacementProps> = ( if (audioOnly) { icon = <LuVolume2 />; } - if (previewOnly) { - icon = <LuLock />; - } if (deletionIsPending) { icon = <LuTrash />; } diff --git a/frontend/src/ui/player/Paella.tsx b/frontend/src/ui/player/Paella.tsx index 2afea116c..6ae29cfdd 100644 --- a/frontend/src/ui/player/Paella.tsx +++ b/frontend/src/ui/player/Paella.tsx @@ -69,7 +69,7 @@ const PaellaPlayer: React.FC<PaellaPlayerProps> = ({ event }) => { metadata: { title: event.title, duration: fixedDuration, - preview: event.authorizedData.thumbnail, + preview: event.syncedData.thumbnail, // These are not strictly necessary for Paella to know, but can be used by // plugins, like the Matomo plugin. It is not well defined what to pass how, diff --git a/frontend/src/ui/player/index.tsx b/frontend/src/ui/player/index.tsx index 78b0f3a6d..9ae0e6b1c 100644 --- a/frontend/src/ui/player/index.tsx +++ b/frontend/src/ui/player/index.tsx @@ -39,11 +39,11 @@ export type PlayerEvent = { startTime?: string | null; endTime?: string | null; duration: number; + thumbnail?: string | null; }; authorizedData: { tracks: readonly Track[]; captions: readonly Caption[]; - thumbnail?: string | null; segments: readonly Segment[]; }; }; @@ -98,7 +98,7 @@ export const Player: React.FC<PlayerProps> = ({ event, onEventStateChange }) => }); return ( - <Suspense fallback={<PlayerFallback image={event.authorizedData?.thumbnail} />}> + <Suspense fallback={<PlayerFallback image={event.syncedData?.thumbnail} />}> {event.isLive && (hasStarted === false || hasEnded === true) ? <LiveEventPlaceholder {...{ ...hasStarted === false