Talon/src/api.rs
ThetaDev ba4086bac9
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix: website version ordering
2023-07-22 04:46:57 +02:00

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"))
}