use crate::{
common::timestamp,
limits::VALIDATION_LIMITS,
traits::{HasIdPath, TimestampId, Validatable},
APP_PATH, PUBLIC_PATH,
};
use mime::Mime;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use url::Url;
#[cfg(target_arch = "wasm32")]
use crate::traits::Json;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*;
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
pub const VALID_MIME_TYPES: &[&str] = &[
"application/javascript",
"application/json",
"application/octet-stream",
"application/pdf",
"application/x-www-form-urlencoded",
"application/xml",
"application/zip",
"audio/mpeg",
"audio/wav",
"image/gif",
"image/jpeg",
"image/png",
"image/svg+xml",
"image/webp",
"multipart/form-data",
"text/css",
"text/html",
"text/plain",
"text/xml",
"video/mp4",
"video/mpeg",
];
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
#[derive(Deserialize, Serialize, Debug, Default, Clone)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct PubkyAppFile {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub name: String,
pub created_at: i64,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub src: String,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub content_type: String,
pub size: usize,
}
#[cfg(target_arch = "wasm32")]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
impl PubkyAppFile {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn name(&self) -> String {
self.name.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn src(&self) -> String {
self.src.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn content_type(&self) -> String {
self.content_type.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = fromJson))]
pub fn from_json(js_value: &JsValue) -> Result<Self, String> {
Self::import_json(js_value)
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = toJson))]
pub fn to_json(&self) -> Result<JsValue, String> {
self.export_json()
}
}
#[cfg(target_arch = "wasm32")]
impl Json for PubkyAppFile {}
impl PubkyAppFile {
pub fn new(name: String, src: String, content_type: String, size: usize) -> Self {
let created_at = timestamp();
Self {
name,
created_at,
src,
content_type,
size,
}
.sanitize()
}
}
impl TimestampId for PubkyAppFile {}
impl HasIdPath for PubkyAppFile {
const PATH_SEGMENT: &'static str = "files/";
fn create_path(id: &str) -> String {
[PUBLIC_PATH, APP_PATH, Self::PATH_SEGMENT, id].concat()
}
}
impl Validatable for PubkyAppFile {
fn sanitize(self) -> Self {
let name = self.name.trim().to_string();
let sanitized_src = self
.src
.trim()
.chars()
.take(VALIDATION_LIMITS.file_src_max_length)
.collect::<String>();
let src = match Url::parse(&sanitized_src) {
Ok(_) => Some(sanitized_src),
Err(_) => None, };
let content_type = self.content_type.trim().to_string();
Self {
name,
created_at: self.created_at,
src: src.unwrap_or("".to_string()),
content_type,
size: self.size,
}
}
fn validate(&self, id: Option<&str>) -> Result<(), String> {
if let Some(id) = id {
self.validate_id(id)?;
}
if self.size == 0 {
return Err("Validation Error: File size cannot be zero".to_string());
}
if self.size > VALIDATION_LIMITS.max_file_size_bytes {
return Err("Validation Error: File size exceeds maximum limit of 100MB".to_string());
}
let name_length = self.name.chars().count();
if !(VALIDATION_LIMITS.file_name_min_length..=VALIDATION_LIMITS.file_name_max_length)
.contains(&name_length)
{
return Err("Validation Error: Invalid name length".into());
}
if self.src.chars().count() == 0 {
return Err("Validation Error: Invalid src".into());
}
if self.src.chars().count() > VALIDATION_LIMITS.file_src_max_length {
return Err("Validation Error: src exceeds maximum length".into());
}
Url::parse(&self.src)
.map_err(|_| "Validation Error: Invalid src URI format".to_string())?;
match Mime::from_str(&self.content_type) {
Ok(mime) => {
if !VALID_MIME_TYPES.contains(&mime.essence_str()) {
return Err("Validation Error: Invalid content type".into());
}
}
Err(_) => {
return Err("Validation Error: Invalid content type".into());
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{blob_uri_builder, traits::Validatable};
#[test]
fn test_new() {
let file = PubkyAppFile::new(
"example.png".to_string(),
blob_uri_builder("user_id".into(), "id".into()),
"image/png".to_string(),
1024,
);
assert_eq!(file.name, "example.png");
assert_eq!(file.src, "pubky://user_id/pub/pubky.app/blobs/id");
assert_eq!(file.content_type, "image/png");
assert_eq!(file.size, 1024);
let now = timestamp();
assert!(file.created_at <= now && file.created_at >= now - 1_000_000); }
#[test]
fn test_create_path() {
let file = PubkyAppFile::new(
"example.png".to_string(),
blob_uri_builder("user_id".into(), "id".into()),
"image/png".to_string(),
1024,
);
let file_id = file.create_id();
let path = PubkyAppFile::create_path(&file_id);
let prefix = format!("{}{}files/", PUBLIC_PATH, APP_PATH);
assert!(path.starts_with(&prefix));
let expected_path_len = prefix.len() + file_id.len();
assert_eq!(path.len(), expected_path_len);
}
#[test]
fn test_validate() {
let file = PubkyAppFile::new(
"example.png".to_string(),
blob_uri_builder("user_id".into(), "id".into()),
"image/png".to_string(),
1024,
);
let id = file.create_id();
let result = file.validate(Some(&id));
assert!(result.is_ok());
}
#[test]
fn test_validate_invalid_id() {
let file = PubkyAppFile::new(
"example.png".to_string(),
blob_uri_builder("user_id".into(), "id".into()),
"image/png".to_string(),
1024,
);
let invalid_id = "INVALIDID";
let result = file.validate(Some(invalid_id));
assert!(result.is_err());
}
#[test]
fn test_validate_field_errors() {
let test_cases = vec![
(
PubkyAppFile::new(
"example.png".to_string(),
blob_uri_builder("user_id".into(), "id".into()),
"notavalid/content_type".to_string(),
1024,
),
"Invalid content type",
),
(
PubkyAppFile::new(
"example.png".to_string(),
blob_uri_builder("user_id".into(), "id".into()),
"image/png".to_string(),
VALIDATION_LIMITS.max_file_size_bytes + 1,
),
"exceeds maximum limit",
),
(
PubkyAppFile::new(
"example.png".to_string(),
blob_uri_builder("user_id".into(), "id".into()),
"image/png".to_string(),
0,
),
"cannot be zero",
),
];
for (file, expected_error) in test_cases {
let id = file.create_id();
let result = file.validate(Some(&id));
assert!(
result.is_err(),
"Should reject file with {}",
expected_error
);
assert!(result.unwrap_err().contains(expected_error));
}
}
#[test]
fn test_validate_invalid_src() {
let file = PubkyAppFile {
name: "example.png".to_string(),
created_at: timestamp(),
src: "not_a_url".to_string(), content_type: "image/png".to_string(),
size: 1024,
};
let id = file.create_id();
let result = file.validate(Some(&id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid src"));
}
#[test]
fn test_try_from_valid() {
let file_json = r#"
{
"name": "example.png",
"created_at": 1627849723,
"src": "pubky://user_id/pub/pubky.app/blobs/id",
"content_type": "image/png",
"size": 1024
}
"#;
let file = PubkyAppFile::new(
"example.png".to_string(),
blob_uri_builder("user_id".into(), "id".into()),
"image/png".to_string(),
1024,
);
let id = file.create_id();
let blob = file_json.as_bytes();
let file_parsed = <PubkyAppFile as Validatable>::try_from(blob, &id).unwrap();
assert_eq!(file_parsed.name, "example.png");
assert_eq!(file_parsed.src, "pubky://user_id/pub/pubky.app/blobs/id");
assert_eq!(file_parsed.content_type, "image/png");
assert_eq!(file_parsed.size, 1024);
}
}