mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
refactor(media): share one external proxy url codec across every service (#1794)
This commit is contained in:
Generated
+9
@@ -1777,6 +1777,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
"clap",
|
"clap",
|
||||||
|
"fluxer_common",
|
||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
"hyper 1.10.1",
|
"hyper 1.10.1",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
@@ -1800,6 +1801,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
"fluxer-svc",
|
"fluxer-svc",
|
||||||
|
"fluxer_common",
|
||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
"moka",
|
"moka",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -1836,6 +1838,7 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
"clap",
|
"clap",
|
||||||
"criterion",
|
"criterion",
|
||||||
|
"fluxer_common",
|
||||||
"hex",
|
"hex",
|
||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
"http 1.4.2",
|
"http 1.4.2",
|
||||||
@@ -1875,6 +1878,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"criterion",
|
"criterion",
|
||||||
"fluxer-svc",
|
"fluxer-svc",
|
||||||
|
"fluxer_common",
|
||||||
"fluxer_markdown_parser",
|
"fluxer_markdown_parser",
|
||||||
"futures",
|
"futures",
|
||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
@@ -1943,6 +1947,7 @@ dependencies = [
|
|||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"entities",
|
"entities",
|
||||||
"fluxer-svc",
|
"fluxer-svc",
|
||||||
|
"fluxer_common",
|
||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
"infer",
|
"infer",
|
||||||
"moka",
|
"moka",
|
||||||
@@ -2038,10 +2043,14 @@ dependencies = [
|
|||||||
"aws-credential-types",
|
"aws-credential-types",
|
||||||
"aws-sigv4",
|
"aws-sigv4",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64",
|
||||||
|
"hmac 0.13.0",
|
||||||
"maxminddb",
|
"maxminddb",
|
||||||
"moka",
|
"moka",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2 0.11.0",
|
||||||
|
"thiserror",
|
||||||
"time",
|
"time",
|
||||||
"tracing",
|
"tracing",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
|
|||||||
@@ -15,4 +15,8 @@ reqwest = { version = "0.13.4", default-features = false, features = ["blocking"
|
|||||||
serde_json = "1.0.150"
|
serde_json = "1.0.150"
|
||||||
time = { version = "0.3.47", features = ["formatting", "macros", "parsing"] }
|
time = { version = "0.3.47", features = ["formatting", "macros", "parsing"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
|
base64 = "0.22"
|
||||||
|
hmac = "0.13.0"
|
||||||
|
sha2 = "0.11.0"
|
||||||
|
thiserror = "2"
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
use base64::prelude::*;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
const V2_PREFIX: &str = "v2/";
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Eq, PartialEq)]
|
||||||
|
pub enum ExternalPathError {
|
||||||
|
#[error("invalid external path")]
|
||||||
|
InvalidExternalPath,
|
||||||
|
#[error("invalid base64")]
|
||||||
|
InvalidBase64,
|
||||||
|
#[error("invalid utf-8")]
|
||||||
|
InvalidUtf8,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_v2_external_media_proxy_path(input_url: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{V2_PREFIX}{}",
|
||||||
|
BASE64_URL_SAFE_NO_PAD.encode(input_url.as_bytes())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_encode_component(value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(value.len());
|
||||||
|
for byte in value.bytes() {
|
||||||
|
match byte {
|
||||||
|
b'A'..=b'Z'
|
||||||
|
| b'a'..=b'z'
|
||||||
|
| b'0'..=b'9'
|
||||||
|
| b'-'
|
||||||
|
| b'_'
|
||||||
|
| b'.'
|
||||||
|
| b'!'
|
||||||
|
| b'~'
|
||||||
|
| b'*'
|
||||||
|
| b'\''
|
||||||
|
| b'('
|
||||||
|
| b')' => out.push(byte as char),
|
||||||
|
_ => out.push_str(&format!("%{byte:02X}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_external_media_proxy_path(input_url: &str) -> Result<String, ExternalPathError> {
|
||||||
|
let (scheme, remainder) = input_url
|
||||||
|
.split_once("://")
|
||||||
|
.ok_or(ExternalPathError::InvalidExternalPath)?;
|
||||||
|
if scheme.is_empty() || remainder.is_empty() {
|
||||||
|
return Err(ExternalPathError::InvalidExternalPath);
|
||||||
|
}
|
||||||
|
let (authority_and_path, query) = match remainder.split_once('?') {
|
||||||
|
Some((head, tail)) => (head, Some(tail)),
|
||||||
|
None => (remainder, None),
|
||||||
|
};
|
||||||
|
let (host, path) = match authority_and_path.split_once('/') {
|
||||||
|
Some((host, path)) => (host, path),
|
||||||
|
None => (authority_and_path, ""),
|
||||||
|
};
|
||||||
|
if host.is_empty() {
|
||||||
|
return Err(ExternalPathError::InvalidExternalPath);
|
||||||
|
}
|
||||||
|
let mut segments: Vec<String> = Vec::new();
|
||||||
|
if let Some(query) = query {
|
||||||
|
segments.push(percent_encode_component(&format!("?{query}")));
|
||||||
|
}
|
||||||
|
segments.push(scheme.to_owned());
|
||||||
|
segments.push(host.to_owned());
|
||||||
|
if !path.is_empty() {
|
||||||
|
segments.push(
|
||||||
|
path.split('/')
|
||||||
|
.map(percent_encode_component)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(segments.join("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_v2(proxy_path: &str) -> Result<String, ExternalPathError> {
|
||||||
|
let encoded = proxy_path
|
||||||
|
.strip_prefix(V2_PREFIX)
|
||||||
|
.ok_or(ExternalPathError::InvalidExternalPath)?;
|
||||||
|
if encoded.is_empty() {
|
||||||
|
return Err(ExternalPathError::InvalidExternalPath);
|
||||||
|
}
|
||||||
|
let bytes = BASE64_URL_SAFE_NO_PAD
|
||||||
|
.decode(encoded)
|
||||||
|
.map_err(|_| ExternalPathError::InvalidBase64)?;
|
||||||
|
String::from_utf8(bytes).map_err(|_| ExternalPathError::InvalidUtf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_val(c: u8) -> Option<u8> {
|
||||||
|
match c {
|
||||||
|
b'0'..=b'9' => Some(c - b'0'),
|
||||||
|
b'a'..=b'f' => Some(c - b'a' + 10),
|
||||||
|
b'A'..=b'F' => Some(c - b'A' + 10),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn percent_decode(input: &str, plus_as_space: bool) -> Vec<u8> {
|
||||||
|
let bytes = input.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
let ch = bytes[i];
|
||||||
|
if ch == b'%'
|
||||||
|
&& i + 2 < bytes.len()
|
||||||
|
&& let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
|
||||||
|
{
|
||||||
|
out.push((hi << 4) | lo);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(if plus_as_space && ch == b'+' {
|
||||||
|
b' '
|
||||||
|
} else {
|
||||||
|
ch
|
||||||
|
});
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn percent_decode_string(input: &str, plus_as_space: bool) -> String {
|
||||||
|
String::from_utf8_lossy(&percent_decode(input, plus_as_space)).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_protocol_index(parts: &[&str]) -> Option<usize> {
|
||||||
|
for (index, part) in parts.iter().enumerate() {
|
||||||
|
if part.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if index == 0 && part.contains("%3D") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut chars = part.bytes();
|
||||||
|
let first = chars.next()?;
|
||||||
|
if !first.is_ascii_alphabetic() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'+' | b'.' | b'-')) {
|
||||||
|
return Some(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reconstruct_legacy(proxy_path: &str) -> Result<String, ExternalPathError> {
|
||||||
|
let parts: Vec<&str> = proxy_path.split('/').collect();
|
||||||
|
let protocol_index =
|
||||||
|
legacy_protocol_index(&parts).ok_or(ExternalPathError::InvalidExternalPath)?;
|
||||||
|
if protocol_index + 1 >= parts.len() {
|
||||||
|
return Err(ExternalPathError::InvalidExternalPath);
|
||||||
|
}
|
||||||
|
let protocol = parts[protocol_index];
|
||||||
|
let host_port = percent_decode_string(parts[protocol_index + 1], false);
|
||||||
|
let query_raw = parts[..protocol_index].join("/");
|
||||||
|
let path_raw = parts[protocol_index + 2..].join("/");
|
||||||
|
let query = percent_decode_string(&query_raw, false);
|
||||||
|
let path = percent_decode_string(&path_raw, false);
|
||||||
|
let normalized_query = query.strip_prefix('?').unwrap_or(&query);
|
||||||
|
Ok(format!(
|
||||||
|
"{protocol}://{host_port}/{path}{}{normalized_query}",
|
||||||
|
if normalized_query.is_empty() { "" } else { "?" }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reconstruct_original_url(proxy_path: &str) -> Result<String, ExternalPathError> {
|
||||||
|
if proxy_path.starts_with(V2_PREFIX) {
|
||||||
|
decode_v2(proxy_path)
|
||||||
|
} else {
|
||||||
|
reconstruct_legacy(proxy_path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type HmacSha256 = hmac::Hmac<sha2::Sha256>;
|
||||||
|
|
||||||
|
pub fn create_signature(input: &str, secret: &[u8]) -> String {
|
||||||
|
use hmac::{KeyInit, Mac};
|
||||||
|
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
|
||||||
|
mac.update(input.as_bytes());
|
||||||
|
BASE64_URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_external_media_proxy_url(
|
||||||
|
public_endpoint: &str,
|
||||||
|
input_url: &str,
|
||||||
|
secret: &[u8],
|
||||||
|
) -> Option<String> {
|
||||||
|
let path = build_external_media_proxy_path(input_url).ok()?;
|
||||||
|
let signature = create_signature(&path, secret);
|
||||||
|
Some(format!("{public_endpoint}/external/{signature}/{path}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_the_plain_path_shape() {
|
||||||
|
assert_eq!(
|
||||||
|
"https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp",
|
||||||
|
build_external_media_proxy_path("https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp")
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_an_encoded_query_ahead_of_the_scheme() {
|
||||||
|
assert_eq!(
|
||||||
|
"%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/HkAKKCzZ.webp",
|
||||||
|
build_external_media_proxy_path(
|
||||||
|
"https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here"
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_a_non_default_port() {
|
||||||
|
assert_eq!(
|
||||||
|
"https/example.com:8443/a.png",
|
||||||
|
build_external_media_proxy_path("https://example.com:8443/a.png").unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trips_every_shape() {
|
||||||
|
for url in [
|
||||||
|
"https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp",
|
||||||
|
"https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here",
|
||||||
|
"https://example.com:8443/a.png",
|
||||||
|
"https://avatars.githubusercontent.com/u/241303489?v=4",
|
||||||
|
"http://example.com/plain.gif",
|
||||||
|
"https://example.com/file.png?a=1&b=2&c=3",
|
||||||
|
] {
|
||||||
|
let path = build_external_media_proxy_path(url).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
reconstruct_original_url(&path).unwrap(),
|
||||||
|
"round trip {url}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_double_the_question_mark() {
|
||||||
|
let decoded = reconstruct_original_url("%3Fa%3D1/https/example.com/x.png").unwrap();
|
||||||
|
assert_eq!("https://example.com/x.png?a=1", decoded);
|
||||||
|
assert!(!decoded.contains("??"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn still_accepts_a_query_without_a_leading_question_mark() {
|
||||||
|
assert_eq!(
|
||||||
|
"https://example.com/x.png?a=1",
|
||||||
|
reconstruct_original_url("a%3D1/https/example.com/x.png").unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_url_without_a_scheme() {
|
||||||
|
assert!(build_external_media_proxy_path("example.com/a.png").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v2_path_roundtrip() {
|
||||||
|
let path = build_v2_external_media_proxy_path("https://example.com/a b.png?x=1");
|
||||||
|
let decoded = reconstruct_original_url(&path).unwrap();
|
||||||
|
assert_eq!("https://example.com/a b.png?x=1", decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod external_media_path;
|
||||||
pub mod geoip;
|
pub mod geoip;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
fluxer_common = { path = "../fluxer_common" }
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
fluxer-svc = { path = "../fluxer_svc", default-features = false }
|
fluxer-svc = { path = "../fluxer_svc", default-features = false }
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use base64::prelude::*;
|
|
||||||
use hmac::{Hmac, KeyInit, Mac};
|
|
||||||
use sha2::Sha256;
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
const V2_PATH_PREFIX: &str = "v2/";
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct MediaProxyUrlBuilder {
|
pub struct MediaProxyUrlBuilder {
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
@@ -66,28 +61,14 @@ impl MediaProxyUrlBuilder {
|
|||||||
return Some(input_url.to_owned());
|
return Some(input_url.to_owned());
|
||||||
}
|
}
|
||||||
|
|
||||||
let proxy_path = build_external_media_proxy_path(parsed.as_str());
|
fluxer_common::external_media_path::build_external_media_proxy_url(
|
||||||
let signature = create_signature(&proxy_path, &self.secret_key);
|
&self.endpoint,
|
||||||
Some(format!(
|
parsed.as_str(),
|
||||||
"{}/external/{signature}/{proxy_path}",
|
self.secret_key.as_bytes(),
|
||||||
self.endpoint
|
)
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_external_media_proxy_path(input_url: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"{V2_PATH_PREFIX}{}",
|
|
||||||
BASE64_URL_SAFE_NO_PAD.encode(input_url)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_signature(input: &str, secret: &str) -> String {
|
|
||||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key");
|
|
||||||
mac.update(input.as_bytes());
|
|
||||||
BASE64_URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -153,7 +134,7 @@ mod tests {
|
|||||||
.expect("proxy url");
|
.expect("proxy url");
|
||||||
|
|
||||||
assert!(url.starts_with("https://media.example.test/external/"));
|
assert!(url.starts_with("https://media.example.test/external/"));
|
||||||
assert!(url.contains("/v2/"));
|
assert!(url.contains("/https/"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
builder.external_proxy_url("https://media.example.test/external/existing"),
|
builder.external_proxy_url("https://media.example.test/external/existing"),
|
||||||
Some("https://media.example.test/external/existing".to_owned())
|
Some("https://media.example.test/external/existing".to_owned())
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ name = "core"
|
|||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
fluxer_common = { path = "../fluxer_common" }
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
axum = {version = "0.8.9", default-features = false, features = ["http1", "json", "query", "tokio"]}
|
axum = {version = "0.8.9", default-features = false, features = ["http1", "json", "query", "tokio"]}
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
|
|||||||
@@ -1,257 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use base64::prelude::*;
|
pub use fluxer_common::external_media_path::{
|
||||||
use thiserror::Error;
|
ExternalPathError, build_external_media_proxy_path, build_v2_external_media_proxy_path,
|
||||||
|
percent_decode, percent_decode_string, reconstruct_original_url,
|
||||||
const V2_PREFIX: &str = "v2/";
|
};
|
||||||
|
|
||||||
#[derive(Debug, Error, Eq, PartialEq)]
|
|
||||||
pub enum ExternalPathError {
|
|
||||||
#[error("invalid external path")]
|
|
||||||
InvalidExternalPath,
|
|
||||||
#[error("invalid base64")]
|
|
||||||
InvalidBase64,
|
|
||||||
#[error("invalid utf-8")]
|
|
||||||
InvalidUtf8,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_v2_external_media_proxy_path(input_url: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"{V2_PREFIX}{}",
|
|
||||||
BASE64_URL_SAFE_NO_PAD.encode(input_url.as_bytes())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn percent_encode_component(value: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(value.len());
|
|
||||||
for byte in value.bytes() {
|
|
||||||
match byte {
|
|
||||||
b'A'..=b'Z'
|
|
||||||
| b'a'..=b'z'
|
|
||||||
| b'0'..=b'9'
|
|
||||||
| b'-'
|
|
||||||
| b'_'
|
|
||||||
| b'.'
|
|
||||||
| b'!'
|
|
||||||
| b'~'
|
|
||||||
| b'*'
|
|
||||||
| b'\''
|
|
||||||
| b'('
|
|
||||||
| b')' => out.push(byte as char),
|
|
||||||
_ => out.push_str(&format!("%{byte:02X}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_external_media_proxy_path(input_url: &str) -> Result<String, ExternalPathError> {
|
|
||||||
let (scheme, remainder) = input_url
|
|
||||||
.split_once("://")
|
|
||||||
.ok_or(ExternalPathError::InvalidExternalPath)?;
|
|
||||||
if scheme.is_empty() || remainder.is_empty() {
|
|
||||||
return Err(ExternalPathError::InvalidExternalPath);
|
|
||||||
}
|
|
||||||
let (authority_and_path, query) = match remainder.split_once('?') {
|
|
||||||
Some((head, tail)) => (head, Some(tail)),
|
|
||||||
None => (remainder, None),
|
|
||||||
};
|
|
||||||
let (host, path) = match authority_and_path.split_once('/') {
|
|
||||||
Some((host, path)) => (host, path),
|
|
||||||
None => (authority_and_path, ""),
|
|
||||||
};
|
|
||||||
if host.is_empty() {
|
|
||||||
return Err(ExternalPathError::InvalidExternalPath);
|
|
||||||
}
|
|
||||||
let mut segments: Vec<String> = Vec::new();
|
|
||||||
if let Some(query) = query {
|
|
||||||
segments.push(percent_encode_component(&format!("?{query}")));
|
|
||||||
}
|
|
||||||
segments.push(scheme.to_owned());
|
|
||||||
segments.push(host.to_owned());
|
|
||||||
if !path.is_empty() {
|
|
||||||
segments.push(
|
|
||||||
path.split('/')
|
|
||||||
.map(percent_encode_component)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("/"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(segments.join("/"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_v2(proxy_path: &str) -> Result<String, ExternalPathError> {
|
|
||||||
let encoded = proxy_path
|
|
||||||
.strip_prefix(V2_PREFIX)
|
|
||||||
.ok_or(ExternalPathError::InvalidExternalPath)?;
|
|
||||||
if encoded.is_empty() {
|
|
||||||
return Err(ExternalPathError::InvalidExternalPath);
|
|
||||||
}
|
|
||||||
let bytes = BASE64_URL_SAFE_NO_PAD
|
|
||||||
.decode(encoded)
|
|
||||||
.map_err(|_| ExternalPathError::InvalidBase64)?;
|
|
||||||
String::from_utf8(bytes).map_err(|_| ExternalPathError::InvalidUtf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hex_val(c: u8) -> Option<u8> {
|
|
||||||
match c {
|
|
||||||
b'0'..=b'9' => Some(c - b'0'),
|
|
||||||
b'a'..=b'f' => Some(c - b'a' + 10),
|
|
||||||
b'A'..=b'F' => Some(c - b'A' + 10),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn percent_decode(input: &str, plus_as_space: bool) -> Vec<u8> {
|
|
||||||
let bytes = input.as_bytes();
|
|
||||||
let mut out = Vec::with_capacity(bytes.len());
|
|
||||||
let mut i = 0;
|
|
||||||
while i < bytes.len() {
|
|
||||||
let ch = bytes[i];
|
|
||||||
if ch == b'%'
|
|
||||||
&& i + 2 < bytes.len()
|
|
||||||
&& let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
|
|
||||||
{
|
|
||||||
out.push((hi << 4) | lo);
|
|
||||||
i += 3;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.push(if plus_as_space && ch == b'+' {
|
|
||||||
b' '
|
|
||||||
} else {
|
|
||||||
ch
|
|
||||||
});
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn percent_decode_string(input: &str, plus_as_space: bool) -> String {
|
|
||||||
String::from_utf8_lossy(&percent_decode(input, plus_as_space)).into_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn legacy_protocol_index(parts: &[&str]) -> Option<usize> {
|
|
||||||
for (index, part) in parts.iter().enumerate() {
|
|
||||||
if part.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if index == 0 && part.contains("%3D") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut chars = part.bytes();
|
|
||||||
let first = chars.next()?;
|
|
||||||
if !first.is_ascii_alphabetic() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'+' | b'.' | b'-')) {
|
|
||||||
return Some(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reconstruct_legacy(proxy_path: &str) -> Result<String, ExternalPathError> {
|
|
||||||
let parts: Vec<&str> = proxy_path.split('/').collect();
|
|
||||||
let protocol_index =
|
|
||||||
legacy_protocol_index(&parts).ok_or(ExternalPathError::InvalidExternalPath)?;
|
|
||||||
if protocol_index + 1 >= parts.len() {
|
|
||||||
return Err(ExternalPathError::InvalidExternalPath);
|
|
||||||
}
|
|
||||||
let protocol = parts[protocol_index];
|
|
||||||
let host_port = percent_decode_string(parts[protocol_index + 1], false);
|
|
||||||
let query_raw = parts[..protocol_index].join("/");
|
|
||||||
let path_raw = parts[protocol_index + 2..].join("/");
|
|
||||||
let query = percent_decode_string(&query_raw, false);
|
|
||||||
let path = percent_decode_string(&path_raw, false);
|
|
||||||
let normalized_query = query.strip_prefix('?').unwrap_or(&query);
|
|
||||||
Ok(format!(
|
|
||||||
"{protocol}://{host_port}/{path}{}{normalized_query}",
|
|
||||||
if normalized_query.is_empty() { "" } else { "?" }
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reconstruct_original_url(proxy_path: &str) -> Result<String, ExternalPathError> {
|
|
||||||
if proxy_path.starts_with(V2_PREFIX) {
|
|
||||||
decode_v2(proxy_path)
|
|
||||||
} else {
|
|
||||||
reconstruct_legacy(proxy_path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn builds_the_plain_path_shape() {
|
|
||||||
assert_eq!(
|
|
||||||
"https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp",
|
|
||||||
build_external_media_proxy_path("https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp")
|
|
||||||
.unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn builds_an_encoded_query_ahead_of_the_scheme() {
|
|
||||||
assert_eq!(
|
|
||||||
"%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/HkAKKCzZ.webp",
|
|
||||||
build_external_media_proxy_path(
|
|
||||||
"https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here"
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn keeps_a_non_default_port() {
|
|
||||||
assert_eq!(
|
|
||||||
"https/example.com:8443/a.png",
|
|
||||||
build_external_media_proxy_path("https://example.com:8443/a.png").unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trips_every_shape() {
|
|
||||||
for url in [
|
|
||||||
"https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp",
|
|
||||||
"https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here",
|
|
||||||
"https://example.com:8443/a.png",
|
|
||||||
"https://avatars.githubusercontent.com/u/241303489?v=4",
|
|
||||||
"http://example.com/plain.gif",
|
|
||||||
"https://example.com/file.png?a=1&b=2&c=3",
|
|
||||||
] {
|
|
||||||
let path = build_external_media_proxy_path(url).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
url,
|
|
||||||
reconstruct_original_url(&path).unwrap(),
|
|
||||||
"round trip {url}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn does_not_double_the_question_mark() {
|
|
||||||
let decoded = reconstruct_original_url("%3Fa%3D1/https/example.com/x.png").unwrap();
|
|
||||||
assert_eq!("https://example.com/x.png?a=1", decoded);
|
|
||||||
assert!(!decoded.contains("??"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn still_accepts_a_query_without_a_leading_question_mark() {
|
|
||||||
assert_eq!(
|
|
||||||
"https://example.com/x.png?a=1",
|
|
||||||
reconstruct_original_url("a%3D1/https/example.com/x.png").unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_a_url_without_a_scheme() {
|
|
||||||
assert!(build_external_media_proxy_path("example.com/a.png").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn v2_path_roundtrip() {
|
|
||||||
let path = build_v2_external_media_proxy_path("https://example.com/a b.png?x=1");
|
|
||||||
let decoded = reconstruct_original_url(&path).unwrap();
|
|
||||||
assert_eq!("https://example.com/a b.png?x=1", decoded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
fluxer_common = { path = "../fluxer_common" }
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
chrono = { version = "0.4.45", default-features = false, features = ["serde"] }
|
chrono = { version = "0.4.45", default-features = false, features = ["serde"] }
|
||||||
|
|||||||
@@ -15,13 +15,11 @@ use crate::types::{
|
|||||||
MessageSnapshot, MessageStickerItem,
|
MessageSnapshot, MessageStickerItem,
|
||||||
};
|
};
|
||||||
use crate::udt;
|
use crate::udt;
|
||||||
use base64::Engine;
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use fluxer_svc::shard::ShardService;
|
use fluxer_svc::shard::ShardService;
|
||||||
use fluxer_svc::transport::NatsTransport;
|
use fluxer_svc::transport::NatsTransport;
|
||||||
use fluxer_svc::{postgres, postgres::BigIntBound, postgres::KeyPart};
|
use fluxer_svc::{postgres, postgres::BigIntBound, postgres::KeyPart};
|
||||||
use futures::stream::{self, StreamExt};
|
use futures::stream::{self, StreamExt};
|
||||||
use hmac::{Hmac, KeyInit, Mac};
|
|
||||||
#[cfg(feature = "scylla")]
|
#[cfg(feature = "scylla")]
|
||||||
use scylla::DeserializeRow;
|
use scylla::DeserializeRow;
|
||||||
#[cfg(feature = "scylla")]
|
#[cfg(feature = "scylla")]
|
||||||
@@ -31,7 +29,6 @@ use scylla::response::query_result::QueryRowsResult;
|
|||||||
#[cfg(feature = "scylla")]
|
#[cfg(feature = "scylla")]
|
||||||
use scylla::statement::prepared::PreparedStatement;
|
use scylla::statement::prepared::PreparedStatement;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sha2::Sha256;
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
#[cfg(feature = "scylla")]
|
#[cfg(feature = "scylla")]
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -2796,23 +2793,12 @@ fn external_media_proxy_url(input_url: &str, options: &ResponseBuildOptions) ->
|
|||||||
Ok(url) => url,
|
Ok(url) => url,
|
||||||
Err(_) => return input_url.to_owned(),
|
Err(_) => return input_url.to_owned(),
|
||||||
};
|
};
|
||||||
let encoded =
|
fluxer_common::external_media_path::build_external_media_proxy_url(
|
||||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(parsed_url.as_str().as_bytes());
|
|
||||||
let path = format!("v2/{encoded}");
|
|
||||||
let signature = create_signature(&path, &options.media_proxy_secret_key);
|
|
||||||
format!(
|
|
||||||
"{}/external/{}/{}",
|
|
||||||
options.media_endpoint.trim_end_matches('/'),
|
options.media_endpoint.trim_end_matches('/'),
|
||||||
signature,
|
parsed_url.as_str(),
|
||||||
path
|
options.media_proxy_secret_key.as_bytes(),
|
||||||
)
|
)
|
||||||
}
|
.unwrap_or_else(|| input_url.to_owned())
|
||||||
|
|
||||||
fn create_signature(input: &str, secret: &str) -> String {
|
|
||||||
let mut mac =
|
|
||||||
Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("hmac accepts any key size");
|
|
||||||
mac.update(input.as_bytes());
|
|
||||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dt_to_epoch_millis(dt: &DateTime<Utc>) -> i64 {
|
fn dt_to_epoch_millis(dt: &DateTime<Utc>) -> i64 {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
fluxer_common = { path = "../fluxer_common" }
|
||||||
ammonia = "4.1.2"
|
ammonia = "4.1.2"
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use std::time::Duration;
|
|||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
const METADATA_TIMEOUT: Duration = Duration::from_secs(5);
|
const METADATA_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
const EXTERNAL_PROXY_VERSION_PREFIX: &str = "v2/";
|
|
||||||
|
|
||||||
pub struct MediaProxyClient {
|
pub struct MediaProxyClient {
|
||||||
http_client: reqwest::Client,
|
http_client: reqwest::Client,
|
||||||
@@ -120,30 +119,14 @@ impl MediaProxyClient {
|
|||||||
return Some(input_url.to_owned());
|
return Some(input_url.to_owned());
|
||||||
}
|
}
|
||||||
let parsed = Url::parse(input_url).ok()?;
|
let parsed = Url::parse(input_url).ok()?;
|
||||||
let encoded = base64::Engine::encode(
|
fluxer_common::external_media_path::build_external_media_proxy_url(
|
||||||
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
&self.public_endpoint,
|
||||||
parsed.as_str(),
|
parsed.as_str(),
|
||||||
);
|
self.secret_key.as_bytes(),
|
||||||
let path = format!("{EXTERNAL_PROXY_VERSION_PREFIX}{encoded}");
|
)
|
||||||
let signature = create_signature(&path, &self.secret_key);
|
|
||||||
Some(format!(
|
|
||||||
"{}/external/{}/{}",
|
|
||||||
self.public_endpoint, signature, path
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_signature(input: &str, secret: &str) -> String {
|
|
||||||
use hmac::{Hmac, KeyInit, Mac};
|
|
||||||
let mut mac =
|
|
||||||
Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes()).expect("hmac accepts any key size");
|
|
||||||
mac.update(input.as_bytes());
|
|
||||||
base64::Engine::encode(
|
|
||||||
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
|
||||||
mac.finalize().into_bytes(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -178,7 +161,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn external_proxy_url_uses_public_endpoint_and_v2_path() {
|
fn external_proxy_url_uses_public_endpoint_and_plain_path() {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let mp = MediaProxyClient::new_with_public_endpoint(
|
let mp = MediaProxyClient::new_with_public_endpoint(
|
||||||
"http://media-proxy:8080/",
|
"http://media-proxy:8080/",
|
||||||
@@ -191,15 +174,18 @@ mod tests {
|
|||||||
.expect("proxy url");
|
.expect("proxy url");
|
||||||
|
|
||||||
assert!(proxy.starts_with("https://media.example.test/external/"));
|
assert!(proxy.starts_with("https://media.example.test/external/"));
|
||||||
assert!(proxy.contains("/v2/"));
|
assert!(proxy.contains("/https/"));
|
||||||
|
|
||||||
let encoded = proxy.rsplit('/').next().expect("encoded path segment");
|
let path = proxy
|
||||||
let decoded =
|
.split_once("/external/")
|
||||||
base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, encoded)
|
.expect("external segment")
|
||||||
.expect("valid base64");
|
.1
|
||||||
|
.split_once('/')
|
||||||
|
.expect("signature segment")
|
||||||
|
.1;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
String::from_utf8(decoded).expect("utf8"),
|
"https://pbs.twimg.com/media/a.jpg?name=orig",
|
||||||
"https://pbs.twimg.com/media/a.jpg?name=orig"
|
fluxer_common::external_media_path::reconstruct_original_url(path).expect("decodes")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
mp.external_proxy_url("https://media.example.test/external/already"),
|
mp.external_proxy_url("https://media.example.test/external/already"),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
fluxer_common = { path = "../../fluxer_common" }
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
axum = "0.8.9"
|
axum = "0.8.9"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
|
||||||
use clap::Args;
|
use clap::Args;
|
||||||
use hmac::{Hmac, KeyInit, Mac};
|
|
||||||
use sha2::Sha256;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Args)]
|
#[derive(Debug, Clone, Args)]
|
||||||
pub struct SignExternalUrlArgs {
|
pub struct SignExternalUrlArgs {
|
||||||
@@ -16,17 +13,12 @@ pub struct SignExternalUrlArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn sign_external_url(secret_key: &str, server_url: &str, upstream: &str) -> Result<String> {
|
pub fn sign_external_url(secret_key: &str, server_url: &str, upstream: &str) -> Result<String> {
|
||||||
let path = format!("v2/{}", URL_SAFE_NO_PAD.encode(upstream.as_bytes()));
|
fluxer_common::external_media_path::build_external_media_proxy_url(
|
||||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret_key.as_bytes())
|
|
||||||
.context("failed to create HMAC signer")?;
|
|
||||||
mac.update(path.as_bytes());
|
|
||||||
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
|
|
||||||
Ok(format!(
|
|
||||||
"{}/external/{}/{}",
|
|
||||||
server_url.trim_end_matches('/'),
|
server_url.trim_end_matches('/'),
|
||||||
signature,
|
upstream,
|
||||||
path
|
secret_key.as_bytes(),
|
||||||
))
|
)
|
||||||
|
.context("failed to build the external media proxy url")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -41,13 +33,20 @@ mod tests {
|
|||||||
"https://example.test/a b.jpg",
|
"https://example.test/a b.jpg",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let parts = signed.split('/').collect::<Vec<_>>();
|
|
||||||
assert!(signed.starts_with("http://127.0.0.1:19110/external/"));
|
assert!(signed.starts_with("http://127.0.0.1:19110/external/"));
|
||||||
assert_eq!(parts[5], "v2");
|
assert!(signed.ends_with("/https/example.test/a%20b.jpg"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
URL_SAFE_NO_PAD.decode(parts[6]).unwrap(),
|
"https://example.test/a b.jpg",
|
||||||
b"https://example.test/a b.jpg"
|
fluxer_common::external_media_path::reconstruct_original_url(
|
||||||
|
signed
|
||||||
|
.split_once("/external/")
|
||||||
|
.unwrap()
|
||||||
|
.1
|
||||||
|
.split_once('/')
|
||||||
|
.unwrap()
|
||||||
|
.1
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
);
|
);
|
||||||
assert_eq!(URL_SAFE_NO_PAD.decode(parts[4]).unwrap().len(), 32);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user