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 { let talon = req.data::()?; 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#"

Talon API

Rumfingern

OpenAPI specification

"#, ) } /// Get a website #[oai(path = "/website/:subdomain", method = "get")] async fn website_get( &self, talon: Data<&Talon>, subdomain: Path, ) -> Result>> { 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, website: Json, ) -> 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, website: Json, ) -> 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, /// Icon data. data: Binary>, ) -> 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, ) -> 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, ) -> 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, ) -> Result>>> { 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::, _>>() .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, ) -> Result>>> { 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::, _>>() .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, ) -> Result>>> { let website = talon.db.get_website(&subdomain)?; let mut versions = talon .db .get_website_versions(&subdomain) .map(|r| r.map(Version::from)) .collect::, _>>()?; 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, version: Path, ) -> Result>> { 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, version: Path, ) -> Result>>> { let v = talon.db.get_version(&subdomain, *version)?; talon .db .get_version_files(&subdomain, *version) .map(|r| r.map(VersionFile::from)) .collect::, _>>() .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, version: Path, ) -> 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, /// Fallback page /// /// The fallback page gets returned when the requested page does not exist fallback: Query>, /// SPA mode (return fallback page with OK status) #[oai(default)] spa: Query, /// 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>, ) -> 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>, subdomain: &str, version: u32, fallback: Option, ) -> 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, ) -> Result { 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> { Ok(Json(talon.info()?)) } } pub fn api_service() -> OpenApiService { OpenApiService::new(TalonApi, "Talon", crate::API_VERSION) .description("API for the Talon static site management system") .license(LicenseObject::new("MIT License")) }