From a0bde1803d2226df34d6ed9911798bb0fe7825dc Mon Sep 17 00:00:00 2001 From: Jonatan Holmgren Date: Sat, 4 Jul 2026 21:32:26 +0200 Subject: [PATCH 1/3] Use the image cache in the frontend --- crates/web/src/components/account/tx/origin.tsx | 3 ++- crates/web/src/components/asset/add.tsx | 3 ++- crates/web/src/components/asset/icon.tsx | 3 ++- crates/web/src/components/navbar/networks.tsx | 3 ++- crates/web/src/components/net/add.tsx | 5 +++-- crates/web/src/components/net/discovery.tsx | 3 ++- crates/web/src/components/net/edit.tsx | 5 +++-- crates/web/src/components/net/icon.tsx | 3 ++- crates/web/src/utils/image-cache.ts | 17 +++++++++++++++++ 9 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 crates/web/src/utils/image-cache.ts diff --git a/crates/web/src/components/account/tx/origin.tsx b/crates/web/src/components/account/tx/origin.tsx index b444091..5336318 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 { cacheImageUrl } 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}
{known().name}
diff --git a/crates/web/src/components/asset/add.tsx b/crates/web/src/components/asset/add.tsx index 13cc625..91a1235 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 { cacheImageUrl } 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..a67f08b 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 { cacheImageUrl } from "#/utils/image-cache"; type AssetIconProps = { asset: Asset; class?: string; } | { asset_identity: string; class?: string; }; @@ -25,7 +26,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..9f829ba 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 { cacheImageUrl } from "#/utils/image-cache"; import { NetworkIconSuggestions } from "./discovery"; @@ -76,7 +77,7 @@ export const NetworkAdd = () => {
    - {icon => {name()} + {icon => {name()}
    {
  • diff --git a/crates/web/src/components/net/edit.tsx b/crates/web/src/components/net/edit.tsx index 2435593..d90e410 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 { cacheImageUrl } 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}}
    {network()?.network_name} @@ -119,7 +120,7 @@ export const NetworkEdit: Component<{ network_identity: number; embedded?: boole
    - {icon => {network()?.network_name}} + {icon => {network()?.network_name}}
    = ({ network_identity }) => { const networkQuery = useNetwork(() => ({ path: { network_identity } })); return ( - {icon => {networkQuery.data?.network_name}} + {icon => {networkQuery.data?.network_name}} ); }; diff --git a/crates/web/src/utils/image-cache.ts b/crates/web/src/utils/image-cache.ts new file mode 100644 index 0000000..d0c63c8 --- /dev/null +++ b/crates/web/src/utils/image-cache.ts @@ -0,0 +1,17 @@ +const CACHE_IMAGE_PATH = "/api/cache/image"; + +export const cacheImageUrl = (url: string | undefined | null) => { + if (!url) { + return undefined; + } + + const parsed = new URL(url, globalThis.location.origin); + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return url; + } + + return `${CACHE_IMAGE_PATH}?url=${ + encodeURIComponent(url) + }`; +}; From 49c31eaaf58a0c45a97f06a666351f5edc084203 Mon Sep 17 00:00:00 2001 From: Jonatan Holmgren Date: Sat, 4 Jul 2026 22:27:11 +0200 Subject: [PATCH 2/3] Add image cache and HTTP endpoint for fetching remote images --- Cargo.lock | 1 + crates/app/Cargo.toml | 1 + crates/app/src/config.rs | 21 +++ crates/app/src/http/cache.rs | 38 ++++ crates/app/src/http/mod.rs | 4 + crates/app/src/models/image_cache.rs | 273 +++++++++++++++++++++++++++ crates/app/src/models/mod.rs | 1 + crates/app/src/state.rs | 5 +- 8 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 crates/app/src/http/cache.rs create mode 100644 crates/app/src/models/image_cache.rs 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/cache.rs b/crates/app/src/http/cache.rs new file mode 100644 index 0000000..9a1af01 --- /dev/null +++ b/crates/app/src/http/cache.rs @@ -0,0 +1,38 @@ +use poem::{Result, http::header, web::Data}; +use poem_openapi::{ + OpenApi, + param::Query, + payload::{Binary, Response}, +}; + +use crate::{http::ApiTags, state::AppState}; + +pub struct CacheApi; + +pub fn api() -> impl OpenApi { + CacheApi +} + +#[OpenApi] +impl CacheApi { + /// Fetch and cache a remote image + /// + /// GET /api/cache/image?url=... + #[oai(path = "/cache/image", method = "get", tag = "ApiTags::Cache")] + async fn cached_image( + &self, + state: Data<&AppState>, + url: Query, + ) -> Result>>> { + let image = state.images.get(&url).await?; + + Ok(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/models/image_cache.rs b/crates/app/src/models/image_cache.rs new file mode 100644 index 0000000..52d4072 --- /dev/null +++ b/crates/app/src/models/image_cache.rs @@ -0,0 +1,273 @@ +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, raw_url: &str) -> Result { + let url = validate_url(raw_url)?; + let key = cache_key(url.as_str()); + + if let Some(image) = self.read(&key).await? { + return Ok(image); + } + + self.fetch(&url, &key).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(&self, url: &Url, key: &str) -> Result { + 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(CachedImage { + bytes, + content_type: image_type.content_type, + }) + } + + 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())) +} + +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::{ImageCache, cache_key, image_type_from_content_type}; + + #[test] + fn cache_key_is_sha256_hex() { + assert_eq!( + cache_key("https://example.com/icon.png"), + "4d2b6c4e8c53f5b51640c4e08a08b625b7437e866ee270599e3dcb61eedc028e" + ); + } + + #[test] + fn cache_path_uses_image_extension() { + let cache = ImageCache::new("/tmp/koi-image-cache-test".into()); + let image_type = image_type_from_content_type("image/png").unwrap(); + let path = cache.path("abc123", image_type); + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some("abc123.png") + ); + } + + #[test] + fn content_type_matching_is_strict() { + assert_eq!( + image_type_from_content_type("image/png; charset=utf-8").map(|t| t.extension), + Some("png") + ); + assert!(image_type_from_content_type("text/html").is_none()); + } +} 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, })) } From d853399685939731a0b91acff4698f7706899de1 Mon Sep 17 00:00:00 2001 From: Jonatan Holmgren Date: Mon, 13 Jul 2026 16:04:02 +0200 Subject: [PATCH 3/3] Require a POST request to store new cached images --- crates/app/src/http/asset/mod.rs | 16 ++++- crates/app/src/http/cache.rs | 64 +++++++++++++---- crates/app/src/http/net/mod.rs | 16 ++++- crates/app/src/models/image_cache.rs | 68 +++++++++++-------- .../web/src/components/account/tx/origin.tsx | 4 +- crates/web/src/components/asset/add.tsx | 4 +- crates/web/src/components/asset/icon.tsx | 6 +- crates/web/src/components/navbar/networks.tsx | 4 +- crates/web/src/components/net/add.tsx | 6 +- crates/web/src/components/net/discovery.tsx | 4 +- crates/web/src/components/net/edit.tsx | 6 +- crates/web/src/components/net/icon.tsx | 4 +- crates/web/src/utils/image-cache.ts | 17 ----- crates/web/src/utils/image-cache.tsx | 49 +++++++++++++ 14 files changed, 186 insertions(+), 82 deletions(-) delete mode 100644 crates/web/src/utils/image-cache.ts create mode 100644 crates/web/src/utils/image-cache.tsx 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 index 9a1af01..0f3226c 100644 --- a/crates/app/src/http/cache.rs +++ b/crates/app/src/http/cache.rs @@ -1,6 +1,10 @@ -use poem::{Result, http::header, web::Data}; +use poem::{ + Error, Result, + http::{StatusCode, header}, + web::Data, +}; use poem_openapi::{ - OpenApi, + ApiResponse, OpenApi, param::Query, payload::{Binary, Response}, }; @@ -9,30 +13,60 @@ 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 { - /// Fetch and cache a remote image + /// Get a cached image /// - /// GET /api/cache/image?url=... + /// GET /api/cache/image?id=... #[oai(path = "/cache/image", method = "get", tag = "ApiTags::Cache")] async fn cached_image( &self, state: Data<&AppState>, - url: Query, + id: Query, ) -> Result>>> { - let image = state.images.get(&url).await?; - - Ok(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:", - )) + 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/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 index 52d4072..191bcd0 100644 --- a/crates/app/src/models/image_cache.rs +++ b/crates/app/src/models/image_cache.rs @@ -62,15 +62,37 @@ impl ImageCache { Self { cache_dir } } - pub async fn get(&self, raw_url: &str) -> Result { + 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 let Some(image) = self.read(&key).await? { - return Ok(image); + if self.read(&key).await?.is_none() { + self.fetch_and_store(&url, &key).await?; } - self.fetch(&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> { @@ -98,7 +120,7 @@ impl ImageCache { Ok(None) } - async fn fetch(&self, url: &Url, key: &str) -> Result { + async fn fetch_and_store(&self, url: &Url, key: &str) -> Result<(), KoiError> { let client = Client::builder() .redirect(Policy::none()) .timeout(Duration::from_secs(10)) @@ -141,10 +163,7 @@ impl ImageCache { write_atomic(&self.path(key, image_type), &bytes).await?; - Ok(CachedImage { - bytes, - content_type: image_type.content_type, - }) + Ok(()) } fn path(&self, key: &str, image_type: ImageType) -> PathBuf { @@ -189,6 +208,14 @@ 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!( @@ -241,7 +268,7 @@ fn temp_path(parent: &Path, path: &Path) -> Result { #[cfg(test)] mod tests { - use super::{ImageCache, cache_key, image_type_from_content_type}; + use super::{cache_key, validate_cache_key}; #[test] fn cache_key_is_sha256_hex() { @@ -252,22 +279,9 @@ mod tests { } #[test] - fn cache_path_uses_image_extension() { - let cache = ImageCache::new("/tmp/koi-image-cache-test".into()); - let image_type = image_type_from_content_type("image/png").unwrap(); - let path = cache.path("abc123", image_type); - assert_eq!( - path.file_name().and_then(|name| name.to_str()), - Some("abc123.png") - ); - } - - #[test] - fn content_type_matching_is_strict() { - assert_eq!( - image_type_from_content_type("image/png; charset=utf-8").map(|t| t.extension), - Some("png") - ); - assert!(image_type_from_content_type("text/html").is_none()); + 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/web/src/components/account/tx/origin.tsx b/crates/web/src/components/account/tx/origin.tsx index 5336318..a708c40 100644 --- a/crates/web/src/components/account/tx/origin.tsx +++ b/crates/web/src/components/account/tx/origin.tsx @@ -1,6 +1,6 @@ import { Component, createMemo, Match, Show, Switch } from "solid-js"; -import { cacheImageUrl } from "#/utils/image-cache"; +import { CachedImage } from "#/utils/image-cache"; import { narrow } from "#/utils/narrow"; import { TxField } from "./value"; @@ -72,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 91a1235..797ac91 100644 --- a/crates/web/src/components/asset/add.tsx +++ b/crates/web/src/components/asset/add.tsx @@ -3,7 +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 { cacheImageUrl } from "#/utils/image-cache"; +import { CachedImage } from "#/utils/image-cache"; import { Modal } from "../dialog"; import { AddressInput } from "../input/address"; @@ -267,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 a67f08b..7bdff2e 100644 --- a/crates/web/src/components/asset/icon.tsx +++ b/crates/web/src/components/asset/icon.tsx @@ -1,7 +1,7 @@ import { Component, createMemo, Show } from "solid-js"; import { Asset, useAsset } from "#/api/asset"; -import { cacheImageUrl } from "#/utils/image-cache"; +import { CachedImage } from "#/utils/image-cache"; type AssetIconProps = { asset: Asset; class?: string; } | { asset_identity: string; class?: string; }; @@ -25,8 +25,8 @@ export const AssetIconImage: Component<{ asset?: Asset; class?: string; }> = pro )} > {icon => ( - {props.asset?.asset_name} { const networksQuery = useNetworks(); @@ -22,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 9f829ba..4cba0c0 100644 --- a/crates/web/src/components/net/add.tsx +++ b/crates/web/src/components/net/add.tsx @@ -4,7 +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 { cacheImageUrl } from "#/utils/image-cache"; +import { CachedImage } from "#/utils/image-cache"; import { NetworkIconSuggestions } from "./discovery"; @@ -77,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 d90e410..70600a8 100644 --- a/crates/web/src/components/net/edit.tsx +++ b/crates/web/src/components/net/edit.tsx @@ -3,7 +3,7 @@ import { Component, createMemo, For, Show, Suspense } from "solid-js"; import { useNetwork, useNetworkEndpoints } from "#/api/network"; import { button } from "#/components/input/button"; -import { cacheImageUrl } from "#/utils/image-cache"; +import { CachedImage } from "#/utils/image-cache"; import { NetworkDelete } from "./delete"; import { NetworkEndpointAdd } from "./endpoint/add"; @@ -66,7 +66,7 @@ export const NetworkEdit: Component<{ network_identity: number; embedded?: boole
    - {icon => {network()?.network_name}} + {icon => }
    {network()?.network_name} @@ -120,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.ts b/crates/web/src/utils/image-cache.ts deleted file mode 100644 index d0c63c8..0000000 --- a/crates/web/src/utils/image-cache.ts +++ /dev/null @@ -1,17 +0,0 @@ -const CACHE_IMAGE_PATH = "/api/cache/image"; - -export const cacheImageUrl = (url: string | undefined | null) => { - if (!url) { - return undefined; - } - - const parsed = new URL(url, globalThis.location.origin); - - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return url; - } - - return `${CACHE_IMAGE_PATH}?url=${ - encodeURIComponent(url) - }`; -}; 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 => } + + ); +};