mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-09 09:22:18 +02:00
resource policy trusted destinations (#875)
* **New Features** * Added a Resource Policy editor for each input, supporting trusted hostnames and private network ranges. * Resource URLs now retain their source input and use authenticated links where supported. * Resource caching is scoped to the policy authorizing access. * **Bug Fixes** * Invalid or unsupported resource values are safely discarded. * Redirects and destination addresses are rechecked against the applicable policy. * **Breaking Changes** * Private DNS destinations require approved hosts and networks; private IP literals require an approved network. * Input and alias names must be non-empty and globally unique.
This commit is contained in:
@@ -9,6 +9,7 @@ use shared::{
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use tuliprox_core::{
|
||||
model::{ConfigInput, EpgSource, EpgSourceType, PersistedEpgSource, PersistedEpgSourceKind},
|
||||
@@ -200,7 +201,7 @@ pub async fn get_xmltv<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
|
||||
match download_epg_file(epg_source, ctx, input, headers, storage_dir).await {
|
||||
Ok(file_path) => {
|
||||
stored_file_paths.push(file_path.clone());
|
||||
match persisted_source_from_config(epg_source, file_path) {
|
||||
match persisted_source_from_config(epg_source, file_path, input) {
|
||||
Ok(persisted) => file_paths.push(persisted),
|
||||
Err(err) => errors.push(err),
|
||||
}
|
||||
@@ -229,6 +230,7 @@ pub async fn get_xmltv<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
|
||||
fn persisted_source_from_config(
|
||||
epg_source: &EpgSource,
|
||||
file_path: PathBuf,
|
||||
input: &ConfigInput,
|
||||
) -> Result<PersistedEpgSource, TuliproxError> {
|
||||
let kind = match epg_source.source_type {
|
||||
EpgSourceType::Xmltv => PersistedEpgSourceKind::Xmltv,
|
||||
@@ -246,7 +248,13 @@ fn persisted_source_from_config(
|
||||
}
|
||||
};
|
||||
|
||||
Ok(PersistedEpgSource { file_path, priority: epg_source.priority, logo_override: epg_source.logo_override, kind })
|
||||
Ok(PersistedEpgSource {
|
||||
file_path,
|
||||
priority: epg_source.priority,
|
||||
logo_override: epg_source.logo_override,
|
||||
kind,
|
||||
input_name: Some(Arc::clone(&input.name)),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -4,7 +4,7 @@ use quick_xml::events::{BytesStart, BytesText, Event};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::{
|
||||
concat_string,
|
||||
model::{EpgCategory, EpgChannel, EpgNamePrefix, EpgProgramme},
|
||||
model::{has_resource_scheme, ingest_resource_value, EpgCategory, EpgChannel, EpgNamePrefix, EpgProgramme},
|
||||
utils::{deunicode_string, Internable, CONSTANTS},
|
||||
};
|
||||
use std::{
|
||||
@@ -358,6 +358,7 @@ impl TVGuide {
|
||||
if add_channel {
|
||||
with_folded_epg_id(&tag_epg_id, |folded| source_processed.insert(folded.intern()));
|
||||
id_cache.insert_processed_epg_id(&tag_epg_id);
|
||||
let icon_source = ingest_epg_icon(epg_source, Self::channel_icon(&tag));
|
||||
accumulator.upsert_channel(
|
||||
epg_source.priority,
|
||||
source_order,
|
||||
@@ -365,7 +366,7 @@ impl TVGuide {
|
||||
EpgChannel {
|
||||
id: Arc::clone(&tag_epg_id),
|
||||
title: Self::channel_display_name(&tag),
|
||||
icon: Self::channel_icon(&tag),
|
||||
icon: icon_source,
|
||||
programmes: vec![],
|
||||
},
|
||||
);
|
||||
@@ -375,13 +376,14 @@ impl TVGuide {
|
||||
EPG_TAG_PROGRAMME => {
|
||||
if let Some(epg_id) = tag.get_attribute_value(&epg_attrib_channel) {
|
||||
if with_folded_epg_id(epg_id, |folded| source_processed.contains(folded)) {
|
||||
if let Some(programme) = Self::extract_programme(
|
||||
if let Some(mut programme) = Self::extract_programme(
|
||||
&tag,
|
||||
epg_id,
|
||||
&start_attrib,
|
||||
&stop_attrib,
|
||||
&catchup_id_attrib,
|
||||
) {
|
||||
programme.icon = ingest_epg_icon(epg_source, programme.icon);
|
||||
accumulator.push_programme(epg_source.priority, source_order, programme);
|
||||
}
|
||||
}
|
||||
@@ -445,8 +447,9 @@ impl TVGuide {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(channel) => {
|
||||
Ok(mut channel) => {
|
||||
id_cache.insert_processed_epg_id(channel_id);
|
||||
channel.icon = ingest_epg_icon(epg_source, channel.icon);
|
||||
accumulator.add_channel_with_programmes(
|
||||
epg_source.priority,
|
||||
source_order,
|
||||
@@ -1131,6 +1134,22 @@ fn apply_dummy_policies(channels: &mut [ChannelMergeAcc], dummy_policies: &HashM
|
||||
}
|
||||
}
|
||||
|
||||
/// Origin of an icon belonging to `source`.
|
||||
///
|
||||
/// An icon without a value has no origin, and a source without an origin keeps `None`, which the
|
||||
/// request path treats as public-only.
|
||||
fn ingest_epg_icon(source: &PersistedEpgSource, mut icon: Option<Arc<str>>) -> Option<Arc<str>> {
|
||||
let value = icon.as_mut()?;
|
||||
if let Some(input_name) = &source.input_name {
|
||||
if ingest_resource_value(value, input_name).is_err() {
|
||||
return None;
|
||||
}
|
||||
} else if has_resource_scheme(value) {
|
||||
return None;
|
||||
}
|
||||
icon
|
||||
}
|
||||
|
||||
fn backfill_programme_metadata(existing: &mut EpgProgramme, incoming: EpgProgramme) {
|
||||
if existing.title.is_none() {
|
||||
existing.title = incoming.title;
|
||||
@@ -1308,7 +1327,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn xmltv_source(file_path: PathBuf, priority: i16, logo_override: bool) -> PersistedEpgSource {
|
||||
PersistedEpgSource { file_path, priority, logo_override, kind: PersistedEpgSourceKind::Xmltv }
|
||||
PersistedEpgSource { file_path, priority, logo_override, kind: PersistedEpgSourceKind::Xmltv, input_name: None }
|
||||
}
|
||||
|
||||
fn dummy_policy_source(priority: i16, source_order: usize, title: &str) -> EpgDummyPolicySource {
|
||||
@@ -1915,6 +1934,7 @@ mod tests {
|
||||
..IcsEpgSourceConfig::default()
|
||||
}),
|
||||
},
|
||||
input_name: None,
|
||||
},
|
||||
]);
|
||||
let mut id_cache = EpgIdCache::new(None);
|
||||
|
||||
@@ -952,6 +952,7 @@ mod tests {
|
||||
match_names,
|
||||
config: Box::new(IcsEpgSourceConfig::default()),
|
||||
},
|
||||
input_name: None,
|
||||
}])
|
||||
}
|
||||
|
||||
@@ -1040,6 +1041,7 @@ mod tests {
|
||||
priority: 0,
|
||||
logo_override: false,
|
||||
kind: PersistedEpgSourceKind::Xmltv,
|
||||
input_name: None,
|
||||
}]);
|
||||
let mut playlist = FetchedPlaylist {
|
||||
input: &input,
|
||||
@@ -1254,6 +1256,7 @@ mod tests {
|
||||
priority: 0,
|
||||
logo_override: true,
|
||||
kind: PersistedEpgSourceKind::Xmltv,
|
||||
input_name: Some("epg-input".into()),
|
||||
}]);
|
||||
let mut playlist = FetchedPlaylist {
|
||||
input: &input,
|
||||
@@ -1266,8 +1269,12 @@ mod tests {
|
||||
|
||||
let updated = playlist.items_mut().next().unwrap();
|
||||
assert_eq!(updated.header.epg_channel_id.as_deref(), Some("demo.channel"));
|
||||
assert_eq!(updated.header.logo.as_ref(), "http://guide/icon.png");
|
||||
assert_eq!(updated.header.logo_small.as_ref(), "http://guide/icon.png");
|
||||
let logo = shared::model::ResourceLocator::decode(&updated.header.logo).expect("EPG logo locator");
|
||||
let logo_small =
|
||||
shared::model::ResourceLocator::decode(&updated.header.logo_small).expect("EPG small logo locator");
|
||||
assert_eq!(logo.input_name.as_ref(), "epg-input");
|
||||
assert_eq!(logo.url.as_ref(), "http://guide/icon.png");
|
||||
assert_eq!(logo_small, logo);
|
||||
assert_eq!(epg[0].children[0].id.as_ref(), "Demo.Channel");
|
||||
});
|
||||
}
|
||||
@@ -1327,6 +1334,7 @@ mod tests {
|
||||
priority: 0,
|
||||
logo_override: false,
|
||||
kind: PersistedEpgSourceKind::Xmltv,
|
||||
input_name: None,
|
||||
}]);
|
||||
let mut playlist = FetchedPlaylist {
|
||||
input: &input,
|
||||
@@ -1434,6 +1442,7 @@ mod tests {
|
||||
priority: 0,
|
||||
logo_override: false,
|
||||
kind: PersistedEpgSourceKind::Xmltv,
|
||||
input_name: None,
|
||||
}]);
|
||||
let mut playlist = FetchedPlaylist {
|
||||
input: &input,
|
||||
@@ -1468,6 +1477,7 @@ mod tests {
|
||||
priority: 0,
|
||||
logo_override: false,
|
||||
kind: PersistedEpgSourceKind::Xmltv,
|
||||
input_name: None,
|
||||
}]);
|
||||
let mut playlist = FetchedPlaylist {
|
||||
input: &input,
|
||||
|
||||
@@ -139,14 +139,21 @@ fn to_playlist_item(
|
||||
) {
|
||||
match &entry.metadata {
|
||||
MediaMetadata::Movie(_) => {
|
||||
channels.push(build_movie_playlist_item(entry, input_name, group_name, api_base_path));
|
||||
let mut item = build_movie_playlist_item(entry, input_name, group_name, api_base_path);
|
||||
item.header.ingest_resource_values(input_name);
|
||||
channels.push(item);
|
||||
}
|
||||
MediaMetadata::Series(_) => {
|
||||
if let Some((series_info, episodes)) =
|
||||
build_series_playlist_items(entry, input_name, group_name, api_base_path)
|
||||
{
|
||||
let mut series_info = series_info;
|
||||
series_info.header.ingest_resource_values(input_name);
|
||||
channels.push(series_info);
|
||||
channels.extend(episodes);
|
||||
channels.extend(episodes.into_iter().map(|mut episode| {
|
||||
episode.header.ingest_resource_values(input_name);
|
||||
episode
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1563,6 +1563,7 @@ mod mapping_stage {
|
||||
match_names: vec!["BBC One".intern()],
|
||||
config: Box::new(IcsEpgSourceConfig::default()),
|
||||
},
|
||||
input_name: None,
|
||||
}]);
|
||||
|
||||
let mut playlist = FetchedPlaylist {
|
||||
@@ -1658,6 +1659,7 @@ match {
|
||||
match_names: vec![],
|
||||
config: Box::new(IcsEpgSourceConfig::default()),
|
||||
},
|
||||
input_name: None,
|
||||
}]);
|
||||
let mut playlist = FetchedPlaylist {
|
||||
input: &input,
|
||||
|
||||
@@ -164,6 +164,12 @@ impl MappingStageOutcome {
|
||||
}
|
||||
|
||||
pub(crate) fn map_channel(mut channel: PlaylistItem, mapping: &CompiledMapping) -> ChannelMappingOutcome {
|
||||
let mut trusted_locators = HashSet::new();
|
||||
channel.header.visit_resource_values(&mut |value| {
|
||||
if shared::model::resolve_resource_value(value).is_ok_and(|locator| locator.is_some()) {
|
||||
trusted_locators.insert(Arc::clone(value));
|
||||
}
|
||||
});
|
||||
let mut matched_rules = 0;
|
||||
let mut virtual_items = vec![];
|
||||
let mut changed_fields = HashSet::new();
|
||||
@@ -198,6 +204,16 @@ pub(crate) fn map_channel(mut channel: PlaylistItem, mapping: &CompiledMapping)
|
||||
}
|
||||
}
|
||||
}
|
||||
let normalize_mapped_item = |item: &mut PlaylistItem| {
|
||||
item.header.visit_resource_values_mut(&mut |value| {
|
||||
if shared::model::has_resource_scheme(value) && !trusted_locators.contains(value) {
|
||||
*value = Arc::from("");
|
||||
}
|
||||
});
|
||||
item.header.normalize_internal_resource_values();
|
||||
};
|
||||
normalize_mapped_item(&mut channel);
|
||||
virtual_items.iter_mut().for_each(normalize_mapped_item);
|
||||
ChannelMappingOutcome { channel, virtual_items, matched_rules, changed_fields, diagnostics }
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ pub async fn download_stalker_playlist(
|
||||
app_config,
|
||||
api_client.as_ref(),
|
||||
&handshake,
|
||||
refresh_plan,
|
||||
refresh_plan.clone(),
|
||||
&storage_path,
|
||||
identity_fingerprint,
|
||||
refresh_mode.budget(),
|
||||
|
||||
@@ -128,11 +128,12 @@ impl StalkerClusterSelection {
|
||||
}
|
||||
|
||||
/// Requested clusters and the publication policy applied once their generation is complete.
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone)]
|
||||
pub struct StalkerRefreshPlan {
|
||||
selection: StalkerClusterSelection,
|
||||
update_quality: ConfigInputUpdateQuality,
|
||||
quality_bypass_mask: u8,
|
||||
input_name: Arc<str>,
|
||||
}
|
||||
|
||||
impl StalkerRefreshPlan {
|
||||
@@ -143,7 +144,7 @@ impl StalkerRefreshPlan {
|
||||
let quality_bypass_mask = matches!(quality_policy, UpdateQualityPolicy::Bypass)
|
||||
.then_some(selection.mask() & MEDIA_SELECTION)
|
||||
.unwrap_or(0);
|
||||
Self { selection, update_quality, quality_bypass_mask }
|
||||
Self { selection, update_quality, quality_bypass_mask, input_name: Arc::clone(&input.name) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,13 +560,16 @@ fn map_items(
|
||||
categories: &HashMap<u32, StalkerCategory>,
|
||||
kind: StalkerStreamKind,
|
||||
added_at: i64,
|
||||
input_name: &Arc<str>,
|
||||
) -> Vec<StalkerPlaylistItem> {
|
||||
raw_items
|
||||
.iter()
|
||||
.map(|raw| {
|
||||
let category =
|
||||
raw.category_id().and_then(|value| value.parse::<u32>().ok()).and_then(|id| categories.get(&id));
|
||||
parser::map_stalker_to_playlist_item(raw, category, kind, added_at)
|
||||
let mut item = parser::map_stalker_to_playlist_item(raw, category, kind, added_at);
|
||||
item.ingest_resource_values(input_name);
|
||||
item
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -636,7 +640,7 @@ pub async fn advance_stalker_refresh(
|
||||
identity_fingerprint: u64,
|
||||
mut budget: StalkerRefreshBudget,
|
||||
) -> Result<StalkerRefreshOutcome, TuliproxError> {
|
||||
let StalkerRefreshPlan { selection, update_quality, quality_bypass_mask } = refresh_plan;
|
||||
let StalkerRefreshPlan { selection, update_quality, quality_bypass_mask, input_name } = refresh_plan;
|
||||
let mut checkpoint =
|
||||
load_or_start_checkpoint(storage_path, identity_fingerprint, selection, quality_bypass_mask).await?;
|
||||
if checkpoint.phase == StalkerRefreshPhase::Terminal {
|
||||
@@ -673,7 +677,7 @@ pub async fn advance_stalker_refresh(
|
||||
checkpoint.retry_count = 0;
|
||||
}
|
||||
Ok(raw) => {
|
||||
let items = map_items(&raw, categories, StalkerStreamKind::Live, added_at);
|
||||
let items = map_items(&raw, categories, StalkerStreamKind::Live, added_at, &input_name);
|
||||
let path =
|
||||
generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::Live);
|
||||
snapshot_stalker_items_at(app_config, path.clone(), &items).await?;
|
||||
@@ -707,7 +711,7 @@ pub async fn advance_stalker_refresh(
|
||||
{
|
||||
return yield_after_error(storage_path, checkpoint, err).await;
|
||||
}
|
||||
let items = map_items(&response.items, categories, StalkerStreamKind::Live, added_at);
|
||||
let items = map_items(&response.items, categories, StalkerStreamKind::Live, added_at, &input_name);
|
||||
let path = generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::Live);
|
||||
upsert_stalker_items_at(app_config, &path, &items).await?;
|
||||
checkpoint.processed = checkpoint.processed.saturating_add(items.len() as u64);
|
||||
@@ -739,7 +743,7 @@ pub async fn advance_stalker_refresh(
|
||||
{
|
||||
return yield_after_error(storage_path, checkpoint, err).await;
|
||||
}
|
||||
let items = map_items(&response.items, categories, StalkerStreamKind::Movie, added_at);
|
||||
let items = map_items(&response.items, categories, StalkerStreamKind::Movie, added_at, &input_name);
|
||||
let path = generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::Vod);
|
||||
upsert_stalker_items_at(app_config, &path, &items).await?;
|
||||
checkpoint.processed = checkpoint.processed.saturating_add(items.len() as u64);
|
||||
@@ -786,6 +790,7 @@ pub async fn advance_stalker_refresh(
|
||||
root.flussonic_tmp_link = capabilities.flussonic_temporary_link;
|
||||
root.wowza_tmp_link = capabilities.wowza_temporary_link;
|
||||
root.use_http_tmp_link = capabilities.use_http_temporary_link;
|
||||
root.ingest_resource_values(&input_name);
|
||||
root
|
||||
})
|
||||
.collect();
|
||||
@@ -830,7 +835,10 @@ pub async fn advance_stalker_refresh(
|
||||
used_episode_ids
|
||||
.insert(prepare_stalker_episode_series_at(app_config, &path, series_id).await?)
|
||||
};
|
||||
let episodes = parser::map_stalker_series_details(&details, &root, added_at, used);
|
||||
let mut episodes = parser::map_stalker_series_details(&details, &root, added_at, used);
|
||||
for episode in &mut episodes {
|
||||
episode.ingest_resource_values(&input_name);
|
||||
}
|
||||
upsert_stalker_items_at(app_config, &path, &episodes).await?;
|
||||
checkpoint.processed = checkpoint.processed.saturating_add(episodes.len() as u64);
|
||||
checkpoint.phase = StalkerRefreshPhase::SeriesDetails { provider_id: Some(root.stream_id) };
|
||||
@@ -1521,7 +1529,7 @@ mod tests {
|
||||
StalkerCategory { id: "10".to_string(), title: "News".to_string(), alias: None, number: 1 },
|
||||
)]);
|
||||
|
||||
let items = map_items(&[raw], &categories, StalkerStreamKind::Live, 0);
|
||||
let items = map_items(&[raw], &categories, StalkerStreamKind::Live, 0, &Arc::from("stalker-input"));
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].category_id, 10);
|
||||
|
||||
Reference in New Issue
Block a user