Compare commits
No commits in common. "ae59478dd6ef034a7168be2edcbae8d5fab3f693" and "0352989083b4ad4fbdc7fe991d1da2d6e82b3f9a" have entirely different histories.
ae59478dd6
...
0352989083
16 changed files with 125 additions and 961 deletions
52
Cargo.lock
generated
52
Cargo.lock
generated
|
@ -936,8 +936,6 @@ dependencies = [
|
|||
"console",
|
||||
"lazy_static",
|
||||
"linked-hash-map",
|
||||
"pest",
|
||||
"pest_derive",
|
||||
"ron",
|
||||
"serde",
|
||||
"similar",
|
||||
|
@ -1242,50 +1240,6 @@ 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"
|
||||
|
@ -2160,12 +2114,6 @@ 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,7 +23,6 @@ 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 = [
|
||||
|
@ -50,4 +49,5 @@ 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", "redactions"] }
|
||||
insta = { version = "1.17.1", features = ["ron"] }
|
||||
hex-literal = "0.3.4"
|
||||
|
|
190
src/api.rs
190
src/api.rs
|
@ -1,6 +1,5 @@
|
|||
use std::io::Cursor;
|
||||
use std::{collections::BTreeMap, io::Cursor};
|
||||
|
||||
use hex_literal::hex;
|
||||
use poem::{
|
||||
error::{Error, ResponseError},
|
||||
http::StatusCode,
|
||||
|
@ -10,7 +9,7 @@ use poem::{
|
|||
use poem_openapi::{
|
||||
auth::ApiKey,
|
||||
param::{Path, Query},
|
||||
payload::{Binary, Html, Json},
|
||||
payload::{Binary, Json},
|
||||
OpenApi, SecurityScheme,
|
||||
};
|
||||
|
||||
|
@ -18,7 +17,7 @@ use crate::{
|
|||
config::{Access, KeyCfg},
|
||||
db,
|
||||
model::*,
|
||||
oai::{DynParams, FileResponse},
|
||||
oai::DynParams,
|
||||
util, Talon,
|
||||
};
|
||||
|
||||
|
@ -35,8 +34,7 @@ struct ApiKeyAuthorization(KeyCfg);
|
|||
|
||||
async fn api_key_checker(req: &Request, api_key: ApiKey) -> Option<KeyCfg> {
|
||||
let talon = req.data::<Talon>()?;
|
||||
let x = talon.cfg.keys.get(&api_key.key).cloned();
|
||||
x
|
||||
talon.cfg.keys.get(&api_key.key).cloned()
|
||||
}
|
||||
|
||||
impl ApiKeyAuthorization {
|
||||
|
@ -59,16 +57,12 @@ 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(_)
|
||||
| ApiError::InvalidArchiveType => StatusCode::BAD_REQUEST,
|
||||
ApiError::InvalidSubdomain | ApiError::InvalidFallback(_) => StatusCode::BAD_REQUEST,
|
||||
ApiError::NoAccess => StatusCode::UNAUTHORIZED,
|
||||
}
|
||||
}
|
||||
|
@ -77,19 +71,6 @@ 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(
|
||||
|
@ -106,7 +87,7 @@ impl TalonApi {
|
|||
|
||||
/// Create a new website
|
||||
#[oai(path = "/website/:subdomain", method = "put")]
|
||||
async fn website_create(
|
||||
async fn website_post(
|
||||
&self,
|
||||
auth: ApiKeyAuthorization,
|
||||
talon: Data<&Talon>,
|
||||
|
@ -251,12 +232,12 @@ impl TalonApi {
|
|||
talon: Data<&Talon>,
|
||||
subdomain: Path<String>,
|
||||
version: Path<u32>,
|
||||
) -> Result<Json<Vec<VersionFile>>> {
|
||||
) -> Result<Json<Vec<String>>> {
|
||||
talon.db.version_exists(&subdomain, *version)?;
|
||||
talon
|
||||
.db
|
||||
.get_version_files(&subdomain, *version)
|
||||
.map(|r| r.map(VersionFile::from))
|
||||
.map(|r| r.map(|f| f.0))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(Json)
|
||||
.map_err(Error::from)
|
||||
|
@ -277,9 +258,62 @@ impl TalonApi {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Upload a new version
|
||||
#[oai(path = "/website/:subdomain/upload", method = "post")]
|
||||
async fn version_upload(
|
||||
/// 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> {
|
||||
version_data.remove("fallback");
|
||||
version_data.remove("spa");
|
||||
|
||||
let id = talon.db.insert_version(
|
||||
subdomain,
|
||||
&db::model::Version {
|
||||
data: version_data,
|
||||
fallback,
|
||||
spa,
|
||||
..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<()> {
|
||||
// Validata fallback path
|
||||
if let Some(fallback) = fallback {
|
||||
if let Err(e) =
|
||||
talon
|
||||
.storage
|
||||
.get_file(subdomain, version, fallback, &Default::default())
|
||||
{
|
||||
// Remove the bad version
|
||||
let _ = talon.db.delete_version(subdomain, version, false);
|
||||
return Err(ApiError::InvalidFallback(e.to_string()).into());
|
||||
}
|
||||
}
|
||||
|
||||
talon.db.update_website(
|
||||
subdomain,
|
||||
db::model::WebsiteUpdate {
|
||||
latest_version: Some(Some(version)),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upload a new version (.zip archive)
|
||||
#[oai(path = "/website/:subdomain/uploadZip", method = "post")]
|
||||
async fn version_upload_zip(
|
||||
&self,
|
||||
auth: ApiKeyAuthorization,
|
||||
talon: Data<&Talon>,
|
||||
|
@ -296,80 +330,46 @@ impl TalonApi {
|
|||
/// 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
|
||||
/// zip archive with the website files
|
||||
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 version = talon.db.insert_version(
|
||||
&subdomain,
|
||||
&db::model::Version {
|
||||
data: version_data,
|
||||
fallback: fallback.0.clone(),
|
||||
spa: spa.0,
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
if data.starts_with(&hex!("1f8b")) {
|
||||
talon
|
||||
.storage
|
||||
.insert_tgz_archive(data.as_slice(), &subdomain, version)?;
|
||||
} else if data.starts_with(&hex!("504b0304")) {
|
||||
let version =
|
||||
Self::insert_version(&talon, &subdomain, fallback.clone(), spa.0, version_data.0)?;
|
||||
talon
|
||||
.storage
|
||||
.insert_zip_archive(Cursor::new(data.as_slice()), &subdomain, version)?;
|
||||
} else {
|
||||
return Err(ApiError::InvalidArchiveType.into());
|
||||
Self::finalize_version(&talon, &subdomain, version, fallback.as_deref())
|
||||
}
|
||||
|
||||
// Validata fallback path
|
||||
if let Some(fallback) = &fallback.0 {
|
||||
if let Err(e) =
|
||||
/// 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
|
||||
.get_file(&subdomain, version, fallback, &Default::default())
|
||||
{
|
||||
// Remove the bad version
|
||||
let _ = talon.db.delete_version(&subdomain, version, false);
|
||||
return Err(ApiError::InvalidFallback(e.to_string()).into());
|
||||
}
|
||||
}
|
||||
|
||||
talon.db.update_website(
|
||||
&subdomain,
|
||||
db::model::WebsiteUpdate {
|
||||
latest_version: Some(Some(version)),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve a file
|
||||
#[oai(path = "/file/:hash", method = "get")]
|
||||
async fn get_file(
|
||||
&self,
|
||||
talon: Data<&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
|
||||
.file_to_response(gf, request.headers(), true)
|
||||
.await?;
|
||||
Ok(FileResponse(resp))
|
||||
.insert_tgz_archive(data.as_slice(), &subdomain, version)?;
|
||||
Self::finalize_version(&talon, &subdomain, version, fallback.as_deref())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -313,7 +313,7 @@ impl Db {
|
|||
}
|
||||
Err(_) => None,
|
||||
},
|
||||
None => None,
|
||||
None => todo!(),
|
||||
})?
|
||||
.and_then(|data| rmp_serde::from_slice::<Website>(&data).ok());
|
||||
|
||||
|
|
34
src/model.rs
34
src/model.rs
|
@ -1,6 +1,5 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use hex::ToHex;
|
||||
use poem_openapi::{Enum, Object};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
|
@ -30,16 +29,14 @@ pub struct Website {
|
|||
}
|
||||
|
||||
/// Create a new website
|
||||
#[derive(Debug, Clone, Object, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Object)]
|
||||
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
|
||||
#[serde(default)]
|
||||
#[oai(default)]
|
||||
pub visibility: Visibility,
|
||||
pub visibility: Option<Visibility>,
|
||||
/// Link to the source of the page
|
||||
pub source_url: Option<String>,
|
||||
/// Icon for the source link
|
||||
|
@ -49,7 +46,7 @@ pub struct WebsiteNew {
|
|||
/// Update a website with the contained values
|
||||
///
|
||||
/// Values set to `None` remain unchanged.
|
||||
#[derive(Debug, Clone, Object, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Object)]
|
||||
pub struct WebsiteUpdate {
|
||||
/// Website name
|
||||
pub name: Option<String>,
|
||||
|
@ -78,17 +75,6 @@ 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,
|
||||
)]
|
||||
|
@ -134,7 +120,7 @@ impl From<WebsiteNew> for db::model::Website {
|
|||
Self {
|
||||
name: value.name,
|
||||
color: value.color,
|
||||
visibility: value.visibility,
|
||||
visibility: value.visibility.unwrap_or_default(),
|
||||
source_url: value.source_url,
|
||||
source_icon: value.source_icon,
|
||||
..Default::default()
|
||||
|
@ -165,15 +151,3 @@ 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,16 +1,11 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use poem::{IntoResponse, Request, RequestBody, Response, Result};
|
||||
use poem::{Request, RequestBody, Result};
|
||||
use poem_openapi::{
|
||||
ApiExtractor, ApiExtractorType, ExtractParamOptions,
|
||||
__private::UrlQuery,
|
||||
payload::Payload,
|
||||
registry::{
|
||||
MetaHeader, MetaMediaType, MetaParamIn, MetaResponse, MetaResponses, MetaSchemaRef,
|
||||
Registry,
|
||||
},
|
||||
registry::{MetaParamIn, MetaSchemaRef, Registry},
|
||||
types::Type,
|
||||
ApiResponse,
|
||||
};
|
||||
|
||||
pub struct DynParams(pub BTreeMap<String, String>);
|
||||
|
@ -56,44 +51,3 @@ 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, util, Talon};
|
||||
use crate::{storage::StorageError, Talon};
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum PageError {
|
||||
|
@ -27,17 +27,15 @@ pub async fn page(request: &Request, talon: Data<&Talon>) -> Result<Response> {
|
|||
let host = request
|
||||
.header(header::HOST)
|
||||
.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 subdomain = if host == talon.cfg.server.root_domain {
|
||||
"-"
|
||||
} else {
|
||||
host.strip_suffix(&format!(".{}", talon.cfg.server.root_domain))
|
||||
.ok_or(PageError::InvalidSubdomain)?
|
||||
};
|
||||
|
||||
let ws = talon.db.get_website(subdomain)?;
|
||||
let vid = ws.latest_version.ok_or(PageError::NoVersion)?;
|
||||
let (file, ok) =
|
||||
match talon
|
||||
.storage
|
||||
|
|
|
@ -75,12 +75,10 @@ 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}` missing from storage")]
|
||||
MissingFile(String),
|
||||
#[error("file `{0}` of page `{1}` missing from storage")]
|
||||
MissingFile(String, String),
|
||||
}
|
||||
|
||||
impl ResponseError for StorageError {
|
||||
|
@ -88,10 +86,7 @@ impl ResponseError for StorageError {
|
|||
match self {
|
||||
StorageError::Db(e) => e.status(),
|
||||
StorageError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
StorageError::InvalidFile(_) | StorageError::Zip(_) | StorageError::Tgz(_) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
StorageError::Io(_) | StorageError::MissingFile(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -247,9 +242,7 @@ 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())
|
||||
.map_err(|e| StorageError::Tgz(e.to_string()))?;
|
||||
archive.unpack(temp.path())?;
|
||||
let import_path = Self::fix_archive_path(temp.path())?;
|
||||
self.insert_dir(import_path, subdomain, version)
|
||||
}
|
||||
|
@ -364,18 +357,8 @@ 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(),
|
||||
|
@ -385,12 +368,15 @@ 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())),
|
||||
None => Err(StorageError::MissingFile(
|
||||
hash.encode_hex(),
|
||||
new_path.into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -444,7 +430,6 @@ 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,11 +97,9 @@ 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 || subdomain == "xn" || subdomain == "x" {
|
||||
if subdomain.is_empty() || subdomain.len() > 200 {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -118,34 +116,6 @@ 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,10 +43,6 @@ 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,
|
||||
|
@ -265,11 +261,6 @@ 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);
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
---
|
||||
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"),
|
||||
),
|
||||
]
|
|
@ -1,22 +0,0 @@
|
|||
---
|
||||
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",
|
||||
},
|
||||
),
|
||||
]
|
|
@ -1,36 +0,0 @@
|
|||
---
|
||||
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),
|
||||
),
|
||||
]
|
|
@ -1,46 +0,0 @@
|
|||
---
|
||||
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),
|
||||
),
|
||||
]
|
|
@ -1,18 +0,0 @@
|
|||
# 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,7 +587,6 @@ 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,
|
||||
|
@ -685,495 +684,3 @@ 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