use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::envelope::Ack;
use crate::error::{ApiError, ValidationDetails};
use crate::ids::{FileId, UserId};
use crate::page::{Cursor, Page};
use crate::time::UnixMillis;
pub const UPLOAD_META_PART: &str = "meta";
pub const UPLOAD_FILE_PART: &str = "file";
pub const MAX_NAME_BYTES: usize = 255;
pub const MAX_CONTENT_TYPE_BYTES: usize = 127;
pub const MAX_SHARED_WITH: usize = 100;
pub const DEFAULT_MAX_METADATA_BYTES: usize = 4096;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum FileVisibility {
#[default]
Private,
Public,
Friends,
Shared,
#[serde(other)]
Unknown,
}
impl FileVisibility {
pub fn as_str(self) -> &'static str {
match self {
FileVisibility::Private => "private",
FileVisibility::Public => "public",
FileVisibility::Friends => "friends",
FileVisibility::Shared => "shared",
FileVisibility::Unknown => "unknown",
}
}
pub fn parse(text: &str) -> Option<Self> {
match text {
"private" => Some(FileVisibility::Private),
"public" => Some(FileVisibility::Public),
"friends" => Some(FileVisibility::Friends),
"shared" => Some(FileVisibility::Shared),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FileInfo {
pub id: FileId,
pub owner: UserId,
pub name: String,
pub content_type: String,
pub size: u64,
pub sha256: String,
#[serde(default)]
pub visibility: FileVisibility,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub shared_with: Vec<UserId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
pub created_at: UnixMillis,
pub updated_at: UnixMillis,
}
impl FileInfo {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: FileId,
owner: UserId,
name: impl Into<String>,
content_type: impl Into<String>,
size: u64,
sha256: impl Into<String>,
at: UnixMillis,
) -> Self {
Self {
id,
owner,
name: name.into(),
content_type: content_type.into(),
size,
sha256: sha256.into(),
visibility: FileVisibility::Private,
shared_with: Vec::new(),
metadata: None,
created_at: at,
updated_at: at,
}
}
}
pub fn name_problem(name: &str) -> Option<&'static str> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Some("is empty");
}
if trimmed.len() > MAX_NAME_BYTES {
return Some("is longer than 255 bytes");
}
if trimmed.contains(['/', '\\']) {
return Some("contains a slash");
}
crate::text::name_problem(trimmed)
}
pub fn is_valid_content_type(content_type: &str) -> bool {
let token = |part: &str| {
!part.is_empty()
&& part.len() <= 64
&& part.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'!' | b'#' | b'$' | b'&' | b'-' | b'^' | b'_' | b'.' | b'+'))
};
content_type.len() <= MAX_CONTENT_TYPE_BYTES && content_type.split_once('/').is_some_and(|(kind, sub)| token(kind) && token(sub))
}
pub fn is_sha256_hex(text: &str) -> bool {
text.len() == 64 && text.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
fn check_shares(details: &mut ValidationDetails, visibility: Option<FileVisibility>, shared_with: Option<&Vec<UserId>>) {
if let Some(list) = shared_with {
if list.len() > MAX_SHARED_WITH {
details.add("shared_with", format!("names more than {MAX_SHARED_WITH} accounts"));
}
}
if visibility == Some(FileVisibility::Unknown) {
details.add("visibility", "is not private, public, friends or shared");
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FileMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visibility: Option<FileVisibility>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shared_with: Option<Vec<UserId>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
}
impl FileMeta {
pub fn new() -> Self {
Self::default()
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_content_type(mut self, content_type: impl Into<String>) -> Self {
self.content_type = Some(content_type.into());
self
}
pub fn with_visibility(mut self, visibility: FileVisibility) -> Self {
self.visibility = Some(visibility);
self
}
pub fn shared_with(mut self, users: Vec<UserId>) -> Self {
self.visibility = Some(FileVisibility::Shared);
self.shared_with = Some(users);
self
}
pub fn with_metadata(mut self, metadata: Value) -> Self {
self.metadata = Some(metadata);
self
}
pub fn with_sha256(mut self, sha256: impl Into<String>) -> Self {
self.sha256 = Some(sha256.into());
self
}
pub fn validate(&self) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
if let Some(problem) = self.name.as_deref().and_then(name_problem) {
details.add("name", problem);
}
if self.content_type.as_deref().is_some_and(|c| !is_valid_content_type(c)) {
details.add("content_type", "is not a plain type/subtype");
}
if self.sha256.as_deref().is_some_and(|s| !is_sha256_hex(s)) {
details.add("sha256", "is not 64 lower-case hex characters");
}
check_shares(&mut details, self.visibility, self.shared_with.as_ref());
details.into_result()
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UpdateFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visibility: Option<FileVisibility>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shared_with: Option<Vec<UserId>>,
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
}
fn present<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Option<Value>, D::Error> {
Value::deserialize(deserializer).map(Some)
}
impl UpdateFile {
pub fn new() -> Self {
Self::default()
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_visibility(mut self, visibility: FileVisibility) -> Self {
self.visibility = Some(visibility);
self
}
pub fn shared_with(mut self, users: Vec<UserId>) -> Self {
self.visibility = Some(FileVisibility::Shared);
self.shared_with = Some(users);
self
}
pub fn with_metadata(mut self, metadata: Value) -> Self {
self.metadata = Some(metadata);
self
}
pub fn validate(&self) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
if let Some(problem) = self.name.as_deref().and_then(name_problem) {
details.add("name", problem);
}
check_shares(&mut details, self.visibility, self.shared_with.as_ref());
details.into_result()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FileQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<UserId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<Cursor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
impl FileQuery {
pub fn mine() -> Self {
Self::default()
}
pub fn of(owner: UserId) -> Self {
Self { owner: Some(owner), ..Self::default() }
}
pub fn after(mut self, cursor: Cursor) -> Self {
self.cursor = Some(cursor);
self
}
pub fn with_limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FileUsage {
pub files: u64,
pub bytes: u64,
pub max_files: u64,
pub max_bytes: u64,
pub max_file_bytes: u64,
}
impl FileUsage {
pub fn new(files: u64, bytes: u64, max_files: u64, max_bytes: u64, max_file_bytes: u64) -> Self {
Self { files, bytes, max_files, max_bytes, max_file_bytes }
}
}
mod calls {
use super::*;
use crate::http_call::{HttpCall, NoPayload, PathParams, PayloadKind, NO_PAYLOAD};
use crate::routes::{self, HttpMethod, Route};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct ListFiles {
pub query: FileQuery,
}
impl ListFiles {
pub fn new() -> Self {
Self::default()
}
pub fn with_query(mut self, query: FileQuery) -> Self {
self.query = query;
self
}
}
impl HttpCall for ListFiles {
type Payload = FileQuery;
type Response = Page<FileInfo>;
const ROUTE: Route = Route::new(HttpMethod::Get, routes::files::LIST, true);
const PAYLOAD: PayloadKind = PayloadKind::Query;
fn payload(&self) -> &FileQuery {
&self.query
}
fn from_parts(_params: &PathParams, query: FileQuery) -> Result<Self, ApiError> {
Ok(Self::new().with_query(query))
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct GetFileUsage {}
impl GetFileUsage {
pub fn new() -> Self {
Self {}
}
}
impl HttpCall for GetFileUsage {
type Payload = NoPayload;
type Response = FileUsage;
const ROUTE: Route = Route::new(HttpMethod::Get, routes::files::USAGE, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn from_parts(_params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
Ok(Self::new())
}
}
macro_rules! file_call {
($(#[$meta:meta])* $name:ident, $method:ident, $response:ty) => {
$(#[$meta])*
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct $name {
/// The file.
pub file: FileId,
}
impl $name {
pub fn new(file: FileId) -> Self {
Self { file }
}
}
impl HttpCall for $name {
type Payload = NoPayload;
type Response = $response;
const ROUTE: Route = Route::new(HttpMethod::$method, routes::files::ONE, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn path_params(&self) -> PathParams {
PathParams::new().with("file", self.file)
}
fn from_parts(params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
Ok(Self::new(params.id("file")?))
}
}
};
}
file_call!(
GetFile,
Get,
FileInfo
);
file_call!(
DeleteFile,
Delete,
Ack
);
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct EditFile {
pub file: FileId,
pub update: UpdateFile,
}
impl EditFile {
pub fn new(file: FileId, update: UpdateFile) -> Self {
Self { file, update }
}
}
impl HttpCall for EditFile {
type Payload = UpdateFile;
type Response = FileInfo;
const ROUTE: Route = Route::new(HttpMethod::Patch, routes::files::ONE, true);
const PAYLOAD: PayloadKind = PayloadKind::Json;
fn payload(&self) -> &UpdateFile {
&self.update
}
fn path_params(&self) -> PathParams {
PathParams::new().with("file", self.file)
}
fn from_parts(params: &PathParams, update: UpdateFile) -> Result<Self, ApiError> {
Ok(Self::new(params.id("file")?, update))
}
}
}
pub use calls::{DeleteFile, EditFile, GetFile, GetFileUsage, ListFiles};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rules() {
assert!(name_problem("level-3.map").is_none());
assert!(name_problem(" ").is_some() && name_problem("a/b").is_some() && name_problem("a\\b").is_some());
assert!(name_problem(&"x".repeat(256)).is_some() && name_problem("a\u{202e}b").is_some());
assert!(is_valid_content_type("image/png") && is_valid_content_type("application/vnd.game+json"));
assert!(!is_valid_content_type("image") && !is_valid_content_type("text/html; charset=utf-8") && !is_valid_content_type("a/b c"));
assert!(is_sha256_hex(&"a".repeat(64)) && !is_sha256_hex(&"A".repeat(64)) && !is_sha256_hex("abc"));
assert!(FileMeta::new().with_sha256("nope").validate().is_err());
assert!(FileMeta::new().shared_with((0..101).map(UserId).collect()).validate().is_err());
assert!(FileMeta::new().with_name("a.png").with_content_type("image/png").validate().is_ok());
assert!(UpdateFile::new().with_name("").validate().is_err());
assert_eq!(FileVisibility::parse("friends"), Some(FileVisibility::Friends));
assert_eq!(FileVisibility::parse("nope"), None);
}
#[test]
fn json() {
let meta = FileMeta::new().with_name("a.png").shared_with(vec![UserId(4)]);
assert_eq!(serde_json::to_value(&meta).unwrap_or_default(), serde_json::json!({"name": "a.png", "visibility": "shared", "shared_with": [4]}));
let update: UpdateFile = serde_json::from_str(r#"{"metadata":null}"#).unwrap_or_default();
assert_eq!(update.metadata, Some(Value::Null));
let keep: UpdateFile = serde_json::from_str("{}").unwrap_or_default();
assert_eq!(keep.metadata, None);
let info: FileInfo = serde_json::from_value(serde_json::json!({
"id": 1, "owner": 2, "name": "a", "content_type": "x/y", "size": 3, "sha256": "s", "visibility": "galaxy",
"created_at": 5, "updated_at": 6
}))
.unwrap_or_else(|_| FileInfo::new(FileId(0), UserId(0), "", "", 0, "", UnixMillis(0)));
assert_eq!((info.id, info.visibility, info.shared_with.len()), (FileId(1), FileVisibility::Unknown, 0));
}
}