diff --git a/Cargo.lock b/Cargo.lock index aff74ee..8096e89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4269,6 +4269,7 @@ dependencies = [ "serde_json", "serde_plain", "serde_with", + "sha2 0.10.9", "sourcify", "sqlx 0.9.0", "strum 0.28.0", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 61df435..0b52fe2 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -54,6 +54,7 @@ chrono = "0.4.39" serde_with = { version = "3.9.0", features = ["json", "chrono"] } uuid = { version = "1.11.0", features = ["v4", "serde"] } hex = "0.4.3" +sha2 = "0.10" serde_json = {version="1.0.138", features = ["preserve_order", "raw_value"]} figment = { version = "0.10.19", features = ["env", "json"] } dirs = "6" diff --git a/crates/app/src/config.rs b/crates/app/src/config.rs index e61b428..91be15b 100644 --- a/crates/app/src/config.rs +++ b/crates/app/src/config.rs @@ -14,6 +14,7 @@ const LOCAL_DB_NAME: &str = "koi.db"; pub struct Configuration { pub database_url: String, pub abi_cache_dir: String, + pub image_cache_dir: String, pub rpc_requests_per_second: u32, pub rpc_max_in_flight_per_endpoint: usize, pub rpc_rate_limit_retries: u32, @@ -25,6 +26,7 @@ impl Default for Configuration { Self { database_url: "auto".to_string(), abi_cache_dir: "cache/abis".to_string(), + image_cache_dir: "auto".to_string(), rpc_requests_per_second: 12, rpc_max_in_flight_per_endpoint: 8, rpc_rate_limit_retries: 2, @@ -44,11 +46,30 @@ impl Configuration { if config.database_url == "auto" { config.database_url = resolve_database_url()?; } + if config.image_cache_dir == "auto" { + config.image_cache_dir = resolve_cache_dir("images")?; + } Ok(config) } } +pub fn resolve_cache_dir(name: &str) -> Result { + let cwd = std::env::current_dir().map_err(|error| { + KoiError::Internal(format!("could not read current directory: {error}")) + })?; + Ok(resolve_cache_dir_in(&cwd, dirs::cache_dir(), name) + .display() + .to_string()) +} + +pub(crate) fn resolve_cache_dir_in(cwd: &Path, cache_dir: Option, name: &str) -> PathBuf { + cache_dir + .unwrap_or_else(|| cwd.join(".cache")) + .join("koi") + .join(name) +} + pub fn resolve_database_url() -> Result { let cwd = std::env::current_dir().map_err(|error| { KoiError::Internal(format!("could not read current directory: {error}")) diff --git a/crates/app/src/http/asset/mod.rs b/crates/app/src/http/asset/mod.rs index 6e83c1e..7e802d0 100644 --- a/crates/app/src/http/asset/mod.rs +++ b/crates/app/src/http/asset/mod.rs @@ -53,8 +53,12 @@ impl AssetApi { payload: Json, ) -> Result> { let _auth_data = auth.unwrap()?; + let mut asset = payload.0; + if let Some(icon) = &asset.asset_icon_url { + asset.asset_icon_url = Some(state.images.store_reference(icon).await?); + } - Ok(Json(Asset::create(&state.database, payload.0).await?)) + Ok(Json(Asset::create(&state.database, asset).await?)) } /// Get an asset by ID @@ -113,9 +117,17 @@ impl AssetApi { payload: Json, ) -> Result> { let _auth_data = auth.unwrap()?; + let mut asset = payload.0; + if let Some(icon) = asset + .asset_icon_url + .clone() + .filter(|icon| !icon.trim().is_empty()) + { + asset.asset_icon_url = Some(state.images.store_reference(&icon).await?); + } Ok(Json( - Asset::update(&state.database, &asset_identity, payload.0).await?, + Asset::update(&state.database, &asset_identity, asset).await?, )) } diff --git a/crates/app/src/http/cache.rs b/crates/app/src/http/cache.rs new file mode 100644 index 0000000..0f3226c --- /dev/null +++ b/crates/app/src/http/cache.rs @@ -0,0 +1,72 @@ +use poem::{ + Error, Result, + http::{StatusCode, header}, + web::Data, +}; +use poem_openapi::{ + ApiResponse, OpenApi, + param::Query, + payload::{Binary, Response}, +}; + +use crate::{http::ApiTags, state::AppState}; + +pub struct CacheApi; + +#[derive(ApiResponse)] +enum StoreImageResponse { + #[oai(status = 204)] + Stored(#[oai(header = "Location")] String), +} + +pub fn api() -> impl OpenApi { + CacheApi +} + +#[OpenApi] +impl CacheApi { + /// Get a cached image + /// + /// GET /api/cache/image?id=... + #[oai(path = "/cache/image", method = "get", tag = "ApiTags::Cache")] + async fn cached_image( + &self, + state: Data<&AppState>, + id: Query, + ) -> Result>>> { + let image = state + .images + .get(&id) + .await? + .ok_or_else(|| Error::from_status(StatusCode::NOT_FOUND))?; + + Ok(image_response(image)) + } + + /// Fetch a remote image and store it in the cache + /// + /// POST /api/cache/image?url=... + #[oai(path = "/cache/image", method = "post", tag = "ApiTags::Cache")] + async fn fetch_image( + &self, + state: Data<&AppState>, + url: Query, + ) -> Result { + let id = state.images.store(&url).await?; + + Ok(StoreImageResponse::Stored(format!( + "/api/cache/image?id={id}" + ))) + } +} + +fn image_response(image: crate::models::image_cache::CachedImage) -> Response>> { + Response::new(Binary(image.bytes.to_vec())) + .header(header::CONTENT_TYPE, image.content_type) + .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") + .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") + .header( + header::CONTENT_SECURITY_POLICY, + "default-src 'none'; img-src 'self' data:", + ) +} diff --git a/crates/app/src/http/mod.rs b/crates/app/src/http/mod.rs index e956814..8049b06 100644 --- a/crates/app/src/http/mod.rs +++ b/crates/app/src/http/mod.rs @@ -10,6 +10,7 @@ use crate::state::AppState; mod account; mod asset; mod auth; +mod cache; mod health; mod net; mod quoter; @@ -35,6 +36,8 @@ pub enum ApiTags { Task, /// Health endpoints Health, + /// Cache endpoints + Cache, } fn get_api() -> impl OpenApi { @@ -45,6 +48,7 @@ fn get_api() -> impl OpenApi { quoter::api(), vendor::api(), health::api(), + cache::api(), ) } diff --git a/crates/app/src/http/net/mod.rs b/crates/app/src/http/net/mod.rs index 8870edf..3f6f69f 100644 --- a/crates/app/src/http/net/mod.rs +++ b/crates/app/src/http/net/mod.rs @@ -60,8 +60,12 @@ impl NetworkApi { payload: Json, ) -> Result> { let _auth_data = auth.unwrap()?; + let mut network = payload.0; + if let Some(icon) = &network.network_icon_url { + network.network_icon_url = Some(state.images.store_reference(icon).await?); + } - Ok(Json(Network::create(&state.database, payload.0).await?)) + Ok(Json(Network::create(&state.database, network).await?)) } /// Get network presets @@ -151,9 +155,17 @@ impl NetworkApi { payload: Json, ) -> Result> { let _auth_data = auth.unwrap()?; + let mut network = payload.0; + if let Some(icon) = network + .network_icon_url + .clone() + .filter(|icon| !icon.trim().is_empty()) + { + network.network_icon_url = Some(state.images.store_reference(&icon).await?); + } Ok(Json( - Network::update(&state.database, &network_identity, payload.0).await?, + Network::update(&state.database, &network_identity, network).await?, )) } diff --git a/crates/app/src/models/image_cache.rs b/crates/app/src/models/image_cache.rs new file mode 100644 index 0000000..191bcd0 --- /dev/null +++ b/crates/app/src/models/image_cache.rs @@ -0,0 +1,287 @@ +use std::{ + io::ErrorKind, + path::{Path, PathBuf}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use bytes::Bytes; +use reqwest::{Client, Url, redirect::Policy}; +use sha2::{Digest, Sha256}; +use tokio::fs; + +use crate::error::KoiError; + +const MAX_IMAGE_BYTES: u64 = 2 * 1024 * 1024; +const USER_AGENT: &str = concat!("koi/", env!("CARGO_PKG_VERSION"), " image-cache"); + +const IMAGE_TYPES: &[ImageType] = &[ + ImageType { + content_type: "image/avif", + extension: "avif", + }, + ImageType { + content_type: "image/gif", + extension: "gif", + }, + ImageType { + content_type: "image/jpeg", + extension: "jpg", + }, + ImageType { + content_type: "image/png", + extension: "png", + }, + ImageType { + content_type: "image/svg+xml", + extension: "svg", + }, + ImageType { + content_type: "image/webp", + extension: "webp", + }, +]; + +#[derive(Clone)] +pub struct CachedImage { + pub bytes: Bytes, + pub content_type: &'static str, +} + +#[derive(Clone, Copy)] +struct ImageType { + content_type: &'static str, + extension: &'static str, +} + +pub struct ImageCache { + cache_dir: PathBuf, +} + +impl ImageCache { + pub fn new(cache_dir: PathBuf) -> Self { + Self { cache_dir } + } + + pub async fn get(&self, key: &str) -> Result, KoiError> { + validate_cache_key(key)?; + self.read(key).await + } + + pub async fn store(&self, raw_url: &str) -> Result { + let url = validate_url(raw_url)?; + let key = cache_key(url.as_str()); + + if self.read(&key).await?.is_none() { + self.fetch_and_store(&url, &key).await?; + } + + Ok(key) + } + + pub async fn store_reference(&self, reference: &str) -> Result { + if reference.starts_with("data:image/") { + return Ok(reference.to_string()); + } + + if let Some(key) = reference.strip_prefix("/api/cache/image?id=") { + validate_cache_key(key)?; + return Ok(key.to_string()); + } + + if validate_cache_key(reference).is_ok() { + return Ok(reference.to_string()); + } + + self.store(reference).await + } + + async fn read(&self, key: &str) -> Result, KoiError> { + for image_type in IMAGE_TYPES { + let path = self.path(key, *image_type); + let bytes = match fs::read(&path).await { + Ok(bytes) => bytes, + Err(err) if err.kind() == ErrorKind::NotFound => continue, + Err(err) => { + return Err(KoiError::Internal(format!( + "failed to read image cache file {}: {err}", + path.display() + ))); + } + }; + + if bytes.len() as u64 <= MAX_IMAGE_BYTES { + return Ok(Some(CachedImage { + bytes: Bytes::from(bytes), + content_type: image_type.content_type, + })); + } + } + + Ok(None) + } + + async fn fetch_and_store(&self, url: &Url, key: &str) -> Result<(), KoiError> { + let client = Client::builder() + .redirect(Policy::none()) + .timeout(Duration::from_secs(10)) + .user_agent(USER_AGENT) + .build() + .map_err(|err| KoiError::Internal(format!("failed to create image client: {err}")))?; + + let response = client + .get(url.clone()) + .send() + .await + .map_err(|err| KoiError::Internal(format!("failed to fetch image: {err}")))?; + + if !response.status().is_success() { + return Err(KoiError::Internal(format!( + "image fetch failed with status {}", + response.status() + ))); + } + + if response.content_length().unwrap_or(0) > MAX_IMAGE_BYTES { + return Err(KoiError::Internal("image is too large".to_string())); + } + + let image_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(image_type_from_content_type) + .ok_or_else(|| KoiError::Internal("response is not a supported image".to_string()))?; + + let bytes = response + .bytes() + .await + .map_err(|err| KoiError::Internal(format!("failed to read image body: {err}")))?; + + if bytes.len() as u64 > MAX_IMAGE_BYTES { + return Err(KoiError::Internal("image is too large".to_string())); + } + + write_atomic(&self.path(key, image_type), &bytes).await?; + + Ok(()) + } + + fn path(&self, key: &str, image_type: ImageType) -> PathBuf { + self.cache_dir + .join(format!("{key}.{}", image_type.extension)) + } +} + +fn validate_url(raw_url: &str) -> Result { + let url = Url::parse(raw_url) + .map_err(|err| KoiError::Internal(format!("invalid image URL: {err}")))?; + + match url.scheme() { + "http" | "https" => {} + _ => { + return Err(KoiError::Internal( + "image URL must use http or https".to_string(), + )); + } + } + + url.host_str() + .ok_or_else(|| KoiError::Internal("image URL has no host".to_string()))?; + + Ok(url) +} + +fn image_type_from_content_type(value: &str) -> Option { + let value = value + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + IMAGE_TYPES + .iter() + .copied() + .find(|image_type| image_type.content_type == value) +} + +fn cache_key(url: &str) -> String { + hex::encode(Sha256::digest(url.as_bytes())) +} + +fn validate_cache_key(key: &str) -> Result<(), KoiError> { + if key.len() == 64 && key.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Ok(()); + } + + Err(KoiError::Internal("invalid image cache id".to_string())) +} + +async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), KoiError> { + let parent = path.parent().ok_or_else(|| { + KoiError::Internal(format!( + "image cache path has no parent: {}", + path.display() + )) + })?; + fs::create_dir_all(parent).await.map_err(|err| { + KoiError::Internal(format!( + "failed to create image cache directory {}: {err}", + parent.display() + )) + })?; + + let temp_path = temp_path(parent, path)?; + fs::write(&temp_path, bytes).await.map_err(|err| { + KoiError::Internal(format!( + "failed to write image cache file {}: {err}", + temp_path.display() + )) + })?; + fs::rename(&temp_path, path).await.map_err(|err| { + KoiError::Internal(format!( + "failed to move image cache file {} to {}: {err}", + temp_path.display(), + path.display() + )) + })?; + + Ok(()) +} + +fn temp_path(parent: &Path, path: &Path) -> Result { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + KoiError::Internal(format!( + "image cache path has no file name: {}", + path.display() + )) + })?; + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + + Ok(parent.join(format!(".{file_name}.{}.{}.tmp", std::process::id(), nonce))) +} + +#[cfg(test)] +mod tests { + use super::{cache_key, validate_cache_key}; + + #[test] + fn cache_key_is_sha256_hex() { + assert_eq!( + cache_key("https://example.com/icon.png"), + "4d2b6c4e8c53f5b51640c4e08a08b625b7437e866ee270599e3dcb61eedc028e" + ); + } + + #[test] + fn cache_key_validation_rejects_paths_and_urls() { + assert!(validate_cache_key(&"a".repeat(64)).is_ok()); + assert!(validate_cache_key("../image").is_err()); + assert!(validate_cache_key("https://example.com/icon.png").is_err()); + } +} diff --git a/crates/app/src/models/mod.rs b/crates/app/src/models/mod.rs index c37cb67..91e17f8 100644 --- a/crates/app/src/models/mod.rs +++ b/crates/app/src/models/mod.rs @@ -2,6 +2,7 @@ pub mod abi; pub mod account; pub mod alloy; pub mod asset; +pub mod image_cache; pub mod network; pub mod quoter; pub mod tx; diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index dc5a83f..7b28819 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -8,7 +8,7 @@ use crate::{ db::{SkipMigrations, connect}, error::KoiError, models::{ - abi::AbiManager, account::balance_cache::BalanceCacheManager, + abi::AbiManager, account::balance_cache::BalanceCacheManager, image_cache::ImageCache, network::manager::NetworkManager, quoter::man::QuoterManager, vendor::man::VendorManager, }, }; @@ -25,6 +25,7 @@ pub struct State { pub balances: BalanceCacheManager, pub vendors: VendorManager, pub abis: AbiManager, + pub images: ImageCache, } impl State { @@ -39,6 +40,7 @@ impl State { let quoters = QuoterManager::init(&database).await?; let balances = BalanceCacheManager::new(); let abis = AbiManager::new(config.abi_cache_dir.clone().into()); + let images = ImageCache::new(config.image_cache_dir.clone().into()); Ok(Arc::new(State { networks, @@ -48,6 +50,7 @@ impl State { abis, database, config, + images, })) } diff --git a/crates/web/src/components/account/tx/origin.tsx b/crates/web/src/components/account/tx/origin.tsx index b444091..a708c40 100644 --- a/crates/web/src/components/account/tx/origin.tsx +++ b/crates/web/src/components/account/tx/origin.tsx @@ -1,5 +1,6 @@ import { Component, createMemo, Match, Show, Switch } from "solid-js"; +import { CachedImage } from "#/utils/image-cache"; import { narrow } from "#/utils/narrow"; import { TxField } from "./value"; @@ -71,7 +72,7 @@ export const TxOrigin: Component<{ origin: string; }> = (props) => { {known => (
- {known().name} +
{known().name}
diff --git a/crates/web/src/components/asset/add.tsx b/crates/web/src/components/asset/add.tsx index 13cc625..797ac91 100644 --- a/crates/web/src/components/asset/add.tsx +++ b/crates/web/src/components/asset/add.tsx @@ -3,6 +3,7 @@ import { Component, createMemo, createSignal, For, JSX, Show } from "solid-js"; import { match } from "ts-pattern"; import { Asset, useAssetMetadataDiscovery, useCreateAsset } from "#/api/asset"; +import { CachedImage } from "#/utils/image-cache"; import { Modal } from "../dialog"; import { AddressInput } from "../input/address"; @@ -266,7 +267,7 @@ const AssetAddInner: Component = (props) => { {([iconUrl, source]) => (
  • diff --git a/crates/web/src/components/asset/icon.tsx b/crates/web/src/components/asset/icon.tsx index b9213f8..7bdff2e 100644 --- a/crates/web/src/components/asset/icon.tsx +++ b/crates/web/src/components/asset/icon.tsx @@ -1,6 +1,7 @@ import { Component, createMemo, Show } from "solid-js"; import { Asset, useAsset } from "#/api/asset"; +import { CachedImage } from "#/utils/image-cache"; type AssetIconProps = { asset: Asset; class?: string; } | { asset_identity: string; class?: string; }; @@ -24,7 +25,7 @@ export const AssetIconImage: Component<{ asset?: Asset; class?: string; }> = pro )} > {icon => ( - {props.asset?.asset_name} { const networksQuery = useNetworks(); @@ -21,7 +22,7 @@ export const NetworkWidget = () => {
  • diff --git a/crates/web/src/components/net/add.tsx b/crates/web/src/components/net/add.tsx index 5e35f90..4cba0c0 100644 --- a/crates/web/src/components/net/add.tsx +++ b/crates/web/src/components/net/add.tsx @@ -4,6 +4,7 @@ import { createMemo, createSignal, For, Show, Suspense } from "solid-js"; import { Network, useCreateNetwork, useNetworkPresets, useNetworks } from "#/api/network"; import { button } from "#/components/input/button"; +import { CachedImage } from "#/utils/image-cache"; import { NetworkIconSuggestions } from "./discovery"; @@ -76,7 +77,7 @@ export const NetworkAdd = () => {
    - {icon => {name()} + {icon => }
    {
  • diff --git a/crates/web/src/components/net/edit.tsx b/crates/web/src/components/net/edit.tsx index 2435593..70600a8 100644 --- a/crates/web/src/components/net/edit.tsx +++ b/crates/web/src/components/net/edit.tsx @@ -3,6 +3,7 @@ import { Component, createMemo, For, Show, Suspense } from "solid-js"; import { useNetwork, useNetworkEndpoints } from "#/api/network"; import { button } from "#/components/input/button"; +import { CachedImage } from "#/utils/image-cache"; import { NetworkDelete } from "./delete"; import { NetworkEndpointAdd } from "./endpoint/add"; @@ -65,7 +66,7 @@ export const NetworkEdit: Component<{ network_identity: number; embedded?: boole
    - {icon => {network()?.network_name}} + {icon => }
    {network()?.network_name} @@ -119,7 +120,7 @@ export const NetworkEdit: Component<{ network_identity: number; embedded?: boole
    - {icon => {network()?.network_name}} + {icon => }
    = ({ network_identity }) => { const networkQuery = useNetwork(() => ({ path: { network_identity } })); return ( - {icon => {networkQuery.data?.network_name}} + {icon => } ); }; diff --git a/crates/web/src/utils/image-cache.tsx b/crates/web/src/utils/image-cache.tsx new file mode 100644 index 0000000..94557bd --- /dev/null +++ b/crates/web/src/utils/image-cache.tsx @@ -0,0 +1,49 @@ +import { Component, createResource, JSX, Show, splitProps } from "solid-js"; + +const CACHE_IMAGE_PATH = "/api/cache/image"; +const CACHE_IMAGE_ID = /^[\dA-Fa-f]{64}$/; + +const resolveImageUrl = async (url: string) => { + if (CACHE_IMAGE_ID.test(url)) { + return `${CACHE_IMAGE_PATH}?id=${url}`; + } + + const parsed = new URL(url, globalThis.location.origin); + + if (parsed.origin === globalThis.location.origin && parsed.pathname === CACHE_IMAGE_PATH) { + return url; + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return url; + } + + const response = await fetch(`${CACHE_IMAGE_PATH}?url=${encodeURIComponent(url)}`, { method: "POST" }); + + if (!response.ok) { + throw new Error(`Failed to cache image: ${response.status}`); + } + + const location = response.headers.get("Location"); + + if (!location) { + throw new Error("Image cache response has no Location header"); + } + + return location; +}; + +type CachedImageProps = Omit, "src"> & { + src: string; +}; + +export const CachedImage: Component = (props) => { + const [local, imageProps] = splitProps(props, ["src"]); + const [source] = createResource(() => local.src, resolveImageUrl); + + return ( + + {src => } + + ); +};