Compare commits
No commits in common. "0b5f369fa07020c3612f8937d50998e6f40c032e" and "09c43870eb7675562a51d00d7a718ee687168aa4" have entirely different histories.
0b5f369fa0
...
09c43870eb
23 changed files with 343 additions and 284 deletions
40
Cargo.lock
generated
40
Cargo.lock
generated
|
@ -462,19 +462,6 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0"
|
||||
dependencies = [
|
||||
"humantime",
|
||||
"is-terminal",
|
||||
"log",
|
||||
"regex",
|
||||
"termcolor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.2.8"
|
||||
|
@ -746,12 +733,6 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
|
@ -819,12 +800,6 @@ version = "1.0.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421"
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "0.14.24"
|
||||
|
@ -932,18 +907,6 @@ dependencies = [
|
|||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21b6b32576413a8e69b90e952e4a026476040d81017b80445deda5f2d3921857"
|
||||
dependencies = [
|
||||
"hermit-abi 0.3.1",
|
||||
"io-lifetimes",
|
||||
"rustix",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.5"
|
||||
|
@ -1125,7 +1088,7 @@ version = "1.15.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b"
|
||||
dependencies = [
|
||||
"hermit-abi 0.2.6",
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
]
|
||||
|
||||
|
@ -1713,7 +1676,6 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"brotli",
|
||||
"compressible",
|
||||
"env_logger",
|
||||
"flate2",
|
||||
"hex",
|
||||
"hex-literal",
|
||||
|
|
|
@ -14,7 +14,7 @@ sled = "0.34.7"
|
|||
serde = "1.0.152"
|
||||
serde_json = "1.0.93"
|
||||
rmp-serde = "1.1.1"
|
||||
toml = "0.7.2"
|
||||
toml = { version = "0.7.2", default-features = false, features = ["parse"] }
|
||||
thiserror = "1.0.38"
|
||||
time = { version = "0.3.15", features = [
|
||||
"macros",
|
||||
|
@ -38,10 +38,10 @@ compressible = "0.2.0"
|
|||
regex = "1.7.1"
|
||||
log = "0.4.17"
|
||||
httpdate = "1.0.2"
|
||||
env_logger = "0.10.0"
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = "0.16.0"
|
||||
temp_testdir = "0.2.3"
|
||||
insta = { version = "1.17.1", features = ["ron"] }
|
||||
hex-literal = "0.3.4"
|
||||
path_macro = "1.0.0"
|
||||
|
|
86
src/api.rs
86
src/api.rs
|
@ -13,7 +13,14 @@ use poem_openapi::{
|
|||
OpenApi, SecurityScheme,
|
||||
};
|
||||
|
||||
use crate::{config::KeyCfg, db, model::*, oai::DynParams, util, Talon};
|
||||
use crate::{
|
||||
config::{Config, KeyCfg},
|
||||
db::{self, Db},
|
||||
model::*,
|
||||
oai::DynParams,
|
||||
storage::Storage,
|
||||
util,
|
||||
};
|
||||
|
||||
pub struct TalonApi;
|
||||
|
||||
|
@ -27,8 +34,8 @@ pub struct TalonApi;
|
|||
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 cfg = req.data::<Config>()?;
|
||||
cfg.keys.get(&api_key.key).cloned()
|
||||
}
|
||||
|
||||
impl ApiKeyAuthorization {
|
||||
|
@ -64,14 +71,8 @@ impl ResponseError for ApiError {
|
|||
impl TalonApi {
|
||||
/// Get a website
|
||||
#[oai(path = "/website/:subdomain", method = "get")]
|
||||
async fn website_get(
|
||||
&self,
|
||||
talon: Data<&Talon>,
|
||||
subdomain: Path<String>,
|
||||
) -> Result<Json<Website>> {
|
||||
talon
|
||||
.db
|
||||
.get_website(&subdomain)
|
||||
async fn website_get(&self, db: Data<&Db>, subdomain: Path<String>) -> Result<Json<Website>> {
|
||||
db.get_website(&subdomain)
|
||||
.map(|w| Json(Website::from((subdomain.0, w))))
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
@ -81,18 +82,19 @@ impl TalonApi {
|
|||
async fn website_post(
|
||||
&self,
|
||||
auth: ApiKeyAuthorization,
|
||||
talon: Data<&Talon>,
|
||||
db: Data<&Db>,
|
||||
cfg: Data<&Config>,
|
||||
subdomain: Path<String>,
|
||||
website: Json<WebsiteNew>,
|
||||
) -> Result<()> {
|
||||
auth.check_subdomain(&subdomain)?;
|
||||
if subdomain.as_str() == talon.cfg.server.internal_subdomain
|
||||
if subdomain.as_str() == cfg.server.internal_subdomain
|
||||
|| !util::validate_subdomain(&subdomain)
|
||||
{
|
||||
return Err(ApiError::InvalidSubdomain.into());
|
||||
}
|
||||
|
||||
talon.db.insert_website(&subdomain, &website.0.into())?;
|
||||
db.insert_website(&subdomain, &website.0.into())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -101,13 +103,13 @@ impl TalonApi {
|
|||
async fn website_update(
|
||||
&self,
|
||||
auth: ApiKeyAuthorization,
|
||||
talon: Data<&Talon>,
|
||||
db: Data<&Db>,
|
||||
subdomain: Path<String>,
|
||||
website: Json<WebsiteUpdate>,
|
||||
) -> Result<()> {
|
||||
auth.check_subdomain(&subdomain)?;
|
||||
|
||||
talon.db.update_website(&subdomain, website.0.into())?;
|
||||
db.update_website(&subdomain, website.0.into())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -116,21 +118,19 @@ impl TalonApi {
|
|||
async fn website_delete(
|
||||
&self,
|
||||
auth: ApiKeyAuthorization,
|
||||
talon: Data<&Talon>,
|
||||
db: Data<&Db>,
|
||||
subdomain: Path<String>,
|
||||
) -> Result<()> {
|
||||
auth.check_subdomain(&subdomain)?;
|
||||
|
||||
talon.db.delete_website(&subdomain, true)?;
|
||||
db.delete_website(&subdomain, true)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all websites
|
||||
#[oai(path = "/websites", method = "get")]
|
||||
async fn websites_get(&self, talon: Data<&Talon>) -> Result<Json<Vec<Website>>> {
|
||||
talon
|
||||
.db
|
||||
.get_websites()
|
||||
async fn websites_get(&self, db: Data<&Db>) -> Result<Json<Vec<Website>>> {
|
||||
db.get_websites()
|
||||
.map(|r| r.map(Website::from))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(Json)
|
||||
|
@ -141,13 +141,11 @@ impl TalonApi {
|
|||
#[oai(path = "/website/:subdomain/versions", method = "get")]
|
||||
async fn website_versions(
|
||||
&self,
|
||||
talon: Data<&Talon>,
|
||||
db: Data<&Db>,
|
||||
subdomain: Path<String>,
|
||||
) -> Result<Json<Vec<Version>>> {
|
||||
talon.db.website_exists(&subdomain)?;
|
||||
talon
|
||||
.db
|
||||
.get_website_versions(&subdomain)
|
||||
db.website_exists(&subdomain)?;
|
||||
db.get_website_versions(&subdomain)
|
||||
.map(|r| r.map(Version::from))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(Json)
|
||||
|
@ -158,13 +156,11 @@ impl TalonApi {
|
|||
#[oai(path = "/website/:subdomain/version/:id", method = "get")]
|
||||
async fn version_get(
|
||||
&self,
|
||||
talon: Data<&Talon>,
|
||||
db: Data<&Db>,
|
||||
subdomain: Path<String>,
|
||||
id: Path<u32>,
|
||||
) -> Result<Json<Version>> {
|
||||
talon
|
||||
.db
|
||||
.get_version(&subdomain, *id)
|
||||
db.get_version(&subdomain, *id)
|
||||
.map(|v| Json(Version::from((*id, v))))
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
@ -174,13 +170,13 @@ impl TalonApi {
|
|||
async fn version_delete(
|
||||
&self,
|
||||
auth: ApiKeyAuthorization,
|
||||
talon: Data<&Talon>,
|
||||
db: Data<&Db>,
|
||||
subdomain: Path<String>,
|
||||
id: Path<u32>,
|
||||
) -> Result<()> {
|
||||
auth.check_subdomain(&subdomain)?;
|
||||
|
||||
talon.db.delete_version(&subdomain, *id, true)?;
|
||||
db.delete_version(&subdomain, *id, true)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -189,7 +185,8 @@ impl TalonApi {
|
|||
async fn version_upload_zip(
|
||||
&self,
|
||||
auth: ApiKeyAuthorization,
|
||||
talon: Data<&Talon>,
|
||||
db: Data<&Db>,
|
||||
storage: Data<&Storage>,
|
||||
subdomain: Path<String>,
|
||||
/// Associated version data
|
||||
///
|
||||
|
@ -200,12 +197,10 @@ impl TalonApi {
|
|||
data: Binary<Vec<u8>>,
|
||||
) -> Result<()> {
|
||||
auth.check_subdomain(&subdomain)?;
|
||||
let vid = talon.db.new_version_id()?;
|
||||
talon
|
||||
.storage
|
||||
.insert_zip_archive(Cursor::new(data.as_slice()), vid)?;
|
||||
let vid = db.new_version_id()?;
|
||||
storage.insert_zip_archive(Cursor::new(data.as_slice()), vid)?;
|
||||
|
||||
talon.db.insert_version(
|
||||
db.insert_version(
|
||||
&subdomain,
|
||||
vid,
|
||||
&db::model::Version {
|
||||
|
@ -213,7 +208,7 @@ impl TalonApi {
|
|||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
talon.db.update_website(
|
||||
db.update_website(
|
||||
&subdomain,
|
||||
db::model::WebsiteUpdate {
|
||||
latest_version: Some(Some(vid)),
|
||||
|
@ -228,7 +223,8 @@ impl TalonApi {
|
|||
async fn version_upload_tgz(
|
||||
&self,
|
||||
auth: ApiKeyAuthorization,
|
||||
talon: Data<&Talon>,
|
||||
db: Data<&Db>,
|
||||
storage: Data<&Storage>,
|
||||
subdomain: Path<String>,
|
||||
/// Associated version data
|
||||
///
|
||||
|
@ -239,10 +235,10 @@ impl TalonApi {
|
|||
data: Binary<Vec<u8>>,
|
||||
) -> Result<()> {
|
||||
auth.check_subdomain(&subdomain)?;
|
||||
let vid = talon.db.new_version_id()?;
|
||||
talon.storage.insert_tgz_archive(data.as_slice(), vid)?;
|
||||
let vid = db.new_version_id()?;
|
||||
storage.insert_tgz_archive(data.as_slice(), vid)?;
|
||||
|
||||
talon.db.insert_version(
|
||||
db.insert_version(
|
||||
&subdomain,
|
||||
vid,
|
||||
&db::model::Version {
|
||||
|
@ -250,7 +246,7 @@ impl TalonApi {
|
|||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
talon.db.update_website(
|
||||
db.update_website(
|
||||
&subdomain,
|
||||
db::model::WebsiteUpdate {
|
||||
latest_version: Some(Some(vid)),
|
||||
|
|
|
@ -57,21 +57,9 @@ impl Config {
|
|||
Self { i: cfg.into() }
|
||||
}
|
||||
|
||||
/// Read the configuration from the given file
|
||||
/// or create a default config at the given location
|
||||
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||
let path = path.as_ref();
|
||||
if path.is_file() {
|
||||
let cfg_str = std::fs::read_to_string(path)?;
|
||||
Ok(toml::from_str::<Config>(&cfg_str)?)
|
||||
} else {
|
||||
let cfg = Self::default();
|
||||
if let Ok(cfg_str) = toml::to_string_pretty(&cfg) {
|
||||
std::fs::write(path, cfg_str)
|
||||
.unwrap_or_else(|e| log::error!("Could not write default config file: {e}"));
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -79,6 +67,7 @@ impl Config {
|
|||
#[serde(default)]
|
||||
pub struct ServerCfg {
|
||||
pub address: String,
|
||||
pub port: u32,
|
||||
pub root_domain: String,
|
||||
pub internal_subdomain: String,
|
||||
pub internal_url: String,
|
||||
|
@ -87,7 +76,8 @@ pub struct ServerCfg {
|
|||
impl Default for ServerCfg {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
address: "0.0.0.0:3000".to_owned(),
|
||||
address: "0.0.0.0".to_owned(),
|
||||
port: 8080,
|
||||
root_domain: "localhost".to_owned(),
|
||||
internal_subdomain: "talon".to_owned(),
|
||||
internal_url: "http://talon.localhost".to_owned(),
|
||||
|
|
|
@ -198,6 +198,7 @@ impl Db {
|
|||
let website = website.clone();
|
||||
w.name = website.name.unwrap_or(w.name);
|
||||
w.latest_version = website.latest_version.unwrap_or(w.latest_version);
|
||||
w.icon = website.icon.unwrap_or(w.icon);
|
||||
w.color = website.color.unwrap_or(w.color);
|
||||
w.visibility = website.visibility.unwrap_or(w.visibility);
|
||||
w.source_url = website.source_url.unwrap_or(w.source_url);
|
||||
|
@ -463,6 +464,12 @@ impl Db {
|
|||
let (_, file) = f?;
|
||||
set.insert(file.to_vec());
|
||||
}
|
||||
for w in self.get_websites() {
|
||||
let (_, website) = w?;
|
||||
if let Some(icon) = website.icon {
|
||||
set.insert(icon);
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,8 @@ pub struct Website {
|
|||
pub created_at: OffsetDateTime,
|
||||
/// Latest version ID
|
||||
pub latest_version: Option<u32>,
|
||||
/// File hash of the page icon
|
||||
pub icon: Option<Vec<u8>>,
|
||||
/// Color of the page icon
|
||||
pub color: Option<u32>,
|
||||
/// Visibility of the page in the sidebar menu
|
||||
|
@ -30,6 +32,7 @@ impl Default for Website {
|
|||
name: Default::default(),
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
latest_version: Default::default(),
|
||||
icon: Default::default(),
|
||||
color: Default::default(),
|
||||
visibility: Default::default(),
|
||||
source_url: Default::default(),
|
||||
|
@ -47,6 +50,8 @@ pub struct WebsiteUpdate {
|
|||
pub name: Option<String>,
|
||||
/// Latest version ID
|
||||
pub latest_version: Option<Option<u32>>,
|
||||
/// Page icon
|
||||
pub icon: Option<Option<Vec<u8>>>,
|
||||
/// Color of the page icon
|
||||
pub color: Option<Option<u32>>,
|
||||
/// Visibility of the page in the sidebar menu
|
||||
|
|
|
@ -2,11 +2,6 @@ pub mod api;
|
|||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod model;
|
||||
pub mod server;
|
||||
pub mod storage;
|
||||
|
||||
mod oai;
|
||||
mod page;
|
||||
pub mod storage;
|
||||
mod util;
|
||||
|
||||
pub use server::{Result, Talon, TalonError};
|
||||
|
|
96
src/main.rs
96
src/main.rs
|
@ -1,7 +1,95 @@
|
|||
use talon::{Result, Talon};
|
||||
use poem::{
|
||||
error::ResponseError,
|
||||
handler,
|
||||
http::{header, StatusCode},
|
||||
listener::TcpListener,
|
||||
web::{Data, Redirect},
|
||||
EndpointExt, IntoResponse, Request, Response, Result, Route, RouteDomain, Server,
|
||||
};
|
||||
use poem_openapi::OpenApiService;
|
||||
use talon::{api::TalonApi, config::Config, db::Db, storage::Storage};
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
enum PageError {
|
||||
#[error("invalid subdomain")]
|
||||
InvalidSubdomain,
|
||||
#[error("website does not have any version")]
|
||||
NoVersion,
|
||||
}
|
||||
|
||||
impl ResponseError for PageError {
|
||||
fn status(&self) -> StatusCode {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
}
|
||||
|
||||
#[handler]
|
||||
fn page(
|
||||
request: &Request,
|
||||
db: Data<&Db>,
|
||||
storage: Data<&Storage>,
|
||||
cfg: Data<&Config>,
|
||||
) -> Result<Response> {
|
||||
let host = request
|
||||
.header(header::HOST)
|
||||
.ok_or(PageError::InvalidSubdomain)?;
|
||||
let subdomain = if host == cfg.server.root_domain {
|
||||
"-"
|
||||
} else {
|
||||
host.strip_suffix(&format!(".{}", cfg.server.root_domain))
|
||||
.ok_or(PageError::InvalidSubdomain)?
|
||||
};
|
||||
|
||||
let ws = db.get_website(subdomain)?;
|
||||
let version = ws.latest_version.ok_or(PageError::NoVersion)?;
|
||||
let file = storage.get_file(version, request.original_uri().path(), request.headers())?;
|
||||
|
||||
Ok(match file.rd_path {
|
||||
Some(rd_path) => Redirect::moved_permanent(rd_path).into_response(),
|
||||
None => file.to_response(request.headers())?.into_response(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let talon = Talon::new("tmp")?;
|
||||
talon.launch().await
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let db = Db::new("tmp/db")?;
|
||||
let cfg = Config::from_file("tmp/config.toml")?;
|
||||
let storage = Storage::new("tmp/storage", db.clone(), cfg.clone());
|
||||
|
||||
let api_service = OpenApiService::new(TalonApi, "Talon", "0.1.0")
|
||||
.server(format!("{}/api", cfg.server.internal_url));
|
||||
let swagger_ui = api_service.swagger_ui();
|
||||
let spec = api_service.spec();
|
||||
|
||||
let route_internal = Route::new()
|
||||
.at(
|
||||
"/",
|
||||
poem::endpoint::make_sync(|_| "Hello World, I am Talon"),
|
||||
)
|
||||
.nest("/api", api_service)
|
||||
.nest("/api/swagger", swagger_ui)
|
||||
.at(
|
||||
"/api/spec",
|
||||
poem::endpoint::make_sync(move |_| spec.clone()),
|
||||
);
|
||||
|
||||
let internal_domain = format!(
|
||||
"{}.{}",
|
||||
cfg.server.internal_subdomain, cfg.server.root_domain
|
||||
);
|
||||
let site_domains = format!("+.{}", cfg.server.root_domain);
|
||||
println!("internal_domain: {internal_domain}");
|
||||
|
||||
let route = RouteDomain::new()
|
||||
.at(&internal_domain, route_internal)
|
||||
.at(&site_domains, page)
|
||||
.at(&cfg.server.root_domain, page)
|
||||
.data(db)
|
||||
.data(cfg)
|
||||
.data(storage);
|
||||
|
||||
Server::new(TcpListener::bind("0.0.0.0:3000"))
|
||||
.run(route)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -18,6 +18,8 @@ pub struct Website {
|
|||
pub created_at: OffsetDateTime,
|
||||
/// Latest version ID
|
||||
pub latest_version: Option<u32>,
|
||||
// TODO: implement page icon
|
||||
// pub icon: Option<String>,
|
||||
/// Color of the page icon
|
||||
pub color: Option<u32>,
|
||||
/// Visibility of the page in the sidebar menu
|
||||
|
@ -129,6 +131,7 @@ impl From<WebsiteUpdate> for db::model::WebsiteUpdate {
|
|||
Self {
|
||||
name: value.name,
|
||||
latest_version: None,
|
||||
icon: None,
|
||||
color: value.color,
|
||||
visibility: value.visibility,
|
||||
source_url: value.source_url,
|
||||
|
|
47
src/page.rs
47
src/page.rs
|
@ -1,47 +0,0 @@
|
|||
use poem::{
|
||||
error::ResponseError,
|
||||
handler,
|
||||
http::{header, StatusCode},
|
||||
web::{Data, Redirect},
|
||||
IntoResponse, Request, Response, Result,
|
||||
};
|
||||
|
||||
use crate::Talon;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum PageError {
|
||||
#[error("invalid subdomain")]
|
||||
InvalidSubdomain,
|
||||
#[error("website does not have any version")]
|
||||
NoVersion,
|
||||
}
|
||||
|
||||
impl ResponseError for PageError {
|
||||
fn status(&self) -> StatusCode {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
}
|
||||
|
||||
#[handler]
|
||||
pub 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 ws = talon.db.get_website(subdomain)?;
|
||||
let version = ws.latest_version.ok_or(PageError::NoVersion)?;
|
||||
let file = talon
|
||||
.storage
|
||||
.get_file(version, request.original_uri().path(), request.headers())?;
|
||||
|
||||
Ok(match file.rd_path {
|
||||
Some(rd_path) => Redirect::moved_permanent(rd_path).into_response(),
|
||||
None => file.to_response(request.headers())?.into_response(),
|
||||
})
|
||||
}
|
|
@ -1,92 +0,0 @@
|
|||
use std::{ops::Deref, path::Path, sync::Arc};
|
||||
|
||||
use crate::{api::TalonApi, config::Config, db::Db, page::page, storage::Storage, util};
|
||||
use path_macro::path;
|
||||
use poem::{listener::TcpListener, EndpointExt, Route, RouteDomain, Server};
|
||||
use poem_openapi::OpenApiService;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Talon {
|
||||
i: Arc<TalonInner>,
|
||||
}
|
||||
|
||||
pub struct TalonInner {
|
||||
pub cfg: Config,
|
||||
pub db: Db,
|
||||
pub storage: Storage,
|
||||
}
|
||||
|
||||
impl Deref for Talon {
|
||||
type Target = TalonInner;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.i
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum TalonError {
|
||||
#[error("db error: {0}")]
|
||||
Db(#[from] crate::db::DbError),
|
||||
#[error("db error: {0}")]
|
||||
Storage(#[from] crate::storage::StorageError),
|
||||
#[error("db error: {0}")]
|
||||
Config(#[from] crate::config::ConfigError),
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, TalonError>;
|
||||
|
||||
impl Talon {
|
||||
pub fn new<P: AsRef<Path>>(workdir: P) -> Result<Self> {
|
||||
let db_dir = path!(workdir / "db");
|
||||
let storage_dir = path!(workdir / "storage");
|
||||
util::create_dir_ne(&db_dir)?;
|
||||
util::create_dir_ne(&storage_dir)?;
|
||||
|
||||
let cfg = Config::from_file(path!(workdir / "config.toml"))?;
|
||||
let db = Db::new(db_dir)?;
|
||||
let storage = Storage::new(storage_dir, db.clone(), cfg.clone());
|
||||
|
||||
Ok(Self {
|
||||
i: TalonInner { cfg, db, storage }.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn launch(&self) -> Result<()> {
|
||||
let api_service = OpenApiService::new(TalonApi, "Talon", "0.1.0")
|
||||
.server(format!("{}/api", self.i.cfg.server.internal_url));
|
||||
let swagger_ui = api_service.swagger_ui();
|
||||
let spec = api_service.spec();
|
||||
|
||||
let route_internal = Route::new()
|
||||
.at(
|
||||
"/",
|
||||
poem::endpoint::make_sync(|_| "Hello World, I am Talon"),
|
||||
)
|
||||
.nest("/api", api_service)
|
||||
.nest("/api/swagger", swagger_ui)
|
||||
.at(
|
||||
"/api/spec",
|
||||
poem::endpoint::make_sync(move |_| spec.clone()),
|
||||
);
|
||||
|
||||
let internal_domain = format!(
|
||||
"{}.{}",
|
||||
self.i.cfg.server.internal_subdomain, self.i.cfg.server.root_domain
|
||||
);
|
||||
let site_domains = format!("+.{}", self.i.cfg.server.root_domain);
|
||||
|
||||
let route = RouteDomain::new()
|
||||
.at(&internal_domain, route_internal)
|
||||
.at(&site_domains, page)
|
||||
.at(&self.i.cfg.server.root_domain, page)
|
||||
.data(self.clone());
|
||||
|
||||
Server::new(TcpListener::bind(&self.i.cfg.server.address))
|
||||
.run(route)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@ use std::{
|
|||
ops::Bound,
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use flate2::{read::GzDecoder, write::GzEncoder};
|
||||
|
@ -28,7 +29,12 @@ use crate::{
|
|||
util,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Storage {
|
||||
i: Arc<StorageInner>,
|
||||
}
|
||||
|
||||
struct StorageInner {
|
||||
path: PathBuf,
|
||||
db: Db,
|
||||
cfg: Config,
|
||||
|
@ -101,10 +107,13 @@ impl Storage {
|
|||
/// Create a new file storage using the root folder and the database
|
||||
pub fn new<P: Into<PathBuf>>(path: P, db: Db, cfg: Config) -> Self {
|
||||
Self {
|
||||
i: StorageInner {
|
||||
path: path.into(),
|
||||
db,
|
||||
cfg,
|
||||
}
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a single file into the store
|
||||
|
@ -126,26 +135,26 @@ impl Storage {
|
|||
|
||||
fs::copy(file_path, &stored_file)?;
|
||||
|
||||
if self.cfg.compression.enabled()
|
||||
if self.i.cfg.compression.enabled()
|
||||
&& mime_guess::from_path(file_path)
|
||||
.first()
|
||||
.map(|t| compressible::is_compressible(t.essence_str()))
|
||||
.unwrap_or_default()
|
||||
{
|
||||
if self.cfg.compression.gzip_en {
|
||||
if self.i.cfg.compression.gzip_en {
|
||||
let mut encoder = GzEncoder::new(
|
||||
fs::File::create(stored_file.with_extension("gz"))?,
|
||||
flate2::Compression::new(self.cfg.compression.gzip_level.into()),
|
||||
flate2::Compression::new(self.i.cfg.compression.gzip_level.into()),
|
||||
);
|
||||
let mut input = BufReader::new(fs::File::open(&stored_file)?);
|
||||
std::io::copy(&mut input, &mut encoder)?;
|
||||
}
|
||||
|
||||
if self.cfg.compression.brotli_en {
|
||||
if self.i.cfg.compression.brotli_en {
|
||||
let mut encoder = brotli::CompressorWriter::new(
|
||||
fs::File::create(stored_file.with_extension("br"))?,
|
||||
4096,
|
||||
self.cfg.compression.brotli_level.into(),
|
||||
self.i.cfg.compression.brotli_level.into(),
|
||||
20,
|
||||
);
|
||||
let mut input = BufReader::new(fs::File::open(&stored_file)?);
|
||||
|
@ -153,7 +162,7 @@ impl Storage {
|
|||
}
|
||||
}
|
||||
|
||||
self.db.insert_file(version, site_path, &hash)?;
|
||||
self.i.db.insert_file(version, site_path, &hash)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -239,7 +248,7 @@ impl Storage {
|
|||
fn file_path_mkdir(&self, hash: &[u8]) -> Result<PathBuf> {
|
||||
let hash_str = hash.encode_hex::<String>();
|
||||
|
||||
let subdir = self.path.join(&hash_str[..2]);
|
||||
let subdir = self.i.path.join(&hash_str[..2]);
|
||||
if !subdir.is_dir() {
|
||||
fs::create_dir(&subdir)?;
|
||||
}
|
||||
|
@ -248,7 +257,7 @@ impl Storage {
|
|||
|
||||
fn file_path(&self, hash: &[u8]) -> PathBuf {
|
||||
let hash_str = hash.encode_hex::<String>();
|
||||
let subdir = self.path.join(&hash_str[..2]);
|
||||
let subdir = self.i.path.join(&hash_str[..2]);
|
||||
subdir.join(&hash_str)
|
||||
}
|
||||
|
||||
|
@ -256,13 +265,13 @@ impl Storage {
|
|||
let path = self.file_path(hash);
|
||||
let mut res = BTreeMap::new();
|
||||
|
||||
if self.cfg.compression.gzip_en {
|
||||
if self.i.cfg.compression.gzip_en {
|
||||
let path_gz = path.with_extension("gz");
|
||||
if path_gz.is_file() {
|
||||
res.insert(CompressionAlg::Gzip, path_gz);
|
||||
}
|
||||
}
|
||||
if self.cfg.compression.brotli_en {
|
||||
if self.i.cfg.compression.brotli_en {
|
||||
let path_br = path.with_extension("br");
|
||||
if path_br.is_file() {
|
||||
res.insert(CompressionAlg::Brotli, path_br);
|
||||
|
@ -290,7 +299,7 @@ impl Storage {
|
|||
// Attempt to access the following pages
|
||||
// 1. Site path directly
|
||||
// 2. Site path + `/index.html`
|
||||
match self.db.get_file_opt(version, sp)? {
|
||||
match self.i.db.get_file_opt(version, sp)? {
|
||||
Some(h) => {
|
||||
hash = Some(h);
|
||||
}
|
||||
|
@ -308,6 +317,7 @@ impl Storage {
|
|||
let hash = match hash {
|
||||
Some(hash) => hash,
|
||||
None => self
|
||||
.i
|
||||
.db
|
||||
.get_file_opt(version, &new_path)?
|
||||
.ok_or_else(|| StorageError::NotFound(sp.to_owned()))?,
|
||||
|
|
|
@ -119,14 +119,6 @@ pub fn validate_subdomain(subdomain: &str) -> bool {
|
|||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
}
|
||||
|
||||
pub fn create_dir_ne<P: AsRef<Path>>(path: P) -> Result<(), std::io::Error> {
|
||||
let path = path.as_ref();
|
||||
if !path.is_dir() {
|
||||
std::fs::create_dir(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
7
tests/fixtures/mod.rs
vendored
7
tests/fixtures/mod.rs
vendored
|
@ -57,6 +57,9 @@ fn insert_websites(db: &Db) {
|
|||
name: "ThetaDev".to_owned(),
|
||||
created_at: datetime!(2023-02-18 16:30 +0),
|
||||
latest_version: Some(VERSION_1_2),
|
||||
icon: Some(
|
||||
hex!("9f7e7971b4bfdb75429e534dea461ed90340886925078cda252cada9aa0e25f7").to_vec(),
|
||||
),
|
||||
color: Some(2068974),
|
||||
visibility: talon::model::Visibility::Featured,
|
||||
..Default::default()
|
||||
|
@ -69,6 +72,9 @@ fn insert_websites(db: &Db) {
|
|||
name: "Spotify-Gender-Ex".to_owned(),
|
||||
created_at: datetime!(2023-02-18 16:30 +0),
|
||||
latest_version: Some(VERSION_2_1),
|
||||
icon: Some(
|
||||
hex!("9b35024aacebd74010ea595ef5d180f47f5ec822df100236dd6ac808497b64f6").to_vec(),
|
||||
),
|
||||
color: Some(1947988),
|
||||
visibility: talon::model::Visibility::Featured,
|
||||
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex".to_owned()),
|
||||
|
@ -83,6 +89,7 @@ fn insert_websites(db: &Db) {
|
|||
name: "RustyPipe".to_owned(),
|
||||
created_at: datetime!(2023-02-20 18:30 +0),
|
||||
latest_version: Some(VERSION_3_1),
|
||||
icon: None,
|
||||
color: Some(7943647),
|
||||
visibility: talon::model::Visibility::Featured,
|
||||
source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe".to_owned()),
|
||||
|
|
|
@ -4,7 +4,8 @@ expression: "&cfg"
|
|||
---
|
||||
ConfigInner(
|
||||
server: ServerCfg(
|
||||
address: "127.0.0.1:3000",
|
||||
address: "127.0.0.1",
|
||||
port: 3000,
|
||||
root_domain: "example.com",
|
||||
internal_subdomain: "talon-i",
|
||||
internal_url: "http://talon-i.example.com",
|
||||
|
|
|
@ -4,7 +4,8 @@ expression: "&cfg"
|
|||
---
|
||||
ConfigInner(
|
||||
server: ServerCfg(
|
||||
address: "0.0.0.0:3000",
|
||||
address: "0.0.0.0",
|
||||
port: 8080,
|
||||
root_domain: "localhost",
|
||||
internal_subdomain: "talon",
|
||||
internal_url: "http://talon.localhost",
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
---
|
||||
source: tests/tests.rs
|
||||
source: src/db/mod.rs
|
||||
expression: data
|
||||
---
|
||||
{"type":"website","key":"rustypipe","value":{"name":"RustyPipe","created_at":[2023,51,18,30,0,0,0,0,0],"latest_version":4,"color":7943647,"visibility":"featured","source_url":"https://code.thetadev.de/ThetaDev/rustypipe","source_icon":"gitea"}}
|
||||
{"type":"website","key":"spotify-gender-ex","value":{"name":"Spotify-Gender-Ex","created_at":[2023,49,16,30,0,0,0,0,0],"latest_version":3,"color":1947988,"visibility":"featured","source_url":"https://github.com/Theta-Dev/Spotify-Gender-Ex","source_icon":"github"}}
|
||||
{"type":"website","key":"rustypipe","value":{"name":"RustyPipe","created_at":[2023,51,18,30,0,0,0,0,0],"latest_version":4,"icon":null,"color":7943647,"visibility":"featured","source_url":"https://code.thetadev.de/ThetaDev/rustypipe","source_icon":"gitea"}}
|
||||
{"type":"website","key":"spotify-gender-ex","value":{"name":"Spotify-Gender-Ex","created_at":[2023,49,16,30,0,0,0,0,0],"latest_version":3,"icon":[155,53,2,74,172,235,215,64,16,234,89,94,245,209,128,244,127,94,200,34,223,16,2,54,221,106,200,8,73,123,100,246],"color":1947988,"visibility":"featured","source_url":"https://github.com/Theta-Dev/Spotify-Gender-Ex","source_icon":"github"}}
|
||||
{"type":"version","key":"rustypipe:4","value":{"created_at":[2023,51,18,30,0,0,0,0,0],"data":{}}}
|
||||
{"type":"version","key":"spotify-gender-ex:3","value":{"created_at":[2023,49,16,30,0,0,0,0,0],"data":{}}}
|
||||
{"type":"file","key":"3:gex_style.css","value":"fc825b409a49724af8f5b3c4ad15e175e68095ea746237a7b46152d3f383f541"}
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
source: tests/tests.rs
|
||||
expression: data
|
||||
---
|
||||
{"type":"website","key":"-","value":{"name":"ThetaDev","created_at":[2023,49,16,30,0,0,0,0,0],"latest_version":2,"color":2068974,"visibility":"featured","source_url":null,"source_icon":null}}
|
||||
{"type":"website","key":"rustypipe","value":{"name":"RustyPipe","created_at":[2023,51,18,30,0,0,0,0,0],"latest_version":4,"color":7943647,"visibility":"featured","source_url":"https://code.thetadev.de/ThetaDev/rustypipe","source_icon":"gitea"}}
|
||||
{"type":"website","key":"spotify-gender-ex","value":{"name":"Spotify-Gender-Ex","created_at":[2023,49,16,30,0,0,0,0,0],"latest_version":3,"color":1947988,"visibility":"featured","source_url":"https://github.com/Theta-Dev/Spotify-Gender-Ex","source_icon":"github"}}
|
||||
{"type":"website","key":"-","value":{"name":"ThetaDev","created_at":[2023,49,16,30,0,0,0,0,0],"latest_version":2,"icon":[159,126,121,113,180,191,219,117,66,158,83,77,234,70,30,217,3,64,136,105,37,7,140,218,37,44,173,169,170,14,37,247],"color":2068974,"visibility":"featured","source_url":null,"source_icon":null}}
|
||||
{"type":"website","key":"rustypipe","value":{"name":"RustyPipe","created_at":[2023,51,18,30,0,0,0,0,0],"latest_version":4,"icon":null,"color":7943647,"visibility":"featured","source_url":"https://code.thetadev.de/ThetaDev/rustypipe","source_icon":"gitea"}}
|
||||
{"type":"website","key":"spotify-gender-ex","value":{"name":"Spotify-Gender-Ex","created_at":[2023,49,16,30,0,0,0,0,0],"latest_version":3,"icon":[155,53,2,74,172,235,215,64,16,234,89,94,245,209,128,244,127,94,200,34,223,16,2,54,221,106,200,8,73,123,100,246],"color":1947988,"visibility":"featured","source_url":"https://github.com/Theta-Dev/Spotify-Gender-Ex","source_icon":"github"}}
|
||||
{"type":"version","key":"-:1","value":{"created_at":[2023,49,16,30,0,0,0,0,0],"data":{"Deployed by":"https://github.com/Theta-Dev/Talon/actions/runs/1352014628","Version":"v0.1.0"}}}
|
||||
{"type":"version","key":"-:2","value":{"created_at":[2023,49,16,52,0,0,0,0,0],"data":{"Deployed by":"https://github.com/Theta-Dev/Talon/actions/runs/1354755231","Version":"v0.1.1"}}}
|
||||
{"type":"version","key":"rustypipe:4","value":{"created_at":[2023,51,18,30,0,0,0,0,0],"data":{}}}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
source: tests/tests.rs
|
||||
source: src/db/mod.rs
|
||||
expression: "vec![ws1, ws2, ws3]"
|
||||
---
|
||||
[
|
||||
|
@ -7,6 +7,40 @@ expression: "vec![ws1, ws2, ws3]"
|
|||
name: "ThetaDev",
|
||||
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
||||
latest_version: Some(2),
|
||||
icon: Some([
|
||||
159,
|
||||
126,
|
||||
121,
|
||||
113,
|
||||
180,
|
||||
191,
|
||||
219,
|
||||
117,
|
||||
66,
|
||||
158,
|
||||
83,
|
||||
77,
|
||||
234,
|
||||
70,
|
||||
30,
|
||||
217,
|
||||
3,
|
||||
64,
|
||||
136,
|
||||
105,
|
||||
37,
|
||||
7,
|
||||
140,
|
||||
218,
|
||||
37,
|
||||
44,
|
||||
173,
|
||||
169,
|
||||
170,
|
||||
14,
|
||||
37,
|
||||
247,
|
||||
]),
|
||||
color: Some(2068974),
|
||||
visibility: featured,
|
||||
source_url: None,
|
||||
|
@ -16,6 +50,40 @@ expression: "vec![ws1, ws2, ws3]"
|
|||
name: "Spotify-Gender-Ex",
|
||||
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
||||
latest_version: Some(3),
|
||||
icon: Some([
|
||||
155,
|
||||
53,
|
||||
2,
|
||||
74,
|
||||
172,
|
||||
235,
|
||||
215,
|
||||
64,
|
||||
16,
|
||||
234,
|
||||
89,
|
||||
94,
|
||||
245,
|
||||
209,
|
||||
128,
|
||||
244,
|
||||
127,
|
||||
94,
|
||||
200,
|
||||
34,
|
||||
223,
|
||||
16,
|
||||
2,
|
||||
54,
|
||||
221,
|
||||
106,
|
||||
200,
|
||||
8,
|
||||
73,
|
||||
123,
|
||||
100,
|
||||
246,
|
||||
]),
|
||||
color: Some(1947988),
|
||||
visibility: featured,
|
||||
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"),
|
||||
|
@ -25,6 +93,7 @@ expression: "vec![ws1, ws2, ws3]"
|
|||
name: "RustyPipe",
|
||||
created_at: (2023, 51, 18, 30, 0, 0, 0, 0, 0),
|
||||
latest_version: Some(4),
|
||||
icon: None,
|
||||
color: Some(7943647),
|
||||
visibility: featured,
|
||||
source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"),
|
||||
|
|
|
@ -7,6 +7,40 @@ expression: websites
|
|||
name: "ThetaDev",
|
||||
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
||||
latest_version: Some(2),
|
||||
icon: Some([
|
||||
159,
|
||||
126,
|
||||
121,
|
||||
113,
|
||||
180,
|
||||
191,
|
||||
219,
|
||||
117,
|
||||
66,
|
||||
158,
|
||||
83,
|
||||
77,
|
||||
234,
|
||||
70,
|
||||
30,
|
||||
217,
|
||||
3,
|
||||
64,
|
||||
136,
|
||||
105,
|
||||
37,
|
||||
7,
|
||||
140,
|
||||
218,
|
||||
37,
|
||||
44,
|
||||
173,
|
||||
169,
|
||||
170,
|
||||
14,
|
||||
37,
|
||||
247,
|
||||
]),
|
||||
color: Some(2068974),
|
||||
visibility: featured,
|
||||
source_url: None,
|
||||
|
@ -16,6 +50,7 @@ expression: websites
|
|||
name: "RustyPipe",
|
||||
created_at: (2023, 51, 18, 30, 0, 0, 0, 0, 0),
|
||||
latest_version: Some(4),
|
||||
icon: None,
|
||||
color: Some(7943647),
|
||||
visibility: featured,
|
||||
source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"),
|
||||
|
@ -25,6 +60,40 @@ expression: websites
|
|||
name: "Spotify-Gender-Ex",
|
||||
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
||||
latest_version: Some(3),
|
||||
icon: Some([
|
||||
155,
|
||||
53,
|
||||
2,
|
||||
74,
|
||||
172,
|
||||
235,
|
||||
215,
|
||||
64,
|
||||
16,
|
||||
234,
|
||||
89,
|
||||
94,
|
||||
245,
|
||||
209,
|
||||
128,
|
||||
244,
|
||||
127,
|
||||
94,
|
||||
200,
|
||||
34,
|
||||
223,
|
||||
16,
|
||||
2,
|
||||
54,
|
||||
221,
|
||||
106,
|
||||
200,
|
||||
8,
|
||||
73,
|
||||
123,
|
||||
100,
|
||||
246,
|
||||
]),
|
||||
color: Some(1947988),
|
||||
visibility: featured,
|
||||
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"),
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
---
|
||||
source: tests/tests.rs
|
||||
source: src/db/mod.rs
|
||||
expression: website
|
||||
---
|
||||
Website(
|
||||
name: "ThetaDev2",
|
||||
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
||||
latest_version: Some(2),
|
||||
icon: None,
|
||||
color: Some(1000),
|
||||
visibility: hidden,
|
||||
source_url: Some("https://example.com"),
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
[server]
|
||||
address = "127.0.0.1:3000"
|
||||
address = "127.0.0.1"
|
||||
port = 3000
|
||||
root_domain = "example.com"
|
||||
internal_subdomain = "talon-i"
|
||||
internal_url = "http://talon-i.example.com"
|
||||
|
|
|
@ -73,6 +73,7 @@ mod database {
|
|||
SUBDOMAIN_1,
|
||||
WebsiteUpdate {
|
||||
name: Some("ThetaDev2".to_owned()),
|
||||
icon: Some(None),
|
||||
color: Some(Some(1000)),
|
||||
visibility: Some(talon::model::Visibility::Hidden),
|
||||
source_url: Some(Some("https://example.com".to_owned())),
|
||||
|
@ -176,7 +177,7 @@ mod database {
|
|||
#[rstest]
|
||||
fn get_file_hashes(db: DbTest) {
|
||||
let hashes = db.get_file_hashes().unwrap();
|
||||
assert_eq!(hashes.len(), 10)
|
||||
assert_eq!(hashes.len(), 12)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue