Compare commits
4 commits
0352989083
...
ae59478dd6
Author | SHA1 | Date | |
---|---|---|---|
ae59478dd6 | |||
956523d515 | |||
165f456a42 | |||
86631422ec |
16 changed files with 954 additions and 118 deletions
52
Cargo.lock
generated
52
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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"] }
|
||||
|
|
180
src/api.rs
180
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<KeyCfg> {
|
||||
let talon = req.data::<Talon>()?;
|
||||
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#"<html>
|
||||
<h1>Talon API</h1>
|
||||
<p><a href="/api/swagger">Rumfingern</a></p>
|
||||
<p><a href="/api/spec">OpenAPI specification</a></p>
|
||||
</html>"#,
|
||||
)
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
version: Path<u32>,
|
||||
) -> Result<Json<Vec<String>>> {
|
||||
) -> Result<Json<Vec<VersionFile>>> {
|
||||
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::<Result<Vec<_>, _>>()
|
||||
.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<String>,
|
||||
spa: bool,
|
||||
mut version_data: BTreeMap<String, String>,
|
||||
) -> Result<u32> {
|
||||
/// Upload a new version
|
||||
#[oai(path = "/website/:subdomain/upload", method = "post")]
|
||||
async fn version_upload(
|
||||
&self,
|
||||
auth: ApiKeyAuthorization,
|
||||
talon: Data<&Talon>,
|
||||
subdomain: Path<String>,
|
||||
/// Fallback page
|
||||
///
|
||||
/// The fallback page gets returned when the requested page does not exist
|
||||
fallback: Query<Option<String>>,
|
||||
/// SPA mode (return fallback page with OK status)
|
||||
#[oai(default)]
|
||||
spa: Query<bool>,
|
||||
/// 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<Vec<u8>>,
|
||||
) -> 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<String>,
|
||||
/// Fallback page
|
||||
///
|
||||
/// The fallback page gets returned when the requested page does not exist
|
||||
fallback: Query<Option<String>>,
|
||||
/// SPA mode (return fallback page with OK status)
|
||||
#[oai(default)]
|
||||
spa: Query<bool>,
|
||||
/// 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<Vec<u8>>,
|
||||
) -> 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<String>,
|
||||
) -> Result<FileResponse> {
|
||||
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<String>,
|
||||
/// Fallback page
|
||||
///
|
||||
/// The fallback page gets returned when the requested page does not exist
|
||||
fallback: Query<Option<String>>,
|
||||
/// SPA mode (return fallback page with OK status)
|
||||
#[oai(default)]
|
||||
spa: Query<bool>,
|
||||
/// 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<Vec<u8>>,
|
||||
) -> 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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -313,7 +313,7 @@ impl Db {
|
|||
}
|
||||
Err(_) => None,
|
||||
},
|
||||
None => todo!(),
|
||||
None => None,
|
||||
})?
|
||||
.and_then(|data| rmp_serde::from_slice::<Website>(&data).ok());
|
||||
|
||||
|
|
34
src/model.rs
34
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<u32>,
|
||||
/// Visibility of the page in the sidebar menu
|
||||
pub visibility: Option<Visibility>,
|
||||
#[serde(default)]
|
||||
#[oai(default)]
|
||||
pub visibility: Visibility,
|
||||
/// Link to the source of the page
|
||||
pub source_url: Option<String>,
|
||||
/// 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<String>,
|
||||
|
@ -75,6 +78,17 @@ pub struct Version {
|
|||
pub data: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Enum, Serialize, Deserialize,
|
||||
)]
|
||||
|
@ -120,7 +134,7 @@ impl From<WebsiteNew> 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<u8>)> for VersionFile {
|
||||
fn from(value: (String, Vec<u8>)) -> 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
50
src/oai.rs
50
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<String, String>);
|
||||
|
@ -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) {}
|
||||
}
|
||||
|
|
18
src/page.rs
18
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<Response> {
|
|||
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
|
||||
|
|
|
@ -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<Mime>,
|
||||
rd_path: Option<String>,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<GotFile> {
|
||||
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()
|
||||
|
|
32
src/util.rs
32
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<u32>)> {
|
||||
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<P: AsRef<Path>>(path: P) -> Result<(), std::io::Error> {
|
||||
let path = path.as_ref();
|
||||
if !path.is_dir() {
|
||||
|
|
9
tests/fixtures/mod.rs
vendored
9
tests/fixtures/mod.rs
vendored
|
@ -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);
|
||||
|
|
41
tests/snapshots/tests__api__version_files.snap
Normal file
41
tests/snapshots/tests__api__version_files.snap
Normal file
|
@ -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"),
|
||||
),
|
||||
]
|
22
tests/snapshots/tests__api__website_versions.snap
Normal file
22
tests/snapshots/tests__api__website_versions.snap
Normal file
|
@ -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",
|
||||
},
|
||||
),
|
||||
]
|
36
tests/snapshots/tests__api__websites_get.snap
Normal file
36
tests/snapshots/tests__api__websites_get.snap
Normal file
|
@ -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),
|
||||
),
|
||||
]
|
46
tests/snapshots/tests__api__websites_get_all.snap
Normal file
46
tests/snapshots/tests__api__websites_get_all.snap
Normal file
|
@ -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),
|
||||
),
|
||||
]
|
18
tests/testfiles/config/config_test.toml
Normal file
18
tests/testfiles/config/config_test.toml
Normal file
|
@ -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 = "*"
|
493
tests/tests.rs
493
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::<Vec<Website>>()).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::<Vec<Website>>()).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::<Vec<Website>>()).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::<Vec<Version>>()).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::<Vec<VersionFile>>()).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::<Result<Vec<_>, _>>()
|
||||
.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::<Result<Vec<_>, _>>()
|
||||
.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::<String>()
|
||||
))
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue