All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
509 lines
16 KiB
Rust
509 lines
16 KiB
Rust
use std::io::Cursor;
|
|
|
|
use hex_literal::hex;
|
|
use poem::{
|
|
error::{Error, ResponseError},
|
|
http::{header, StatusCode},
|
|
web::Data,
|
|
Request, Result,
|
|
};
|
|
use poem_openapi::{
|
|
auth::ApiKey,
|
|
param::{Path, Query},
|
|
payload::{Binary, Html, Json, Response},
|
|
LicenseObject, OpenApi, OpenApiService, SecurityScheme,
|
|
};
|
|
|
|
use crate::{
|
|
config::{Access, KeyCfg},
|
|
db,
|
|
model::*,
|
|
oai::{DynParams, FileResponse},
|
|
util, Talon,
|
|
};
|
|
|
|
pub struct TalonApi;
|
|
|
|
#[derive(SecurityScheme)]
|
|
#[oai(
|
|
type = "api_key",
|
|
key_name = "X-API-Key",
|
|
in = "header",
|
|
checker = "api_key_checker"
|
|
)]
|
|
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
|
|
}
|
|
|
|
impl ApiKeyAuthorization {
|
|
fn check_subdomain(&self, subdomain: &str, access: Access) -> Result<()> {
|
|
if subdomain.is_empty() {
|
|
Err(ApiError::InvalidSubdomain.into())
|
|
} else if !self.0.domains.matches_domain(subdomain) || !self.0.allows(access) {
|
|
Err(ApiError::NoAccess.into())
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(thiserror::Error, Debug)]
|
|
pub enum ApiError {
|
|
#[error("invalid subdomain")]
|
|
InvalidSubdomain,
|
|
#[error("you do not have access to this subdomain")]
|
|
NoAccess,
|
|
#[error("invalid fallback: {0}")]
|
|
InvalidFallback(String),
|
|
#[error("invalid archive type")]
|
|
InvalidArchiveType,
|
|
#[error("invalid color")]
|
|
InvalidColor,
|
|
#[error("join error: {0}")]
|
|
TokioJoin(#[from] tokio::task::JoinError),
|
|
}
|
|
|
|
impl ResponseError for ApiError {
|
|
fn status(&self) -> StatusCode {
|
|
match self {
|
|
ApiError::InvalidSubdomain
|
|
| ApiError::InvalidFallback(_)
|
|
| ApiError::InvalidArchiveType
|
|
| ApiError::InvalidColor => StatusCode::BAD_REQUEST,
|
|
ApiError::NoAccess => StatusCode::FORBIDDEN,
|
|
ApiError::TokioJoin(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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(
|
|
&self,
|
|
talon: Data<&Talon>,
|
|
subdomain: Path<String>,
|
|
) -> Result<Response<Json<Website>>> {
|
|
talon
|
|
.db
|
|
.get_website(&subdomain)
|
|
.map(|website| {
|
|
let modified = website.updated_at;
|
|
Response::new(Json(Website::from((subdomain.0, website)))).header(
|
|
header::LAST_MODIFIED,
|
|
httpdate::fmt_http_date(modified.into()),
|
|
)
|
|
})
|
|
.map_err(Error::from)
|
|
}
|
|
|
|
/// Create a new website
|
|
#[oai(path = "/website/:subdomain", method = "put")]
|
|
async fn website_create(
|
|
&self,
|
|
auth: ApiKeyAuthorization,
|
|
talon: Data<&Talon>,
|
|
subdomain: Path<String>,
|
|
website: Json<WebsiteNew>,
|
|
) -> Result<()> {
|
|
auth.check_subdomain(&subdomain, Access::Modify)?;
|
|
if subdomain.as_str() == talon.cfg.server.internal_subdomain
|
|
|| !util::validate_subdomain(&subdomain)
|
|
{
|
|
return Err(ApiError::InvalidSubdomain.into());
|
|
}
|
|
|
|
talon
|
|
.db
|
|
.insert_website(&subdomain, &website.0.try_into()?)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Update website
|
|
#[oai(path = "/website/:subdomain", method = "patch")]
|
|
async fn website_update(
|
|
&self,
|
|
auth: ApiKeyAuthorization,
|
|
talon: Data<&Talon>,
|
|
subdomain: Path<String>,
|
|
website: Json<WebsiteUpdate>,
|
|
) -> Result<()> {
|
|
auth.check_subdomain(&subdomain, Access::Modify)?;
|
|
|
|
talon.db.update_website(&subdomain, website.0.try_into()?)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Upload a website icon
|
|
///
|
|
/// Supported image formats: png, jpeg, gif, bmp, ico, tiff,
|
|
/// webp, pnm, dds, tga, openexr, farbfeld
|
|
///
|
|
/// Maximum upload size: 5MB, 4000 pixels
|
|
///
|
|
/// Icons are resized to 32x32 pixels.
|
|
#[oai(path = "/website/:subdomain/icon", method = "put")]
|
|
async fn website_add_icon(
|
|
&self,
|
|
auth: ApiKeyAuthorization,
|
|
talon: Data<&Talon>,
|
|
subdomain: Path<String>,
|
|
/// Icon data.
|
|
data: Binary<Vec<u8>>,
|
|
) -> Result<()> {
|
|
auth.check_subdomain(&subdomain, Access::Modify)?;
|
|
|
|
let t2 = talon.clone();
|
|
let sd = subdomain.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
t2.icons.insert_icon(Cursor::new(data.as_slice()), &sd)
|
|
})
|
|
.await
|
|
.map_err(ApiError::from)??;
|
|
|
|
talon.db.update_website(
|
|
&subdomain,
|
|
db::model::WebsiteUpdate {
|
|
has_icon: Some(true),
|
|
..Default::default()
|
|
},
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete a website icon
|
|
#[oai(path = "/website/:subdomain/icon", method = "delete")]
|
|
async fn website_delete_icon(
|
|
&self,
|
|
auth: ApiKeyAuthorization,
|
|
talon: Data<&Talon>,
|
|
subdomain: Path<String>,
|
|
) -> Result<()> {
|
|
auth.check_subdomain(&subdomain, Access::Modify)?;
|
|
|
|
talon.icons.delete_icon(&subdomain)?;
|
|
talon.db.update_website(
|
|
&subdomain,
|
|
db::model::WebsiteUpdate {
|
|
has_icon: Some(false),
|
|
..Default::default()
|
|
},
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete website
|
|
#[oai(path = "/website/:subdomain", method = "delete")]
|
|
async fn website_delete(
|
|
&self,
|
|
auth: ApiKeyAuthorization,
|
|
talon: Data<&Talon>,
|
|
subdomain: Path<String>,
|
|
) -> Result<()> {
|
|
auth.check_subdomain(&subdomain, Access::Modify)?;
|
|
|
|
talon.db.delete_website(&subdomain, true)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Get all publicly listed websites
|
|
///
|
|
/// Returns all publicly listed websites (visibility != `hidden`)
|
|
#[oai(path = "/websites", method = "get")]
|
|
async fn websites_get(
|
|
&self,
|
|
talon: Data<&Talon>,
|
|
/// Mimimum visibility of the websites
|
|
#[oai(default)]
|
|
visibility: Query<Visibility>,
|
|
) -> Result<Response<Json<Vec<Website>>>> {
|
|
let modified = talon.db.websites_last_update()?;
|
|
talon
|
|
.db
|
|
.get_websites()
|
|
.map(|r| r.map(Website::from))
|
|
.filter(|ws| match ws {
|
|
Ok(ws) => ws.visibility != Visibility::Hidden && ws.visibility <= visibility.0,
|
|
Err(_) => true,
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map(|data| {
|
|
Response::new(Json(data))
|
|
.header(header::LAST_MODIFIED, httpdate::fmt_http_date(modified))
|
|
})
|
|
.map_err(Error::from)
|
|
}
|
|
|
|
/// Get all websites
|
|
///
|
|
/// Returns all websites from Talon's database (including hidden ones, if the current user
|
|
/// has access to them). This endpoint requires authentication (use the `/websites` endpoint
|
|
/// for unauthenticated users).
|
|
#[oai(path = "/websitesAll", method = "get")]
|
|
async fn websites_get_all(
|
|
&self,
|
|
auth: ApiKeyAuthorization,
|
|
talon: Data<&Talon>,
|
|
/// Mimimum visibility of the websites
|
|
#[oai(default)]
|
|
visibility: Query<Visibility>,
|
|
) -> Result<Response<Json<Vec<Website>>>> {
|
|
let modified = talon.db.websites_last_update()?;
|
|
talon
|
|
.db
|
|
.get_websites()
|
|
.map(|r| r.map(Website::from))
|
|
.filter(|ws| match ws {
|
|
Ok(ws) => {
|
|
if ws.visibility == Visibility::Hidden
|
|
&& auth.check_subdomain(&ws.subdomain, Access::Read).is_err()
|
|
{
|
|
false
|
|
} else {
|
|
ws.visibility <= visibility.0
|
|
}
|
|
}
|
|
Err(_) => true,
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map(|data| {
|
|
Response::new(Json(data))
|
|
.header(header::LAST_MODIFIED, httpdate::fmt_http_date(modified))
|
|
})
|
|
.map_err(Error::from)
|
|
}
|
|
|
|
/// Get website versions
|
|
#[oai(path = "/website/:subdomain/versions", method = "get")]
|
|
async fn website_versions(
|
|
&self,
|
|
talon: Data<&Talon>,
|
|
subdomain: Path<String>,
|
|
) -> Result<Response<Json<Vec<Version>>>> {
|
|
let website = talon.db.get_website(&subdomain)?;
|
|
let mut versions = talon
|
|
.db
|
|
.get_website_versions(&subdomain)
|
|
.map(|r| r.map(Version::from))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
versions.sort_by_key(|v| v.id);
|
|
|
|
Ok(Response::new(Json(versions)).header(
|
|
header::LAST_MODIFIED,
|
|
httpdate::fmt_http_date(website.updated_at.into()),
|
|
))
|
|
}
|
|
|
|
/// Get version
|
|
#[oai(path = "/website/:subdomain/version/:version", method = "get")]
|
|
async fn version_get(
|
|
&self,
|
|
talon: Data<&Talon>,
|
|
subdomain: Path<String>,
|
|
version: Path<u32>,
|
|
) -> Result<Response<Json<Version>>> {
|
|
talon
|
|
.db
|
|
.get_version(&subdomain, *version)
|
|
.map(|v| {
|
|
let create_date = v.created_at;
|
|
Response::new(Json(Version::from((*version, v)))).header(
|
|
header::LAST_MODIFIED,
|
|
httpdate::fmt_http_date(create_date.into()),
|
|
)
|
|
})
|
|
.map_err(Error::from)
|
|
}
|
|
|
|
/// Get version files
|
|
#[oai(path = "/website/:subdomain/version/:version/files", method = "get")]
|
|
async fn version_files(
|
|
&self,
|
|
talon: Data<&Talon>,
|
|
subdomain: Path<String>,
|
|
version: Path<u32>,
|
|
) -> Result<Response<Json<Vec<VersionFile>>>> {
|
|
let v = talon.db.get_version(&subdomain, *version)?;
|
|
talon
|
|
.db
|
|
.get_version_files(&subdomain, *version)
|
|
.map(|r| r.map(VersionFile::from))
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map(|r| {
|
|
Response::new(Json(r)).header(
|
|
header::LAST_MODIFIED,
|
|
httpdate::fmt_http_date(v.created_at.into()),
|
|
)
|
|
})
|
|
.map_err(Error::from)
|
|
}
|
|
|
|
/// Delete version
|
|
#[oai(path = "/website/:subdomain/version/:version", method = "delete")]
|
|
async fn version_delete(
|
|
&self,
|
|
auth: ApiKeyAuthorization,
|
|
talon: Data<&Talon>,
|
|
subdomain: Path<String>,
|
|
version: Path<u32>,
|
|
) -> Result<()> {
|
|
auth.check_subdomain(&subdomain, Access::Modify)?;
|
|
|
|
talon.db.delete_version(&subdomain, *version, true)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// 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 version = talon.db.insert_version(
|
|
&subdomain,
|
|
&db::model::Version {
|
|
data: version_data,
|
|
fallback: fallback.0.clone(),
|
|
spa: spa.0,
|
|
..Default::default()
|
|
},
|
|
)?;
|
|
|
|
// Try to store the uploaded website
|
|
// If this fails, the new version needs to be deleted
|
|
fn try_insert(
|
|
talon: &Talon,
|
|
data: Binary<Vec<u8>>,
|
|
subdomain: &str,
|
|
version: u32,
|
|
fallback: Option<String>,
|
|
) -> 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(Error::from(ApiError::InvalidArchiveType));
|
|
}
|
|
|
|
// Validata fallback path
|
|
if let Some(fallback) = &fallback {
|
|
if let Err(e) =
|
|
talon
|
|
.storage
|
|
.get_file(subdomain, version, fallback, &Default::default())
|
|
{
|
|
return Err(Error::from(ApiError::InvalidFallback(e.to_string())));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
let t2 = talon.clone();
|
|
let sd = subdomain.clone();
|
|
|
|
match tokio::task::spawn_blocking(move || try_insert(&t2, data, &sd, version, fallback.0))
|
|
.await
|
|
.map_err(|e| Error::from(ApiError::from(e)))
|
|
{
|
|
Ok(Ok(())) => {
|
|
talon.db.update_website(
|
|
&subdomain,
|
|
db::model::WebsiteUpdate {
|
|
latest_version: Some(Some(version)),
|
|
..Default::default()
|
|
},
|
|
)?;
|
|
Ok(())
|
|
}
|
|
Err(e) | Ok(Err(e)) => {
|
|
// Remove the bad version and decrement the id counter
|
|
let _ = talon.db.delete_version(&subdomain, version, false);
|
|
let _ = talon.db.decrement_vid(&subdomain, version);
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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))
|
|
}
|
|
|
|
/// Get information about your server
|
|
#[oai(path = "/info", method = "get")]
|
|
async fn get_info(&self, talon: Data<&Talon>) -> Result<Json<Info>> {
|
|
Ok(Json(talon.info()?))
|
|
}
|
|
}
|
|
|
|
pub fn api_service() -> OpenApiService<TalonApi, ()> {
|
|
OpenApiService::new(TalonApi, "Talon", crate::API_VERSION)
|
|
.description("API for the Talon static site management system")
|
|
.license(LicenseObject::new("MIT License"))
|
|
}
|