Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/app-frontend/src/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export async function openPath(path) {
return await invoke('plugin:utils|open_path', { path })
}

export async function getYouTubeEmbedOrigin() {
return await invoke('plugin:utils|get_youtube_embed_origin')
}

export async function highlightInFolder(path) {
return await invoke('plugin:utils|highlight_in_folder', { path })
}
Expand Down
7 changes: 7 additions & 0 deletions apps/app-frontend/src/main.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import 'overlayscrollbars/overlayscrollbars.css'

import { installTooltipDirective } from '@modrinth/ui'
import { setYouTubeEmbedOrigin } from '@modrinth/utils'
import { VueQueryPlugin } from '@tanstack/vue-query'
import { createApp } from 'vue'

import App from '@/App.vue'
import { overlayScrollbarsDirective } from '@/directives/overlayScrollbars'
import { setupErrorReporting } from '@/helpers/error-reporting'
import { debugStartup, traceStartupStep } from '@/helpers/startup-debug'
import { getYouTubeEmbedOrigin } from '@/helpers/utils'
import i18nPlugin from '@/plugins/i18n'
import i18nDebugPlugin from '@/plugins/i18n-debug'
import router from '@/routes'
Expand All @@ -34,6 +36,11 @@ async function mount() {
)
app.use(new VueScanPlugin({ enabled: true, showOverlay: true, log: false, playSound: false }))
}
try {
setYouTubeEmbedOrigin(await getYouTubeEmbedOrigin())
} catch (err) {
console.warn('Failed to start YouTube embed server', err)
}
debugStartup('Vue mount started')
app.mount('#app')
debugStartup('Vue mount completed')
Expand Down
5 changes: 5 additions & 0 deletions apps/app/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
<key>NSIncludesSubdomains</key>
<true />
</dict>
<key>127.0.0.1</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true />
</dict>
<key>textures.minecraft.net</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
Expand Down
1 change: 1 addition & 0 deletions apps/app/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ fn main() {
"progress_bars_list",
"get_opening_command",
"get_image_thumbnail",
"get_youtube_embed_origin",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub mod tags;
mod thumbnails;
pub mod users;
pub mod utils;
mod youtube_embed;

pub mod ads;
#[cfg(target_os = "macos")]
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/api/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
progress_bars_list,
get_opening_command,
super::thumbnails::get_image_thumbnail,
super::youtube_embed::get_youtube_embed_origin,
])
.build()
}
Expand Down
28 changes: 28 additions & 0 deletions apps/app/src/api/youtube_embed.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
html,
body {
margin: 0;
height: 100%;
background: #000;
}

iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
}
</style>
</head>
<body>
<iframe
src="{{src}}"
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
allowfullscreen
></iframe>
</body>
</html>
97 changes: 97 additions & 0 deletions apps/app/src/api/youtube_embed.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//! Loopback HTTP server for YouTube embeds. The webview's `tauri://` origin sends no `Referer`,
//! which YouTube rejects with "Error 153"; serving each embed from a page on `http://127.0.0.1`
//! gives the player a valid referrer.

use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;

use hyper::body::Incoming;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
use tokio::sync::OnceCell;

use super::Result;

static ORIGIN: OnceCell<String> = OnceCell::const_new();

/// Returns the origin of the embed server, starting it on first use.
#[tauri::command]
pub async fn get_youtube_embed_origin() -> Result<String> {
Ok(ORIGIN.get_or_try_init(start).await?.clone())
}

async fn start() -> std::io::Result<String> {
let listener =
TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).await?;
let origin = format!("http://{}", listener.local_addr()?);

tokio::spawn(async move {
loop {
let Ok((socket, _)) = listener.accept().await else {
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
};
tokio::spawn(
hyper::server::conn::http1::Builder::new().serve_connection(
TokioIo::new(socket),
hyper::service::service_fn(handle),
),
);
}
});

Ok(origin)
}

async fn handle(
req: Request<Incoming>,
) -> std::result::Result<Response<String>, hyper::http::Error> {
let embed_url = if req.method() == Method::GET {
embed_url(req.uri().path(), req.uri().query())
} else {
None
};

match embed_url {
Some(url) => Response::builder()
.header("Content-Type", "text/html;charset=utf-8")
.body(include_str!("youtube_embed.html").replace("{{src}}", &url)),
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(String::new()),
}
}

/// Maps `[/nocookie]/embed/<video id>?start=<s>&end=<s>` to the YouTube embed URL, keeping
/// only the parameters the markdown sanitizer allows.
fn embed_url(path: &str, query: Option<&str>) -> Option<String> {
let (host, path) = match path.strip_prefix("/nocookie") {
Some(path) => ("www.youtube-nocookie.com", path),
None => ("www.youtube.com", path),
};
let id = path.strip_prefix("/embed/")?;
if id.len() != 11
|| !id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return None;
}

let params = url::form_urlencoded::parse(query.unwrap_or("").as_bytes())
.filter(|(key, value)| {
matches!(key.as_ref(), "start" | "end")
&& !value.is_empty()
&& value.bytes().all(|b| b.is_ascii_digit())
})
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>();

let mut url = format!("https://{host}/embed/{id}");
if !params.is_empty() {
url.push('?');
url.push_str(&params.join("&amp;"));
}
Some(url)
}
2 changes: 1 addition & 1 deletion apps/app/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'",
"script-src": "https://js.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://tally.so/widgets/embed.js 'self'",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ https://js.stripe.com https://hooks.stripe.com https://*.intercom.io https://intercom-sheets.com https://www.intercom-reporting.com https://app.intercom.com 'self'",
"frame-src": "http://127.0.0.1:* https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ https://js.stripe.com https://hooks.stripe.com https://*.intercom.io https://intercom-sheets.com https://www.intercom-reporting.com https://app.intercom.com 'self'",
"media-src": "https://*.githubusercontent.com"
}
}
Expand Down
16 changes: 15 additions & 1 deletion packages/utils/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ export const setMarkdownUserCountryResolver = (resolver: MarkdownUserCountryReso
resolveMarkdownUserCountry = resolver
}

let youtubeEmbedOrigin: string | null = null

/** Sets the app's loopback origin for YouTube embeds. */
export const setYouTubeEmbedOrigin = (origin: string | null) => {
youtubeEmbedOrigin = origin
}

const youtubeEmbedUrl = /^https?:\/\/(www\.)?youtube(-nocookie)?\.com\/embed\/[a-zA-Z0-9_-]{11}/

const shouldProxyImgur = () => {
try {
const country = resolveMarkdownUserCountry()
Expand Down Expand Up @@ -84,7 +93,7 @@ export const configuredXss = new FilterXSS({
if (tag === 'iframe' && name === 'src') {
const allowedSources = [
{
url: /^https?:\/\/(www\.)?youtube(-nocookie)?\.com\/embed\/[a-zA-Z0-9_-]{11}/,
url: youtubeEmbedUrl,
allowedParameters: [/start=\d+/, /end=\d+/],
},
{
Expand All @@ -109,6 +118,11 @@ export const configuredXss = new FilterXSS({
})

url.search = newSearchParams.toString()
if (youtubeEmbedOrigin && source.url === youtubeEmbedUrl) {
const nocookie = url.hostname.endsWith('youtube-nocookie.com') ? '/nocookie' : ''
const src = `${youtubeEmbedOrigin}${nocookie}${url.pathname}${url.search}`
return `${name}="${escapeAttrValue(src)}"`
}
return `${name}="${escapeAttrValue(url.toString())}"`
}
} catch {
Expand Down