diff --git a/Cargo.lock b/Cargo.lock index 519d51d..cad3e4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -936,6 +936,8 @@ dependencies = [ "console", "lazy_static", "linked-hash-map", + "pest", + "pest_derive", "ron", "serde", "similar", @@ -1240,6 +1242,50 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" +[[package]] +name = "pest" +version = "2.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cbd939b234e95d72bc393d51788aec68aeeb5d51e748ca08ff3aad58cb722f7" +dependencies = [ + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a81186863f3d0a27340815be8f2078dd8050b14cd71913db9fbda795e5f707d7" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a1ef20bf3193c15ac345acb32e26b3dc3223aff4d77ae4fc5359567683796b" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e3b284b1f13a20dc5ebc90aff59a51b8d7137c221131b52a7260c08cbc1cc80" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + [[package]] name = "pin-project" version = "0.4.30" @@ -2114,6 +2160,12 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" +[[package]] +name = "ucd-trie" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81" + [[package]] name = "uncased" version = "0.9.7" diff --git a/Cargo.toml b/Cargo.toml index 543fa6e..bfd918f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ time = { version = "0.3.15", features = [ ] } sha2 = "0.10.6" path_macro = "1.0.0" +hex-literal = "0.3.4" hex = { version = "0.4.3", features = ["serde"] } temp-dir = "0.1.11" zip = { version = "0.6.4", default-features = false, features = [ @@ -49,5 +50,4 @@ rstest = "0.16.0" poem = { version = "1.3.55", features = ["test"] } tokio-test = "0.4.2" temp_testdir = "0.2.3" -insta = { version = "1.17.1", features = ["ron"] } -hex-literal = "0.3.4" +insta = { version = "1.17.1", features = ["ron", "redactions"] } diff --git a/src/api.rs b/src/api.rs index f99886a..d0020aa 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,5 +1,6 @@ -use std::{collections::BTreeMap, io::Cursor}; +use std::io::Cursor; +use hex_literal::hex; use poem::{ error::{Error, ResponseError}, http::StatusCode, @@ -9,7 +10,7 @@ use poem::{ use poem_openapi::{ auth::ApiKey, param::{Path, Query}, - payload::{Binary, Json}, + payload::{Binary, Html, Json}, OpenApi, SecurityScheme, }; @@ -17,7 +18,7 @@ use crate::{ config::{Access, KeyCfg}, db, model::*, - oai::DynParams, + oai::{DynParams, FileResponse}, util, Talon, }; @@ -34,7 +35,8 @@ struct ApiKeyAuthorization(KeyCfg); async fn api_key_checker(req: &Request, api_key: ApiKey) -> Option { let talon = req.data::()?; - talon.cfg.keys.get(&api_key.key).cloned() + let x = talon.cfg.keys.get(&api_key.key).cloned(); + x } impl ApiKeyAuthorization { @@ -57,12 +59,16 @@ enum ApiError { NoAccess, #[error("invalid fallback: {0}")] InvalidFallback(String), + #[error("invalid archive type")] + InvalidArchiveType, } impl ResponseError for ApiError { fn status(&self) -> StatusCode { match self { - ApiError::InvalidSubdomain | ApiError::InvalidFallback(_) => StatusCode::BAD_REQUEST, + ApiError::InvalidSubdomain + | ApiError::InvalidFallback(_) + | ApiError::InvalidArchiveType => StatusCode::BAD_REQUEST, ApiError::NoAccess => StatusCode::UNAUTHORIZED, } } @@ -71,6 +77,19 @@ impl ResponseError for ApiError { #[OpenApi] #[allow(clippy::too_many_arguments)] impl TalonApi { + /// Show some information about the API + #[oai(path = "/", method = "get", hidden)] + async fn root(&self) -> Html<&str> { + // TODO: use a pretty template for this + Html( + r#" +

Talon API

+

Rumfingern

+

OpenAPI specification

+"#, + ) + } + /// Get a website #[oai(path = "/website/:subdomain", method = "get")] async fn website_get( @@ -87,7 +106,7 @@ impl TalonApi { /// Create a new website #[oai(path = "/website/:subdomain", method = "put")] - async fn website_post( + async fn website_create( &self, auth: ApiKeyAuthorization, talon: Data<&Talon>, @@ -232,12 +251,12 @@ impl TalonApi { talon: Data<&Talon>, subdomain: Path, version: Path, - ) -> Result>> { + ) -> Result>> { talon.db.version_exists(&subdomain, *version)?; talon .db .get_version_files(&subdomain, *version) - .map(|r| r.map(|f| f.0)) + .map(|r| r.map(VersionFile::from)) .collect::, _>>() .map(Json) .map_err(Error::from) @@ -258,51 +277,72 @@ impl TalonApi { Ok(()) } - /// Insert a new version into the database - fn insert_version( - talon: &Talon, - subdomain: &str, - fallback: Option, - spa: bool, - mut version_data: BTreeMap, - ) -> Result { + /// Upload a new version + #[oai(path = "/website/:subdomain/upload", method = "post")] + async fn version_upload( + &self, + auth: ApiKeyAuthorization, + talon: Data<&Talon>, + subdomain: Path, + /// Fallback page + /// + /// The fallback page gets returned when the requested page does not exist + fallback: Query>, + /// SPA mode (return fallback page with OK status) + #[oai(default)] + spa: Query, + /// Associated version data + /// + /// This is an arbitrary string map that can hold build information and other stuff + /// and will be displayed in the site info dialog. + version_data: DynParams, + /// Archive containing the website files. + /// + /// Supported types: zip, tar.gz + data: Binary>, + ) -> Result<()> { + auth.check_subdomain(&subdomain, Access::Upload)?; + let mut version_data = version_data.0; version_data.remove("fallback"); version_data.remove("spa"); - let id = talon.db.insert_version( - subdomain, + let version = talon.db.insert_version( + &subdomain, &db::model::Version { data: version_data, - fallback, - spa, + fallback: fallback.0.clone(), + spa: spa.0, ..Default::default() }, )?; - Ok(id) - } - /// Set the given version as the most recent one - fn finalize_version( - talon: &Talon, - subdomain: &str, - version: u32, - fallback: Option<&str>, - ) -> Result<()> { + if data.starts_with(&hex!("1f8b")) { + talon + .storage + .insert_tgz_archive(data.as_slice(), &subdomain, version)?; + } else if data.starts_with(&hex!("504b0304")) { + talon + .storage + .insert_zip_archive(Cursor::new(data.as_slice()), &subdomain, version)?; + } else { + return Err(ApiError::InvalidArchiveType.into()); + } + // Validata fallback path - if let Some(fallback) = fallback { + if let Some(fallback) = &fallback.0 { if let Err(e) = talon .storage - .get_file(subdomain, version, fallback, &Default::default()) + .get_file(&subdomain, version, fallback, &Default::default()) { // Remove the bad version - let _ = talon.db.delete_version(subdomain, version, false); + let _ = talon.db.delete_version(&subdomain, version, false); return Err(ApiError::InvalidFallback(e.to_string()).into()); } } talon.db.update_website( - subdomain, + &subdomain, db::model::WebsiteUpdate { latest_version: Some(Some(version)), ..Default::default() @@ -311,65 +351,25 @@ impl TalonApi { Ok(()) } - /// Upload a new version (.zip archive) - #[oai(path = "/website/:subdomain/uploadZip", method = "post")] - async fn version_upload_zip( + /// Retrieve a file + #[oai(path = "/file/:hash", method = "get")] + async fn get_file( &self, - auth: ApiKeyAuthorization, talon: Data<&Talon>, - subdomain: Path, - /// Fallback page - /// - /// The fallback page gets returned when the requested page does not exist - fallback: Query>, - /// SPA mode (return fallback page with OK status) - #[oai(default)] - spa: Query, - /// Associated version data - /// - /// This is an arbitrary string map that can hold build information and other stuff - /// and will be displayed in the site info dialog. - version_data: DynParams, - /// zip archive with the website files - data: Binary>, - ) -> Result<()> { - auth.check_subdomain(&subdomain, Access::Upload)?; - let version = - Self::insert_version(&talon, &subdomain, fallback.clone(), spa.0, version_data.0)?; - talon + request: &Request, + hash: Path, + ) -> Result { + let hash = hex::decode(hash.as_bytes()).map_err(|_| poem::http::StatusCode::BAD_REQUEST)?; + let gf = talon.storage.get_file_from_hash( + &hash, + Some(mime_guess::mime::APPLICATION_OCTET_STREAM), + None, + request.headers(), + )?; + let resp = talon .storage - .insert_zip_archive(Cursor::new(data.as_slice()), &subdomain, version)?; - Self::finalize_version(&talon, &subdomain, version, fallback.as_deref()) - } - - /// Upload a new version (.tar.gz archive) - #[oai(path = "/website/:subdomain/uploadTgz", method = "post")] - async fn version_upload_tgz( - &self, - auth: ApiKeyAuthorization, - talon: Data<&Talon>, - subdomain: Path, - /// Fallback page - /// - /// The fallback page gets returned when the requested page does not exist - fallback: Query>, - /// SPA mode (return fallback page with OK status) - #[oai(default)] - spa: Query, - /// Associated version data - /// - /// This is an arbitrary string map that can hold build information and other stuff - /// and will be displayed in the site info dialog. - version_data: DynParams, - /// tar.gz archive with the website files - data: Binary>, - ) -> Result<()> { - auth.check_subdomain(&subdomain, Access::Upload)?; - let version = - Self::insert_version(&talon, &subdomain, fallback.clone(), spa.0, version_data.0)?; - talon - .storage - .insert_tgz_archive(data.as_slice(), &subdomain, version)?; - Self::finalize_version(&talon, &subdomain, version, fallback.as_deref()) + .file_to_response(gf, request.headers(), true) + .await?; + Ok(FileResponse(resp)) } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 80df335..4463d45 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -313,7 +313,7 @@ impl Db { } Err(_) => None, }, - None => todo!(), + None => None, })? .and_then(|data| rmp_serde::from_slice::(&data).ok()); diff --git a/src/model.rs b/src/model.rs index 00d4c76..dc17870 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; +use hex::ToHex; use poem_openapi::{Enum, Object}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; @@ -29,14 +30,16 @@ pub struct Website { } /// Create a new website -#[derive(Debug, Clone, Object)] +#[derive(Debug, Clone, Object, Serialize, Deserialize)] pub struct WebsiteNew { /// Website name pub name: String, /// Color of the page icon pub color: Option, /// Visibility of the page in the sidebar menu - pub visibility: Option, + #[serde(default)] + #[oai(default)] + pub visibility: Visibility, /// Link to the source of the page pub source_url: Option, /// Icon for the source link @@ -46,7 +49,7 @@ pub struct WebsiteNew { /// Update a website with the contained values /// /// Values set to `None` remain unchanged. -#[derive(Debug, Clone, Object)] +#[derive(Debug, Clone, Object, Serialize, Deserialize)] pub struct WebsiteUpdate { /// Website name pub name: Option, @@ -75,6 +78,17 @@ pub struct Version { pub data: BTreeMap, } +/// Website file +#[derive(Debug, Clone, Object, Serialize, Deserialize)] +pub struct VersionFile { + /// File path + pub path: String, + /// File hash + pub hash: String, + /// MIME file type + pub mime: Option, +} + #[derive( Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Enum, Serialize, Deserialize, )] @@ -120,7 +134,7 @@ impl From for db::model::Website { Self { name: value.name, color: value.color, - visibility: value.visibility.unwrap_or_default(), + visibility: value.visibility, source_url: value.source_url, source_icon: value.source_icon, ..Default::default() @@ -151,3 +165,15 @@ impl From<(u32, db::model::Version)> for Version { } } } + +impl From<(String, Vec)> for VersionFile { + fn from(value: (String, Vec)) -> Self { + Self { + mime: mime_guess::from_path(&value.0) + .first() + .map(|m| m.essence_str().to_owned()), + path: value.0, + hash: value.1.encode_hex(), + } + } +} diff --git a/src/oai.rs b/src/oai.rs index c637c9d..64a79da 100644 --- a/src/oai.rs +++ b/src/oai.rs @@ -1,11 +1,16 @@ use std::collections::BTreeMap; -use poem::{Request, RequestBody, Result}; +use poem::{IntoResponse, Request, RequestBody, Response, Result}; use poem_openapi::{ ApiExtractor, ApiExtractorType, ExtractParamOptions, __private::UrlQuery, - registry::{MetaParamIn, MetaSchemaRef, Registry}, + payload::Payload, + registry::{ + MetaHeader, MetaMediaType, MetaParamIn, MetaResponse, MetaResponses, MetaSchemaRef, + Registry, + }, types::Type, + ApiResponse, }; pub struct DynParams(pub BTreeMap); @@ -51,3 +56,44 @@ impl<'a> ApiExtractor<'a> for DynParams { )) } } + +pub struct FileResponse(pub Response); + +impl IntoResponse for FileResponse { + fn into_response(self) -> Response { + self.0 + } +} + +impl ApiResponse for FileResponse { + fn meta() -> MetaResponses { + MetaResponses { + responses: vec![MetaResponse { + description: "File content", + status: Some(200), + content: vec![MetaMediaType { + content_type: "application/octet-stream", + schema: poem_openapi::payload::Binary::<()>::schema_ref(), + }], + headers: vec![ + MetaHeader { + name: "etag".to_owned(), + description: Some("File hash".to_owned()), + required: true, + deprecated: false, + schema: String::schema_ref(), + }, + MetaHeader { + name: "last-modified".to_owned(), + description: Some("Date when the file was last modified".to_owned()), + required: true, + deprecated: false, + schema: String::schema_ref(), + }, + ], + }], + } + } + + fn register(_registry: &mut Registry) {} +} diff --git a/src/page.rs b/src/page.rs index 008fe1d..8bf2243 100644 --- a/src/page.rs +++ b/src/page.rs @@ -6,7 +6,7 @@ use poem::{ IntoResponse, Request, Response, Result, }; -use crate::{storage::StorageError, Talon}; +use crate::{storage::StorageError, util, Talon}; #[derive(thiserror::Error, Debug)] pub enum PageError { @@ -27,15 +27,17 @@ pub async fn page(request: &Request, talon: Data<&Talon>) -> Result { let host = request .header(header::HOST) .ok_or(PageError::InvalidSubdomain)?; - let subdomain = if host == talon.cfg.server.root_domain { - "-" - } else { - host.strip_suffix(&format!(".{}", talon.cfg.server.root_domain)) - .ok_or(PageError::InvalidSubdomain)? + let (subdomain, vid) = + util::parse_host(host, &talon.cfg.server.root_domain).ok_or(PageError::InvalidSubdomain)?; + + let vid = match vid { + Some(vid) => vid, + None => { + let ws = talon.db.get_website(subdomain)?; + ws.latest_version.ok_or(PageError::NoVersion)? + } }; - let ws = talon.db.get_website(subdomain)?; - let vid = ws.latest_version.ok_or(PageError::NoVersion)?; let (file, ok) = match talon .storage diff --git a/src/storage.rs b/src/storage.rs index cab461e..3c972e1 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -75,10 +75,12 @@ pub enum StorageError { InvalidFile(PathBuf), #[error("zip archive error: {0}")] Zip(#[from] zip::result::ZipError), + #[error("tar.gz archive error: {0}")] + Tgz(String), #[error("page `{0}` not found")] NotFound(String), - #[error("file `{0}` of page `{1}` missing from storage")] - MissingFile(String, String), + #[error("file `{0}` missing from storage")] + MissingFile(String), } impl ResponseError for StorageError { @@ -86,7 +88,10 @@ impl ResponseError for StorageError { match self { StorageError::Db(e) => e.status(), StorageError::NotFound(_) => StatusCode::NOT_FOUND, - _ => StatusCode::INTERNAL_SERVER_ERROR, + StorageError::InvalidFile(_) | StorageError::Zip(_) | StorageError::Tgz(_) => { + StatusCode::BAD_REQUEST + } + StorageError::Io(_) | StorageError::MissingFile(_) => StatusCode::INTERNAL_SERVER_ERROR, } } } @@ -242,7 +247,9 @@ impl Storage { let temp = TempDir::with_prefix(TMPDIR_PREFIX)?; let decoder = GzDecoder::new(reader); let mut archive = tar::Archive::new(decoder); - archive.unpack(temp.path())?; + archive + .unpack(temp.path()) + .map_err(|e| StorageError::Tgz(e.to_string()))?; let import_path = Self::fix_archive_path(temp.path())?; self.insert_dir(import_path, subdomain, version) } @@ -357,8 +364,18 @@ impl Storage { let mime = util::site_path_mime(&new_path); + self.get_file_from_hash(&hash, mime, rd_path, headers) + } + + pub fn get_file_from_hash( + &self, + hash: &[u8], + mime: Option, + rd_path: Option, + headers: &HeaderMap, + ) -> Result { let algorithms = self.file_compressions( - &hash, + hash, mime.as_ref() .map(|m| Self::is_compressible(m.essence_str())) .unwrap_or_default(), @@ -368,15 +385,12 @@ impl Storage { match alg { Some(alg) => Ok(GotFile { hash: hash.encode_hex(), - file_path: self.file_path_compressed(&hash, alg), + file_path: self.file_path_compressed(hash, alg), encoding: alg.encoding(), mime, rd_path, }), - None => Err(StorageError::MissingFile( - hash.encode_hex(), - new_path.into(), - )), + None => Err(StorageError::MissingFile(hash.encode_hex())), } } @@ -430,6 +444,7 @@ impl Storage { } } + // HTML files are not precompressed and need to have UI code injected if gf .mime .as_ref() diff --git a/src/util.rs b/src/util.rs index 779544e..7e2a7c8 100644 --- a/src/util.rs +++ b/src/util.rs @@ -97,9 +97,11 @@ pub fn parse_accept_encoding( /// Subdomains may only contain letters a-z, numbers 0-9 and dashes. /// They must not start or end with dashes. /// +/// Forbidden subdomains: `xn` (punycode), `x` (reserved placeholder) +/// /// Special case: the root domain is described by subdomain `-` pub fn validate_subdomain(subdomain: &str) -> bool { - if subdomain.is_empty() || subdomain.len() > 200 { + if subdomain.is_empty() || subdomain.len() > 200 || subdomain == "xn" || subdomain == "x" { return false; } @@ -116,6 +118,34 @@ pub fn validate_subdomain(subdomain: &str) -> bool { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } +/// Parse the given hostname +/// +/// Returns the page subdomain and optional version ID +/// +/// # Examples +/// `example.com` (root domain) +/// +/// `x--v1.example.com` (root domain + version id) +/// +/// `talon.example.com` (subdomain) +/// +/// `talon--v1.example.com` (subdomain + version id) +pub fn parse_host<'a>(host: &'a str, root_domain: &str) -> Option<(&'a str, Option)> { + if host == root_domain { + Some(("-", None)) + } else { + let subdomain = host.strip_suffix(&format!(".{}", root_domain))?; + + if let Some((subdomain, vstr)) = subdomain.split_once("--") { + let version = vstr.strip_prefix('v')?.parse().ok()?; + let subdomain = if subdomain == "x" { "-" } else { subdomain }; + Some((subdomain, Some(version))) + } else { + Some((subdomain, None)) + } + } +} + pub fn create_dir_ne>(path: P) -> Result<(), std::io::Error> { let path = path.as_ref(); if !path.is_dir() { diff --git a/tests/fixtures/mod.rs b/tests/fixtures/mod.rs index 38be02f..60ceffe 100644 --- a/tests/fixtures/mod.rs +++ b/tests/fixtures/mod.rs @@ -43,6 +43,10 @@ pub const HASH_SPA_INDEX: [u8; 32] = pub const HASH_SPA_FALLBACK: [u8; 32] = hex!("4ee0d3f7522f620a2a69b39b7443f8fe65029e1324cefaf797b8cad2b223cf7b"); +pub const API_KEY_ROOT: &str = "c32ff286c8ac1c3102625badf38ffd251ae0c4a56079d8ba490f320af63f1f47"; +pub const API_KEY_2: &str = "21bdac19ffd22870d561b1d55b35eddd9029497107edb7b926aa3e7856bb409b"; +// pub const API_KEY_3: &str = "04e99561e3824f387a217d141d2a3b46375de6864afbedf9c9a2cc102bc946a4"; + pub struct DbTest { db: Db, _temp: TempDir, @@ -261,6 +265,11 @@ impl Deref for TalonTest { #[fixture] pub fn tln() -> TalonTest { let temp = temp_testdir::TempDir::default(); + std::fs::copy( + path!("tests" / "testfiles" / "config" / "config_test.toml"), + path!(temp / "config.toml"), + ) + .unwrap(); let talon = Talon::new(&temp).unwrap(); insert_websites(&talon.db); diff --git a/tests/snapshots/tests__api__version_files.snap b/tests/snapshots/tests__api__version_files.snap new file mode 100644 index 0000000..51fe659 --- /dev/null +++ b/tests/snapshots/tests__api__version_files.snap @@ -0,0 +1,41 @@ +--- +source: tests/tests.rs +expression: files +--- +[ + VersionFile( + path: "assets/example.txt", + hash: "bae6bdae8097c24f9a99028e04bfc8d5e0a0c318955316db0e7b955def9c1dbb", + mime: Some("text/plain"), + ), + VersionFile( + path: "assets/image.jpg", + hash: "901d291a47a8a9b55c06f84e5e5f82fd2dcee65cac1406d6e878b805d45c1e93", + mime: Some("image/jpeg"), + ), + VersionFile( + path: "assets/style.css", + hash: "356f131c825fbf604797c7e9c85352549d81db8af91fee834016d075110af026", + mime: Some("text/css"), + ), + VersionFile( + path: "assets/test.js", + hash: "b6ed35f5ae339a35a8babb11a91ff90c1a62ef250d30fa98e59500e8dbb896fa", + mime: Some("application/javascript"), + ), + VersionFile( + path: "assets/thetadev-blue.svg", + hash: "9c37a2cb1230a9cbe7911d34404d4fb03b27552e56b2173683cf9fc52be7bc99", + mime: Some("image/svg+xml"), + ), + VersionFile( + path: "index.html", + hash: "a44816e6c3b650bdf88e6532659ba07ef187c2113ae311da9709e056aec8eadb", + mime: Some("text/html"), + ), + VersionFile( + path: "logo.png", + hash: "9f7e7971b4bfdb75429e534dea461ed90340886925078cda252cada9aa0e25f7", + mime: Some("image/png"), + ), +] diff --git a/tests/snapshots/tests__api__website_versions.snap b/tests/snapshots/tests__api__website_versions.snap new file mode 100644 index 0000000..cfc49b7 --- /dev/null +++ b/tests/snapshots/tests__api__website_versions.snap @@ -0,0 +1,22 @@ +--- +source: tests/tests.rs +expression: versions +--- +[ + Version( + id: 1, + created_at: "2023-02-18T16:30:00Z", + data: { + "Deployed by": "https://github.com/Theta-Dev/Talon/actions/runs/1352014628", + "Version": "v0.1.0", + }, + ), + Version( + id: 2, + created_at: "2023-02-18T16:52:00Z", + data: { + "Deployed by": "https://github.com/Theta-Dev/Talon/actions/runs/1354755231", + "Version": "v0.1.1", + }, + ), +] diff --git a/tests/snapshots/tests__api__websites_get.snap b/tests/snapshots/tests__api__websites_get.snap new file mode 100644 index 0000000..72b052d --- /dev/null +++ b/tests/snapshots/tests__api__websites_get.snap @@ -0,0 +1,36 @@ +--- +source: tests/tests.rs +expression: websites +--- +[ + Website( + subdomain: "-", + name: "ThetaDev", + created_at: "2023-02-18T16:30:00Z", + latest_version: Some(2), + color: Some(2068974), + visibility: featured, + source_url: None, + source_icon: None, + ), + Website( + subdomain: "rustypipe", + name: "RustyPipe", + created_at: "2023-02-20T18:30:00Z", + latest_version: Some(1), + color: Some(7943647), + visibility: featured, + source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"), + source_icon: Some(gitea), + ), + Website( + subdomain: "spotify-gender-ex", + name: "Spotify-Gender-Ex", + created_at: "2023-02-18T16:30:00Z", + latest_version: Some(1), + color: Some(1947988), + visibility: featured, + source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"), + source_icon: Some(github), + ), +] diff --git a/tests/snapshots/tests__api__websites_get_all.snap b/tests/snapshots/tests__api__websites_get_all.snap new file mode 100644 index 0000000..e79c55a --- /dev/null +++ b/tests/snapshots/tests__api__websites_get_all.snap @@ -0,0 +1,46 @@ +--- +source: tests/tests.rs +expression: websites +--- +[ + Website( + subdomain: "-", + name: "ThetaDev", + created_at: "2023-02-18T16:30:00Z", + latest_version: Some(2), + color: Some(2068974), + visibility: featured, + source_url: None, + source_icon: None, + ), + Website( + subdomain: "rustypipe", + name: "RustyPipe", + created_at: "2023-02-20T18:30:00Z", + latest_version: Some(1), + color: Some(7943647), + visibility: featured, + source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"), + source_icon: Some(gitea), + ), + Website( + subdomain: "spa", + name: "SvelteKit SPA", + created_at: "2023-03-03T22:00:00Z", + latest_version: Some(1), + color: Some(16727552), + visibility: hidden, + source_url: None, + source_icon: None, + ), + Website( + subdomain: "spotify-gender-ex", + name: "Spotify-Gender-Ex", + created_at: "2023-02-18T16:30:00Z", + latest_version: Some(1), + color: Some(1947988), + visibility: featured, + source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"), + source_icon: Some(github), + ), +] diff --git a/tests/testfiles/config/config_test.toml b/tests/testfiles/config/config_test.toml new file mode 100644 index 0000000..92c8925 --- /dev/null +++ b/tests/testfiles/config/config_test.toml @@ -0,0 +1,18 @@ +# Config file for running tests + +[keys.c32ff286c8ac1c3102625badf38ffd251ae0c4a56079d8ba490f320af63f1f47] +domains = "*" +upload = true +modify = true + +[keys.21bdac19ffd22870d561b1d55b35eddd9029497107edb7b926aa3e7856bb409b] +domains = ["spotify-gender-ex", "rustypipe", "test"] +upload = true + +[keys.04e99561e3824f387a217d141d2a3b46375de6864afbedf9c9a2cc102bc946a4] +domains = "/^talon-\\d+/" +upload = true +modify = true + +[keys.48691ad9f42bb12e61e259b5e90dc941a293cfae11af18c9e6557f92557f0086] +domains = "*" diff --git a/tests/tests.rs b/tests/tests.rs index 6d4f204..9800e3d 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -587,6 +587,7 @@ mod page { #[case::rustypipe2("rustypipe", "/page2/index.html", &HASH_3_1_PAGE2, "text/html")] #[case::spa_index("spa", "/", &HASH_SPA_INDEX, "text/html")] #[case::spa_fallback("spa", "/user/2", &HASH_SPA_FALLBACK, "text/html")] + #[case::version("x--v1", "/", &HASH_1_1_INDEX, "text/html")] fn page( tln: TalonTest, #[case] subdomain: &str, @@ -684,3 +685,495 @@ mod page { } } } + +mod api { + use hex::ToHex; + use hex_literal::hex; + use poem::{ + http::{header, Method, StatusCode}, + test::TestClient, + }; + use talon::model::*; + use time::macros::datetime; + + use super::*; + + #[rstest] + fn website_get(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .get("http://talon.localhost:3000/api/website/spotify-gender-ex") + .header(header::HOST, "talon.localhost:3000") + .data(tln.clone()) + .send(), + ); + resp.assert_status_is_ok(); + tokio_test::block_on(resp.assert_json(Website { + subdomain: "spotify-gender-ex".to_owned(), + name: "Spotify-Gender-Ex".to_owned(), + created_at: datetime!(2023-02-18 16:30 +0), + latest_version: Some(1), + color: Some(1947988), + visibility: Visibility::Featured, + source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex".to_owned()), + source_icon: Some(SourceIcon::Github), + })); + } + + #[rstest] + fn website_get_404(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .get("http://talon.localhost:3000/api/website/foo") + .header(header::HOST, "talon.localhost:3000") + .data(tln.clone()) + .send(), + ); + resp.assert_status(StatusCode::NOT_FOUND); + } + + #[rstest] + fn website_create(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .put("http://talon.localhost:3000/api/website/test") + .header(header::HOST, "talon.localhost:3000") + .header("x-api-key", API_KEY_ROOT) + .data(tln.clone()) + .body_json(&WebsiteNew { + name: "Test".to_owned(), + color: Some(1000), + visibility: Visibility::Searchable, + source_icon: Some(SourceIcon::Git), + source_url: Some("example.com".to_owned()), + }) + .send(), + ); + resp.assert_status_is_ok(); + + let ws = tln.db.get_website("test").unwrap(); + insta::assert_ron_snapshot!(ws, {".created_at" => "[date]"}, @r###" + Website( + name: "Test", + created_at: "[date]", + latest_version: None, + color: Some(1000), + visibility: searchable, + source_url: Some("example.com"), + source_icon: Some(git), + vid_count: 0, + ) + "###); + } + + #[rstest] + fn website_create_conflict(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .put("http://talon.localhost:3000/api/website/-") + .header(header::HOST, "talon.localhost:3000") + .header("x-api-key", API_KEY_ROOT) + .data(tln.clone()) + .body_json(&WebsiteNew { + name: "Test".to_owned(), + color: Some(1000), + visibility: Visibility::Searchable, + source_icon: Some(SourceIcon::Git), + source_url: Some("example.com".to_owned()), + }) + .send(), + ); + resp.assert_status(StatusCode::CONFLICT); + } + + #[rstest] + fn website_update(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .patch("http://talon.localhost:3000/api/website/-") + .header(header::HOST, "talon.localhost:3000") + .header("x-api-key", API_KEY_ROOT) + .data(tln.clone()) + .body_json(&WebsiteUpdate { + name: Some("Test".to_owned()), + color: Some(Some(1000)), + visibility: Some(Visibility::Searchable), + source_icon: Some(Some(SourceIcon::Git)), + source_url: Some(Some("example.com".to_owned())), + }) + .send(), + ); + resp.assert_status_is_ok(); + + let ws = tln.db.get_website("-").unwrap(); + insta::assert_ron_snapshot!(ws, @r###" + Website( + name: "Test", + created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0), + latest_version: Some(2), + color: Some(1000), + visibility: searchable, + source_url: Some("example.com"), + source_icon: Some(git), + vid_count: 2, + ) + "###); + } + + #[rstest] + fn website_update_404(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .patch("http://talon.localhost:3000/api/website/foo") + .header(header::HOST, "talon.localhost:3000") + .header("x-api-key", API_KEY_ROOT) + .data(tln.clone()) + .body_json(&WebsiteUpdate { + name: Some("Test".to_owned()), + color: Some(Some(1000)), + visibility: Some(Visibility::Searchable), + source_icon: Some(Some(SourceIcon::Git)), + source_url: Some(Some("example.com".to_owned())), + }) + .send(), + ); + resp.assert_status(StatusCode::NOT_FOUND); + } + + #[rstest] + fn website_delete(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .delete("http://talon.localhost:3000/api/website/-") + .header(header::HOST, "talon.localhost:3000") + .header("x-api-key", API_KEY_ROOT) + .data(tln.clone()) + .send(), + ); + resp.assert_status_is_ok(); + + let err = tln.db.get_website("-").unwrap_err(); + assert!(matches!(err, DbError::NotExists(_, _))); + } + + #[rstest] + fn website_delete_404(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .delete("http://talon.localhost:3000/api/website/foo") + .header(header::HOST, "talon.localhost:3000") + .header("x-api-key", API_KEY_ROOT) + .data(tln.clone()) + .send(), + ); + resp.assert_status(StatusCode::NOT_FOUND); + } + + #[rstest] + fn websites_get(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .get("http://talon.localhost:3000/api/websites") + .header(header::HOST, "talon.localhost:3000") + .data(tln.clone()) + .send(), + ); + resp.assert_status(StatusCode::OK); + let websites = + tokio_test::block_on(resp.0.into_body().into_json::>()).unwrap(); + insta::assert_ron_snapshot!(websites); + } + + #[rstest] + fn websites_get_all(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .get("http://talon.localhost:3000/api/websitesAll") + .header(header::HOST, "talon.localhost:3000") + .header("x-api-key", API_KEY_ROOT) + .data(tln.clone()) + .send(), + ); + resp.assert_status(StatusCode::OK); + let websites = + tokio_test::block_on(resp.0.into_body().into_json::>()).unwrap(); + insta::assert_ron_snapshot!(websites); + } + + /// `websitesAll` should only return hidden websites if the user can access them + #[rstest] + fn websites_get_all_noperm(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .get("http://talon.localhost:3000/api/websitesAll") + .header(header::HOST, "talon.localhost:3000") + .header("x-api-key", API_KEY_2) + .data(tln.clone()) + .send(), + ); + resp.assert_status(StatusCode::OK); + let websites = + tokio_test::block_on(resp.0.into_body().into_json::>()).unwrap(); + insta::assert_ron_snapshot!("websites_get", websites); + } + + #[rstest] + fn website_versions(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .get("http://talon.localhost:3000/api/website/-/versions") + .header(header::HOST, "talon.localhost:3000") + .data(tln.clone()) + .send(), + ); + resp.assert_status(StatusCode::OK); + let versions = + tokio_test::block_on(resp.0.into_body().into_json::>()).unwrap(); + insta::assert_ron_snapshot!(versions); + } + + #[rstest] + fn website_versions_404(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .get("http://talon.localhost:3000/api/website/foo/versions") + .header(header::HOST, "talon.localhost:3000") + .data(tln.clone()) + .send(), + ); + resp.assert_status(StatusCode::NOT_FOUND); + } + + #[rstest] + fn version_files(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .get("http://talon.localhost:3000/api/website/-/version/2/files") + .header(header::HOST, "talon.localhost:3000") + .data(tln.clone()) + .send(), + ); + resp.assert_status(StatusCode::OK); + let files = + tokio_test::block_on(resp.0.into_body().into_json::>()).unwrap(); + insta::assert_ron_snapshot!(files); + } + + #[rstest] + fn version_files_404(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .get("http://talon.localhost:3000/api/website/-/version/3/files") + .header(header::HOST, "talon.localhost:3000") + .data(tln.clone()) + .send(), + ); + resp.assert_status(StatusCode::NOT_FOUND); + } + + #[rstest] + fn version_delete(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .delete("http://talon.localhost:3000/api/website/-/version/2") + .header(header::HOST, "talon.localhost:3000") + .header("x-api-key", API_KEY_ROOT) + .data(tln.clone()) + .send(), + ); + resp.assert_status_is_ok(); + + let err = tln.db.get_version("-", 2).unwrap_err(); + assert!(matches!(err, DbError::NotExists(_, _))); + + let ws = tln.db.get_website("-").unwrap(); + assert_eq!(ws.latest_version, Some(1)); + + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .delete("http://talon.localhost:3000/api/website/-/version/1") + .header(header::HOST, "talon.localhost:3000") + .header("x-api-key", API_KEY_ROOT) + .data(tln.clone()) + .send(), + ); + resp.assert_status_is_ok(); + + let err = tln.db.get_version("-", 1).unwrap_err(); + assert!(matches!(err, DbError::NotExists(_, _))); + + let ws = tln.db.get_website("-").unwrap(); + assert_eq!(ws.latest_version, None); + } + + #[rstest] + fn version_delete_404(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .delete("http://talon.localhost:3000/api/website/-/version/3") + .header(header::HOST, "talon.localhost:3000") + .header("x-api-key", API_KEY_ROOT) + .data(tln.clone()) + .send(), + ); + resp.assert_status(StatusCode::NOT_FOUND); + } + + #[rstest] + fn version_upload_zip(tln: TalonTest) { + let path = path!("tests" / "testfiles" / "archive" / "ThetaDev1.zip"); + let archive = std::fs::read(path).unwrap(); + + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .post("http://talon.localhost:3000/api/website/rustypipe/upload?version=1.2.3&hello=world") + .header(header::HOST, "talon.localhost:3000") + .header(header::CONTENT_TYPE, "application/octet-stream") + .header("x-api-key", API_KEY_ROOT) + .data(tln.clone()) + .body(archive) + .send(), + ); + resp.assert_status_is_ok(); + + let ws = tln.db.get_website("rustypipe").unwrap(); + assert_eq!(ws.latest_version, Some(2)); + + let version = tln.db.get_version("rustypipe", 2).unwrap(); + insta::assert_ron_snapshot!(version, {".created_at" => "[date]"}, @r###" + Version( + created_at: "[date]", + data: { + "hello": "world", + "version": "1.2.3", + }, + fallback: None, + spa: false, + ) + "###); + + let files = tln + .db + .get_version_files("rustypipe", 2) + .collect::, _>>() + .unwrap(); + assert_eq!(files.len(), 7); + } + + #[rstest] + fn version_upload_tgz(tln: TalonTest) { + let path = path!("tests" / "testfiles" / "archive" / "spa.tar.gz"); + let archive = std::fs::read(path).unwrap(); + + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .post("http://talon.localhost:3000/api/website/rustypipe/upload?spa=true&fallback=200.html&version=1.2.3") + .header(header::HOST, "talon.localhost:3000") + .header(header::CONTENT_TYPE, "application/octet-stream") + .header("x-api-key", API_KEY_ROOT) + .data(tln.clone()) + .body(archive) + .send(), + ); + resp.assert_status_is_ok(); + + let ws = tln.db.get_website("rustypipe").unwrap(); + assert_eq!(ws.latest_version, Some(2)); + + let version = tln.db.get_version("rustypipe", 2).unwrap(); + insta::assert_ron_snapshot!(version, {".created_at" => "[date]"}, @r###" + Version( + created_at: "[date]", + data: { + "version": "1.2.3", + }, + fallback: Some("200.html"), + spa: true, + ) + "###); + + let files = tln + .db + .get_version_files("rustypipe", 2) + .collect::, _>>() + .unwrap(); + assert_eq!(files.len(), 23); + } + + #[rstest] + fn version_upload_fallback_not_found(tln: TalonTest) { + let path = path!("tests" / "testfiles" / "archive" / "ThetaDev1.zip"); + let archive = std::fs::read(path).unwrap(); + + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .post("http://talon.localhost:3000/api/website/rustypipe/upload?spa=true&fallback=foo.html") + .header(header::HOST, "talon.localhost:3000") + .header(header::CONTENT_TYPE, "application/octet-stream") + .header("x-api-key", API_KEY_ROOT) + .data(tln.clone()) + .body(archive) + .send(), + ); + resp.assert_status(StatusCode::BAD_REQUEST); + } + + #[rstest] + #[case::no_archive(&hex!("badeaffe"))] + #[case::bad_zip(&hex!("504b0304badeaffe"))] + #[case::bad_tgz(&hex!("1f8bbadeaffe"))] + fn version_upload_invalid(tln: TalonTest, #[case] data: &[u8]) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .post("http://talon.localhost:3000/api/website/rustypipe/upload?spa=true&fallback=foo.html") + .header(header::HOST, "talon.localhost:3000") + .header(header::CONTENT_TYPE, "application/octet-stream") + .header("x-api-key", API_KEY_ROOT) + .data(tln.clone()) + .body(data.to_vec()) + .send(), + ); + resp.assert_status(StatusCode::BAD_REQUEST); + } + + #[rstest] + fn file(tln: TalonTest) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .get(format!( + "http://talon.localhost:3000/api/file/{}", + HASH_1_1_INDEX.encode_hex::() + )) + .header(header::HOST, "talon.localhost:3000") + .data(tln.clone()) + .send(), + ); + resp.assert_status_is_ok(); + + let expect = + std::fs::read_to_string(path!("tests" / "testfiles" / "ThetaDev0" / "index.html")) + .unwrap(); + tokio_test::block_on(resp.assert_text(expect)); + } + + #[rstest] + #[case::website_create("website/test", Method::PUT)] + #[case::website_update("website/test", Method::PATCH)] + #[case::website_delete("website/test", Method::DELETE)] + #[case::websites_all("websitesAll", Method::GET)] + #[case::version_delete("website/test/version/1", Method::DELETE)] + #[case::version_upload("website/test/upload", Method::POST)] + fn unauthorized(tln: TalonTest, #[case] endpoint: &str, #[case] method: Method) { + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .request( + method, + format!("http://talon.localhost:3000/api/{endpoint}"), + ) + .header(header::HOST, "talon.localhost:3000") + .data(tln.clone()) + .send(), + ); + resp.assert_status(StatusCode::UNAUTHORIZED); + } +}