#[derive(Debug, Clone)]
pub struct FileServerConfig {
pub scheme: String,
pub host: String,
pub port: u16,
pub pubkey_hex: String,
pub max_file_size: u64,
pub use_stream_encryption: bool,
}
pub fn default_config() -> FileServerConfig {
FileServerConfig {
scheme: "http".to_string(),
host: "filev2.getsession.org".to_string(),
port: 80,
pubkey_hex: "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59"
.to_string(),
max_file_size: 10_000_000,
use_stream_encryption: false,
}
}
pub const HEADER_CONTENT_TYPE: &str = "Content-Type";
pub const HEADER_CONTENT_DISPOSITION: &str = "Content-Disposition";
pub const HEADER_PUBKEY: &str = "X-FS-Pubkey";
pub const HEADER_TIMESTAMP: &str = "X-FS-Timestamp";
pub const HEADER_SIGNATURE: &str = "X-FS-Signature";
pub const HEADER_TTL: &str = "X-FS-TTL";
pub const ENDPOINT_FILE: &str = "file";
pub const ENDPOINT_FILE_INDIVIDUAL: &str = "file/{}";
pub const ENDPOINT_EXTEND: &str = "file/{}/extend";
pub const LEGACY_ENDPOINT_FILE_INDIVIDUAL: &str = "files/{}";
pub const FRAGMENT_PUBKEY: &str = "p";
pub const FRAGMENT_STREAM_ENCRYPTION: &str = "d";
#[derive(Debug, Clone)]
pub struct DownloadInfo {
pub scheme: String,
pub host: String,
pub file_id: String,
pub custom_pubkey_hex: Option<String>,
pub wants_stream_decryption: bool,
}
pub fn parse_download_url(url: &str) -> Option<DownloadInfo> {
let (base, file_id) = match_file_endpoint(url, "/file/")
.or_else(|| match_file_endpoint(url, "/files/"))?;
if file_id.is_empty() {
return None;
}
let scheme_end = base.find("://")?;
let scheme = &base[..scheme_end];
let host = &base[scheme_end + 3..];
if host.is_empty() {
return None;
}
let mut info = DownloadInfo {
scheme: scheme.to_string(),
host: host.to_string(),
file_id: file_id.to_string(),
custom_pubkey_hex: None,
wants_stream_decryption: false,
};
if let Some(fragment_pos) = url.find('#') {
let fragments = &url[fragment_pos + 1..];
let default_pk = default_config().pubkey_hex;
for fragment in fragments.split('&') {
if fragment == FRAGMENT_STREAM_ENCRYPTION {
info.wants_stream_decryption = true;
} else if fragment.starts_with(&format!("{}=", FRAGMENT_PUBKEY)) {
let pk_value = &fragment[2..];
if pk_value.len() == 64
&& hex::decode(pk_value).is_ok()
&& pk_value != default_pk
{
info.custom_pubkey_hex = Some(pk_value.to_string());
}
}
}
}
Some(info)
}
pub fn generate_download_url(file_id: &str, config: &FileServerConfig) -> String {
let default_pk = default_config().pubkey_hex;
let has_custom_pubkey = config.pubkey_hex != default_pk;
let mut url = format!("{}://{}/file/{}", config.scheme, config.host, file_id);
if config.use_stream_encryption || has_custom_pubkey {
url.push('#');
if has_custom_pubkey {
url.push_str(&format!("{}={}", FRAGMENT_PUBKEY, config.pubkey_hex));
}
if config.use_stream_encryption {
if has_custom_pubkey {
url.push('&');
}
url.push_str(FRAGMENT_STREAM_ENCRYPTION);
}
}
url
}
#[derive(Debug, Clone)]
pub struct FileMetadata {
pub id: String,
pub size: i64,
pub uploaded: Option<i64>,
pub expiry: Option<i64>,
}
pub fn parse_upload_response(
body: &str,
upload_size: usize,
) -> Result<FileMetadata, String> {
let json: serde_json::Value =
serde_json::from_str(body).map_err(|e| e.to_string())?;
let id = json
.get("id")
.and_then(|v| v.as_str())
.ok_or("Upload response missing required 'id' field")?
.to_string();
let mut size = json
.get("size")
.and_then(|v| v.as_i64())
.unwrap_or(0);
if size == 0 {
size = upload_size as i64;
}
let uploaded = json
.get("uploaded")
.and_then(|v| v.as_i64());
let expiry = json
.get("expires")
.and_then(|v| v.as_i64());
Ok(FileMetadata {
id,
size,
uploaded,
expiry,
})
}
fn match_file_endpoint<'a>(url: &'a str, prefix: &str) -> Option<(&'a str, &'a str)> {
let pattern_start = url.find(prefix)?;
let base = &url[..pattern_start];
let after_prefix = &url[pattern_start + prefix.len()..];
let id_end = after_prefix
.find(|c: char| c == '#' || c == '?' || c == '/')
.unwrap_or(after_prefix.len());
let file_id = &after_prefix[..id_end];
Some((base, file_id))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = default_config();
assert_eq!(config.scheme, "http");
assert_eq!(config.host, "filev2.getsession.org");
assert_eq!(config.port, 80);
assert_eq!(config.max_file_size, 10_000_000);
}
#[test]
fn test_parse_download_url_basic() {
let url = "https://example.com/file/abc123";
let info = parse_download_url(url).unwrap();
assert_eq!(info.scheme, "https");
assert_eq!(info.host, "example.com");
assert_eq!(info.file_id, "abc123");
assert!(info.custom_pubkey_hex.is_none());
assert!(!info.wants_stream_decryption);
}
#[test]
fn test_parse_download_url_with_custom_pubkey() {
let url = "https://example.com/file/abc123#p=aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd";
let info = parse_download_url(url).unwrap();
assert_eq!(info.file_id, "abc123");
assert_eq!(
info.custom_pubkey_hex.as_deref(),
Some("aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd")
);
}
#[test]
fn test_parse_download_url_with_stream_decryption() {
let url = "https://example.com/file/abc123#d";
let info = parse_download_url(url).unwrap();
assert!(info.wants_stream_decryption);
}
#[test]
fn test_parse_download_url_with_both_fragments() {
let url = "https://example.com/file/abc123#p=aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd&d";
let info = parse_download_url(url).unwrap();
assert!(info.custom_pubkey_hex.is_some());
assert!(info.wants_stream_decryption);
}
#[test]
fn test_parse_download_url_legacy() {
let url = "https://example.com/files/abc123";
let info = parse_download_url(url).unwrap();
assert_eq!(info.file_id, "abc123");
}
#[test]
fn test_parse_download_url_invalid() {
assert!(parse_download_url("not-a-url").is_none());
assert!(parse_download_url("").is_none());
}
#[test]
fn test_generate_download_url_basic() {
let config = default_config();
let url = generate_download_url("abc123", &config);
assert_eq!(url, "http://filev2.getsession.org/file/abc123");
}
#[test]
fn test_generate_download_url_with_stream_encryption() {
let mut config = default_config();
config.use_stream_encryption = true;
let url = generate_download_url("abc123", &config);
assert_eq!(url, "http://filev2.getsession.org/file/abc123#d");
}
#[test]
fn test_generate_download_url_with_custom_pubkey() {
let mut config = default_config();
config.pubkey_hex = "aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd"
.to_string();
let url = generate_download_url("abc123", &config);
assert!(url.contains("#p=aabbccdd"));
}
#[test]
fn test_generate_download_url_with_both() {
let mut config = default_config();
config.pubkey_hex = "aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd"
.to_string();
config.use_stream_encryption = true;
let url = generate_download_url("abc123", &config);
assert!(url.contains("#p=aabbccdd"));
assert!(url.contains("&d"));
}
#[test]
fn test_parse_upload_response() {
let body = r#"{"id":"file123","size":1024,"uploaded":1700000000,"expires":1700086400}"#;
let meta = parse_upload_response(body, 0).unwrap();
assert_eq!(meta.id, "file123");
assert_eq!(meta.size, 1024);
assert_eq!(meta.uploaded, Some(1700000000));
assert_eq!(meta.expiry, Some(1700086400));
}
#[test]
fn test_parse_upload_response_fallback_size() {
let body = r#"{"id":"file123"}"#;
let meta = parse_upload_response(body, 500).unwrap();
assert_eq!(meta.id, "file123");
assert_eq!(meta.size, 500);
}
#[test]
fn test_parse_upload_response_missing_id() {
let body = r#"{"size":1024}"#;
assert!(parse_upload_response(body, 0).is_err());
}
#[test]
fn test_roundtrip_url() {
let config = default_config();
let url = generate_download_url("testfile42", &config);
let parsed = parse_download_url(&url).unwrap();
assert_eq!(parsed.file_id, "testfile42");
assert_eq!(parsed.scheme, config.scheme);
assert_eq!(parsed.host, config.host);
}
}