Skip to content

Commit

Permalink
Merge pull request #1 from LukasKalbertodt/password-protected-videos
Browse files Browse the repository at this point in the history
Password protected videos
  • Loading branch information
owi92 authored Nov 26, 2024
2 parents 45b86b6 + 4031e07 commit 1c9f4bc
Show file tree
Hide file tree
Showing 16 changed files with 423 additions and 440 deletions.
12 changes: 8 additions & 4 deletions backend/src/api/model/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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);
}

Expand All @@ -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?
Expand Down
46 changes: 21 additions & 25 deletions backend/src/api/model/search/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,58 +69,54 @@ 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 {
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"),
description: field_matches_for(match_positions, "description"),
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)),
series_title: src.series_title,
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,
Expand All @@ -133,7 +129,7 @@ impl SearchEvent {
text_matches,
matches,
has_password: src.has_password,
user_is_authorized,
user_is_authorized: user_can_read,
}
}
}
136 changes: 81 additions & 55 deletions backend/src/api/model/search/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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("preview_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)
Expand Down Expand Up @@ -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("preview_roles", context), writable])
Filter::or([
Filter::preview_or_read_access(context).and_listed(context),
writable,
])
}
}).to_string();

Expand Down Expand Up @@ -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.
Expand All @@ -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()])
}
Expand Down Expand Up @@ -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("read_roles", context), writable])
Filter::or([
Filter::read_access(context).and_listed(context),
writable,
])
}
}).to_string();

Expand All @@ -449,64 +459,82 @@ 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".
fn listed() -> Self {
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()])
}
}

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";

Expand All @@ -533,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 {
Expand All @@ -551,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(()),
}
}
}
Expand Down
3 changes: 1 addition & 2 deletions backend/src/db/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
];
Loading

0 comments on commit 1c9f4bc

Please sign in to comment.