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/.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' \ 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/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 a69487d90..e51bb4b92 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::*, @@ -37,8 +38,11 @@ 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) credentials: Option, pub(crate) synced_data: Option, + pub(crate) authorized_data: Option, pub(crate) tobira_deletion_timestamp: Option>, } @@ -47,11 +51,15 @@ pub(crate) struct SyncedEventData { updated: DateTime, start_time: Option>, end_time: Option>, - + thumbnail: Option, /// Duration in milliseconds duration: i64, + audio_only: bool, +} + +#[derive(Debug)] +pub(crate) struct AuthorizedEventData { tracks: Vec, - thumbnail: Option, captions: Vec, segments: Vec, } @@ -64,11 +72,12 @@ 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, credentials, tobira_deletion_timestamp, }, }, |row| { + let tracks: Vec = row.tracks::>().into_iter().map(Track::from).collect(); Self { key: row.id(), series: row.series(), @@ -81,6 +90,8 @@ impl_from_db!( metadata: row.metadata(), read_roles: row.read_roles::>(), write_roles: row.write_roles::>(), + preview_roles: row.preview_roles::>(), + credentials: row.credentials(), tobira_deletion_timestamp: row.tobira_deletion_timestamp(), synced_data: match row.state::() { EventState::Ready => Some(SyncedEventData { @@ -89,7 +100,13 @@ impl_from_db!( end_time: row.end_time(), duration: row.duration(), thumbnail: row.thumbnail(), - tracks: row.tracks::>().into_iter().map(Track::from).collect(), + audio_only: tracks.iter().all(|t| t.resolution.is_none()), + }), + EventState::Waiting => None, + }, + authorized_data: match row.state::() { + EventState::Ready => Some(AuthorizedEventData { + tracks, captions: row.captions::>() .into_iter() .map(Caption::from) @@ -150,12 +167,21 @@ impl SyncedEventData { fn duration(&self) -> f64 { self.duration as f64 } - fn tracks(&self) -> &[Track] { - &self.tracks - } 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 +/// and event-specific authenticated users. +#[graphql_object(Context = Context, impl = NodeValue)] +impl AuthorizedEventData { + fn tracks(&self) -> &[Track] { + &self.tracks + } fn captions(&self) -> &[Caption] { &self.captions } @@ -198,11 +224,42 @@ impl AuthorizedEvent { fn write_roles(&self) -> &[String] { &self.write_roles } + /// This doesn't contain `ROLE_ADMIN` as that is included implicitly. + fn preview_roles(&self) -> &[String] { + &self.preview_roles + } fn synced_data(&self) -> &Option { &self.synced_data } + /// 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, + password: Option, + ) -> 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 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) || credentials_match { + 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) @@ -238,6 +295,12 @@ impl AuthorizedEvent { ).await?.pipe(Ok) } + + /// Whether this event is password protected. + async fn has_password(&self) -> bool { + self.credentials.is_some() + } + async fn acl(&self, context: &Context) -> ApiResult { let raw_roles_sql = "\ select unnest(read_roles) as role, 'read' as action from events where id = $1 @@ -306,7 +369,7 @@ impl AuthorizedEvent { .await? .map(|row| { let event = Self::from_row_start(&row); - if context.auth.overlaps_roles(&event.read_roles) { + if event.can_be_previewed(context) { Event::Event(event) } else { Event::NotAllowed(NotAllowed) @@ -327,7 +390,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 !event.can_be_previewed(context) { return VideoListEntry::NotAllowed(NotAllowed); } @@ -337,6 +400,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 { let event = Self::load_by_id(id, context) .await? diff --git a/backend/src/api/model/search/event.rs b/backend/src/api/model/search/event.rs index c77e4774d..5da38fbb9 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}; @@ -28,6 +29,8 @@ pub(crate) struct SearchEvent { pub host_realms: Vec, pub text_matches: Vec, pub matches: SearchEventMatches, + pub has_password: bool, + pub user_is_authorized: bool, } #[derive(Debug, GraphQLObject, Default)] @@ -65,25 +68,31 @@ 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 { + 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) -> Self { + pub(crate) fn new(hit: meilisearch_sdk::SearchResult, context: &Context) -> Self { let match_positions = hit.matches_position.as_ref(); 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"), @@ -91,13 +100,14 @@ 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, user_can_read) } fn new_inner( src: search::Event, text_matches: Vec, matches: SearchEventMatches, + user_can_read: bool, ) -> Self { Self { id: Id::search_event(src.id.0), @@ -118,6 +128,8 @@ impl SearchEvent { .collect(), text_matches, matches, + has_password: src.has_password, + user_is_authorized: user_can_read, } } } diff --git a/backend/src/api/model/search/mod.rs b/backend/src/api/model/search/mod.rs index 96ec3551e..f92c58ffb 100644 --- a/backend/src/api/model/search/mod.rs +++ b/backend/src/api/model/search/mod.rs @@ -166,13 +166,13 @@ 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 || read_roles || 'ROLE_ADMIN'::text) && $2"); let items: Vec = context.db .query_opt(&query, &[&uuid_query, &context.auth.roles_vec()]) .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(); @@ -186,18 +186,22 @@ 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)) - // 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) @@ -243,7 +247,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; @@ -324,15 +328,18 @@ 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::listed_and_readable(context), writable]) + Filter::or([ + Filter::preview_or_read_access(context).and_listed(context), + writable, + ]) } }).to_string(); @@ -346,7 +353,7 @@ pub(crate) async fn all_events( } let res = query.execute::().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() })) @@ -370,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. @@ -382,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()]) } @@ -421,15 +428,18 @@ 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::listed_and_readable(context), writable]) + Filter::or([ + Filter::read_access(context).and_listed(context), + writable, + ]) } }).to_string(); @@ -449,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 { - // 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), + + /// Must not contain `Filter::None`, which is handled by `Filter::or`. Or(Vec), 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) -> 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) -> Self { - Self::And(operands.into_iter().collect()) + Self::And( + operands.into_iter() + .filter(|op| !matches!(op, Self::True)) + .collect(), + ) } /// Returns the filter "listed = true". @@ -489,22 +506,35 @@ 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. - fn listed_and_readable(context: &Context) -> Self { - let readable = Self::acl_access("read_roles", 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()]) } } + 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"; @@ -531,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 { @@ -549,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(()), } } } 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/auth/mod.rs b/backend/src/auth/mod.rs index 6109663e6..7a7671252 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -5,12 +5,14 @@ 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; use crate::{ api::err::{not_authorized, ApiError}, + config::Config, db::util::select, http::{response, Context, Response}, prelude::*, @@ -38,7 +40,28 @@ 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 = 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 = 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"; const SESSION_COOKIE: &str = "tobira-session"; diff --git a/backend/src/db/migrations.rs b/backend/src/db/migrations.rs index 812802c9a..74b73c47d 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: "preview-roles-and-credentials", ]; diff --git a/backend/src/db/migrations/39-preview-roles-and-credentials.sql b/backend/src/db/migrations/39-preview-roles-and-credentials.sql new file mode 100644 index 000000000..565f53b39 --- /dev/null +++ b/backend/src/db/migrations/39-preview-roles-and-credentials.sql @@ -0,0 +1,75 @@ +-- 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). + +-- `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 `:` + password text -- as `:` +); + +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; + +-- add `preview_roles` and `has_password` to `search_events` view +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, + (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 + -- 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/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 { ::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/search/event.rs b/backend/src/search/event.rs index 588b473b1..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, 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 @@ -49,6 +50,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 +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, host_realms, slide_texts, caption_texts, + read_roles, write_roles, preview_roles, has_password, + host_realms, slide_texts, caption_texts, }, }, |row| { @@ -100,6 +103,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()), @@ -108,6 +112,7 @@ impl_from_db!( .unwrap_or_else(TextSearchIndex::empty), caption_texts: row.caption_texts::>() .unwrap_or_else(TextSearchIndex::empty), + has_password: row.has_password(), } } ); @@ -135,8 +140,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/backend/src/search/mod.rs b/backend/src/search/mod.rs index d9431508c..276e631db 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; @@ -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 ============================================================================ 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 { .collect() } +/// Decodes hex encoded ACL roles. +pub(crate) fn decode_acl(roles: &[String]) -> Vec { + 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) diff --git a/backend/src/sync/harvest/mod.rs b/backend/src/sync/harvest/mod.rs index 8195c7bf9..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, + auth::{is_special_eth_role, ROLE_ADMIN, ROLE_ANONYMOUS, ETH_ROLE_CREDENTIALS_RE}, config::Config, db::{ - self, - DbConnection, - types::{EventCaption, EventSegment, EventState, EventTrack, SeriesState}, + self, types::{Credentials, EventCaption, EventSegment, EventState, EventTrack, SeriesState}, DbConnection }, prelude::*, }; @@ -88,7 +86,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 +130,7 @@ async fn store_in_db( items: Vec, sync_status: &SyncStatus, db: &mut deadpool_postgres::Transaction<'_>, + config: &Config, ) -> Result<()> { let before = Instant::now(); let mut upserted_events = 0; @@ -180,10 +179,34 @@ 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 && !is_special_eth_role(role, config) + }; + // We always handle the admin role in a special way, so no need // to store it for every single event. - 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); @@ -210,6 +233,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), @@ -217,6 +241,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); @@ -236,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), @@ -251,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 @@ -379,6 +412,15 @@ fn check_affected_rows_removed(rows_affected: u64, entity: &str, opencast_id: &s } } +fn hashed_eth_credentials(read_roles: &[String]) -> Option { + 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/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, #[serde(default)] pub(crate) write: Vec, + #[serde(default)] + pub(crate) preview: Vec, #[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(), 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/i18n/locales/de.yaml b/frontend/src/i18n/locales/de.yaml index 401bc2b9b..c4b16aa86 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: > @@ -178,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 3dd91daf6..9b0757e6e 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: > @@ -175,6 +177,18 @@ video: embed: Embed rss: RSS show-qr-code: Show QR code + 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. + 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/layout/header/UserBox.tsx b/frontend/src/layout/header/UserBox.tsx index e542bac19..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"; @@ -175,6 +176,10 @@ const LoggedIn: React.FC = ({ user }) => { return; } + Object.keys(window.localStorage) + .filter(item => item.startsWith(CREDENTIALS_STORAGE_KEY)) + .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 79908e81b..05752af07 100644 --- a/frontend/src/routes/Embed.tsx +++ b/frontend/src/routes/Embed.tsx @@ -2,11 +2,12 @@ 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, + GraphQLTaggedNode, PreloadedQuery, } 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"; @@ -18,6 +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, useEventWithAuthData } from "./Video"; export const EmbedVideoRoute = makeRoute({ url: ({ videoId }: { videoId: string }) => `/~embed/!v/${keyOfId(videoId)}`, @@ -27,15 +29,21 @@ 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 creds = getCredentials("event", id); + const queryRef = loadQuery(query, { + id, + eventUser: creds?.user, + eventPassword: creds?.password, + }); - const queryRef = loadQuery(query, { id: eventId(videoId) }); return matchedEmbedRoute(query, queryRef); }, @@ -51,13 +59,22 @@ 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(query, { id: videoId }); + const creds = getCredentials("oc-event", videoId); + const queryRef = loadQuery(query, { + id: videoId, + eventUser: creds?.user, + eventPassword: creds?.password, + }); return matchedEmbedRoute(query, queryRef); }, @@ -90,6 +107,7 @@ const embedEventFragment = graphql` __typename ... on NotAllowed { dummy } ... on AuthorizedEvent { + id title created isLive @@ -97,17 +115,18 @@ const embedEventFragment = graphql` creators metadata description - series { title opencastId } + canWrite + hasPassword + series { title id opencastId } syncedData { updated startTime endTime duration thumbnail - tracks { uri flavor mimetype resolution isMaster } - captions { uri lang } - segments { uri startTime } } + ... VideoPageAuthorizedData + @arguments(eventUser: $eventUser, eventPassword: $eventPassword) } } `; @@ -120,10 +139,11 @@ type EmbedProps = { const Embed: React.FC = ({ query, queryRef }) => { const fragmentRef = usePreloadedQuery(query, queryRef); - const event = useFragment( + const protoEvent = useFragment( embedEventFragment, fragmentRef.event, ); + const [event, refetch] = useEventWithAuthData(protoEvent); const { t } = useTranslation(); if (!event) { @@ -151,7 +171,9 @@ const Embed: React.FC = ({ query, queryRef }) => { ; } - return ; + return event.authorizedData + ? + : ; }; 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 = () => { >{t("general.action.back")}; }; -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(); - 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("idle"); + const [state, setState] = useState("idle"); const [loginError, setLoginError] = useState(null); const onSubmit = async (data: FormData) => { @@ -171,15 +164,20 @@ const LoginBox: React.FC = () => { }; return ( -
+ {CONFIG.auth.loginPageNote && (
{ padding: "8px 16px", }}>{translatedConfig(CONFIG.auth.loginPageNote, i18n)}
)} +
+ ); +}; +type AuthenticationFormProps = PropsWithChildren & { + onSubmit: (data: FormData) => Promise | void; + state: AuthenticationFormState; + error: string | null; + className?: string; + SubmitIcon: IconType; + labels: { + user: string; + password: string; + submit: string; + }; +} + +export const AuthenticationForm: React.FC = ({ + onSubmit, + state, + error, + className, + SubmitIcon, + labels, + children, +}) => { + const { t } = useTranslation(); + const isDark = useColorScheme().scheme === "dark"; + const { register, handleSubmit, watch, formState: { errors } } = useForm(); + 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 ( +
+ {children}
{
{
{ ...focusStyle({ offset: 1 }), }} > - - {t("user.login")} + + {labels.submit} {match(state, { "idle": () => null, "pending": () => , @@ -262,7 +302,7 @@ const LoginBox: React.FC = () => { })} - {loginError &&
{loginError}
} + {error && {error}}
); diff --git a/frontend/src/routes/Search.tsx b/frontend/src/routes/Search.tsx index 8d9b62ac5..5040b01d2 100644 --- a/frontend/src/routes/Search.tsx +++ b/frontend/src/routes/Search.tsx @@ -49,7 +49,12 @@ 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, + isExperimentalFlagSet, + keyOfId, + secondsToTimeString, +} from "../util"; import { DirectVideoRoute, VideoRoute } from "./Video"; import { DirectSeriesRoute, SeriesRoute } from "./Series"; import { PartOfSeriesLink } from "../ui/Blocks/VideoList"; @@ -531,10 +536,10 @@ const SearchEvent: React.FC = ({ isLive, created, syncedData: { - thumbnail, duration, startTime, endTime, + thumbnail, audioOnly, }, }} @@ -627,11 +632,11 @@ type TextMatchTimelineProps = Pick }; const slidePreviewQuery = graphql` - query SearchSlidePreviewQuery($id: ID!) { + query SearchSlidePreviewQuery($id: ID!, $user: String, $password: String) { eventById(id: $id) { ...on AuthorizedEvent { id - syncedData { + authorizedData(user: $user, password: $password) { segments { startTime uri } } } @@ -750,7 +755,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..f497c8882 100644 --- a/frontend/src/routes/Video.tsx +++ b/frontend/src/routes/Video.tsx @@ -1,22 +1,33 @@ -import React, { ReactElement, ReactNode, useEffect, useRef, useState } from "react"; -import { graphql, GraphQLTaggedNode, PreloadedQuery, useFragment } from "react-relay/hooks"; +import React, { + ReactElement, + ReactNode, + useEffect, + useRef, + useState, +} from "react"; +import { + graphql, GraphQLTaggedNode, PreloadedQuery, RefetchFnDynamic, useFragment, + useRefetchableFragment, +} 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 { fetchQuery, OperationType } from "relay-runtime"; +import { + 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, + notNullish, } 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 } 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"; @@ -33,12 +44,15 @@ import { eventId, keyOfId, playlistId, + getCredentials, + credentialsStorageKey, + Credentials, } 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"; @@ -72,6 +86,14 @@ 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, +} from "./__generated__/VideoAuthorizedDataQuery.graphql"; +import { AuthorizedBlockEvent } from "../ui/Blocks/Video"; +import { + VideoPageAuthorizedData$data, VideoPageAuthorizedData$key, +} from "./__generated__/VideoPageAuthorizedData.graphql"; // =========================================================================================== @@ -88,12 +110,20 @@ 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 + @arguments(eventUser: $eventUser, eventPassword: $eventPassword) ... on AuthorizedEvent { isReferencedByRealm(path: $realmPath) } @@ -106,10 +136,13 @@ export const VideoRoute = makeRoute({ } `; + const creds = getCredentials("event", id); const queryRef = loadQuery(query, { - id: eventId(videoId), + id, realmPath, listId, + eventUser: creds?.user, + eventPassword: creds?.password, }); return { @@ -117,11 +150,7 @@ export const VideoRoute = makeRoute({ {... { query, queryRef }} nav={data => data.realm ?
@@ -740,9 +1121,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..da40e7e1b 100644 --- a/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx +++ b/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx @@ -43,11 +43,11 @@ export const EditVideoBlock: React.FC = ({ block: blockRef ... on AuthorizedEvent { id title - series { title } + series { id title } created isLive creators - syncedData { thumbnail duration startTime endTime } + syncedData { duration startTime endTime audioOnly } } } showTitle @@ -81,7 +81,12 @@ export const EditVideoBlock: React.FC = ({ block: blockRef const { formState: { errors } } = form; const currentEvent = event?.__typename === "AuthorizedEvent" - ? { ...event, ...event.syncedData, seriesTitle: event.series?.title } + ? { + ...event, + ...event.syncedData, + seriesId: event.series?.id, + seriesTitle: event.series?.title, + } : undefined; return data}> @@ -137,6 +142,7 @@ const EventSelector: React.FC = ({ onChange, onBlur, default items { id title + seriesId seriesTitle creators thumbnail @@ -145,6 +151,7 @@ const EventSelector: React.FC = ({ onChange, onBlur, default duration startTime endTime + audioOnly } } } @@ -166,7 +173,11 @@ const EventSelector: React.FC = ({ onChange, onBlur, default // starting with `ev`. id: item.id.replace(/^es/, "ev"), syncedData: item, - series: item.seriesTitle == null ? null : { title: item.seriesTitle }, + authorizedData: item, + series: (item.seriesTitle == null || item.seriesId == null) ? null : { + id: item.seriesId, + title: item.seriesTitle, + }, }))); }, start: () => {}, @@ -193,13 +204,7 @@ const formatOption = (event: Option, t: TFunction) => ( ? : }
{event.title}
diff --git a/frontend/src/routes/manage/Video/Shared.tsx b/frontend/src/routes/manage/Video/Shared.tsx index 7f9f13ac9..1689ad53f 100644 --- a/frontend/src/routes/manage/Video/Shared.tsx +++ b/frontend/src/routes/manage/Video/Shared.tsx @@ -89,13 +89,17 @@ const query = graphql` acl { role actions info { label implies large } } syncedData { duration - thumbnail updated startTime endTime + thumbnail + audioOnly + } + authorizedData { tracks { flavor resolution mimetype uri } } series { + id title opencastId ...SeriesBlockSeriesData 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..22e943fd6 100644 --- a/frontend/src/routes/manage/Video/index.tsx +++ b/frontend/src/routes/manage/Video/index.tsx @@ -76,9 +76,22 @@ const query = graphql` startIndex endIndex } items { - id title created description isLive tobiraDeletionTimestamp + id + title + created + description + isLive + tobiraDeletionTimestamp + series { id } syncedData { - duration thumbnail updated startTime endTime + duration + thumbnail + updated + startTime + endTime + audioOnly + } + authorizedData { tracks { resolution } } } diff --git a/frontend/src/schema.graphql b/frontend/src/schema.graphql index e9abdd11e..88d766a48 100644 --- a/frontend/src/schema.graphql +++ b/frontend/src/schema.graphql @@ -102,6 +102,8 @@ type SearchEvent implements Node { hostRealms: [SearchRealm!]! textMatches: [TextMatch!]! matches: SearchEventMatches! + hasPassword: Boolean! + userIsAuthorized: Boolean! } input ChildIndex { @@ -213,10 +215,8 @@ type SyncedEventData implements Node { endTime: DateTimeUtc "Duration in ms." duration: Float! - tracks: [Track!]! thumbnail: String - captions: [Caption!]! - segments: [Segment!]! + audioOnly: Boolean! } input NewPlaylistBlock { @@ -317,13 +317,19 @@ type AuthorizedEvent implements Node { readRoles: [String!]! "This doesn't contain `ROLE_ADMIN` as that is included implicitly." writeRoles: [String!]! + "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." + 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 @@ -394,11 +400,6 @@ input UpdateTextBlock { content: String } -type ByteSpan { - start: Int! - len: Int! -} - union Playlist = AuthorizedPlaylist | NotAllowed type Realm implements Node { @@ -492,6 +493,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 @@ -703,7 +709,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! @@ -756,6 +762,16 @@ type EmptyQuery { union RemoveMountedSeriesOutcome = RemovedRealm | RemovedBlock +""" + Represents event data that is only accessible for users with read access + and event-specific authenticated users. +""" +type AuthorizedEventData implements Node { + tracks: [Track!]! + 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..a7fafec53 100644 --- a/frontend/src/ui/Blocks/Video.tsx +++ b/frontend/src/ui/Blocks/Video.tsx @@ -2,15 +2,19 @@ import { graphql, useFragment } 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 { PreviewPlaceholder, useEventWithAuthData } from "../../routes/Video"; +export type BlockEvent = VideoBlockData$data["event"]; +export type AuthorizedBlockEvent = Extract; + type Props = { fragRef: VideoBlockData$key; basePath: string; @@ -18,7 +22,7 @@ type Props = { export const VideoBlock: React.FC = ({ fragRef, basePath }) => { const { t } = useTranslation(); - const { event, showTitle, showLink } = useFragment(graphql` + const { event: protoEvent, showTitle, showLink } = useFragment(graphql` fragment VideoBlockData on VideoBlock { event { __typename @@ -32,23 +36,24 @@ export const VideoBlock: React.FC = ({ fragRef, basePath }) => { creators metadata description - series { title opencastId } + canWrite + hasPassword + series { title id opencastId } syncedData { duration updated startTime endTime 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 {t("video.deleted-video-block")}; @@ -63,11 +68,16 @@ export const VideoBlock: React.FC = ({ fragRef, basePath }) => { return
{showTitle && } - {isSynced(event) - ? <PlayerContextProvider> - <InlinePlayer event={event} css={{ maxWidth: 800 }} /> - </PlayerContextProvider> - : <Card kind="info">{t("video.not-ready.title")}</Card>} + <PlayerContextProvider> + {event.authorizedData && isSynced(event) + ? <InlinePlayer + event={{ ...event, authorizedData: event.authorizedData }} + css={{ margin: "-4px auto 0" }} + /> + : <PreviewPlaceholder {...{ event, refetch }} /> + } + </PlayerContextProvider> + {showLink && <Link to={`${basePath}/${keyOfId(event.id)}`} css={{ diff --git a/frontend/src/ui/Blocks/VideoList.tsx b/frontend/src/ui/Blocks/VideoList.tsx index 32f3c1ffa..dd826335a 100644 --- a/frontend/src/ui/Blocks/VideoList.tsx +++ b/frontend/src/ui/Blocks/VideoList.tsx @@ -57,10 +57,13 @@ export const videoListEventFragment = graphql` description series { title id } syncedData { - duration thumbnail + duration startTime endTime + audioOnly + } + authorizedData { tracks { resolution } } } diff --git a/frontend/src/ui/Video.tsx b/frontend/src/ui/Video.tsx index 12f8c78be..f0e4eca3f 100644 --- a/frontend/src/ui/Video.tsx +++ b/frontend/src/ui/Video.tsx @@ -17,13 +17,8 @@ type ThumbnailProps = JSX.IntrinsicElements["div"] & { thumbnail?: string | null; startTime?: string | null; endTime?: string | null; - } & ( - { - tracks: readonly { resolution?: readonly number[] | null }[]; - } | { - audioOnly: boolean; - } - ) | null; + audioOnly?: boolean; + } | null; }; /** If `true`, an indicator overlay is shown */ @@ -42,23 +37,18 @@ 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 - ? ( - "audioOnly" in event.syncedData - ? event.syncedData.audioOnly - : event.syncedData.tracks.every(t => t.resolution == null) - ) - : false; let inner; - if (event.syncedData?.thumbnail != null && !deletionIsPending) { + if (event.syncedData?.thumbnail && !deletionIsPending) { // We have a proper thumbnail. inner = <ThumbnailImg src={event.syncedData.thumbnail} alt={t("video.thumbnail-for", { video: event.title })} />; } else { - inner = <ThumbnailReplacement {...{ audioOnly, isUpcoming, isDark, deletionIsPending }} />; + inner = <ThumbnailReplacement audioOnly={event.syncedData?.audioOnly} {...{ + isUpcoming, isDark, deletionIsPending, + }}/>; } let overlay; @@ -104,7 +94,7 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ }; type ThumbnailReplacementProps = { - audioOnly: boolean; + audioOnly?: boolean; isDark: boolean; isUpcoming?: boolean; deletionIsPending?: boolean; diff --git a/frontend/src/ui/player/Paella.tsx b/frontend/src/ui/player/Paella.tsx index 6f13b772e..6ae29cfdd 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] = []; @@ -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..9ae0e6b1c 100644 --- a/frontend/src/ui/player/index.tsx +++ b/frontend/src/ui/player/index.tsx @@ -39,9 +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[]; }; }; @@ -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.syncedData?.thumbnail} />}> {event.isLive && (hasStarted === false || hasEnded === true) ? <LiveEventPlaceholder {...{ ...hasStarted === false @@ -125,7 +127,10 @@ 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); + if (!event.authorizedData) { + return null; + } + const aspectRatio = getPlayerAspectRatio(event.authorizedData.tracks); const isDark = useColorScheme().scheme === "dark"; const ref = useRef<HTMLDivElement>(null); @@ -169,9 +174,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%", @@ -273,8 +280,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)]: { "& > *": { diff --git a/frontend/src/util/index.ts b/frontend/src/util/index.ts index ad1e811ec..060454fb4 100644 --- a/frontend/src/util/index.ts +++ b/frontend/src/util/index.ts @@ -5,6 +5,7 @@ import { bug, match } from "@opencast/appkit"; import CONFIG, { TranslatedString } from "../config"; import { TimeUnit } from "../ui/Input"; +import { CREDENTIALS_STORAGE_KEY } from "../routes/Video"; /** @@ -215,3 +216,47 @@ export const secondsToTimeString = (seconds: number): string => { }; export type ExtraMetadata = Record<string, Record<string, string[]>>; + +export type Credentials = { + user: string; + password: string; +} | null; + + +/** + * 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. + */ +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)); + + 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) => + CREDENTIALS_STORAGE_KEY + kind + "-" + id; 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"