diff --git a/src/config.rs b/src/config.rs index b54552f..a5fbee7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,6 +21,40 @@ pub struct Config { pub prometheus: Prometheus, pub cors: bool, pub keys: Keys, + #[serde(default)] + pub public_spaces: PublicSpacesConfig, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Hash, PartialEq, Eq)] +pub struct PublicSpacesConfig { + #[serde(default = "default_rate_limit_per_minute")] + pub rate_limit_per_minute: u32, + #[serde(default = "default_rate_limit_burst")] + pub rate_limit_burst: u32, + #[serde(default = "default_public_storage_limit")] + pub storage_limit: ByteUnit, +} + +fn default_rate_limit_per_minute() -> u32 { + 60 +} + +fn default_rate_limit_burst() -> u32 { + 10 +} + +fn default_public_storage_limit() -> ByteUnit { + ByteUnit::Mebibyte(10) +} + +impl Default for PublicSpacesConfig { + fn default() -> Self { + Self { + rate_limit_per_minute: default_rate_limit_per_minute(), + rate_limit_burst: default_rate_limit_burst(), + storage_limit: default_public_storage_limit(), + } + } } #[derive(Serialize, Deserialize, Debug, Clone, Hash, PartialEq, Eq)] diff --git a/src/lib.rs b/src/lib.rs index aa81528..e8f21b3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,7 +18,12 @@ pub mod storage; mod tracing; use config::{BlockStorage, Config, Keys, StagingStorage}; -use routes::{delegate, invoke, open_host_key, util_routes::*, version}; +use routes::{ + delegate, invoke, open_host_key, + public::{public_kv_get, public_kv_head, public_kv_list, public_kv_options, RateLimiter}, + util_routes::*, + version, +}; use storage::{ file_system::{FileSystemConfig, FileSystemStore, TempFileSystemStage}, s3::{S3BlockConfig, S3BlockStore}, @@ -78,7 +83,18 @@ pub async fn app(config: &Figment) -> Result> { tracing::tracing_try_init(&tinycloud_config.log)?; - let routes = routes![healthcheck, cors, version, open_host_key, invoke, delegate,]; + let routes = routes![ + healthcheck, + cors, + version, + open_host_key, + invoke, + delegate, + public_kv_get, + public_kv_head, + public_kv_list, + public_kv_options, + ]; let key_setup: StaticSecret = match tinycloud_config.keys { Keys::Static(s) => s.try_into()?, @@ -99,6 +115,8 @@ pub async fn app(config: &Figment) -> Result> { tinycloud_config.storage.sql.memory_threshold.as_u64(), ); + let rate_limiter = RateLimiter::new(&tinycloud_config.public_spaces); + let rocket = rocket::custom(config) .mount("/", routes) .attach(AdHoc::config::()) @@ -107,6 +125,7 @@ pub async fn app(config: &Figment) -> Result> { }) .manage(tinycloud) .manage(sql_service) + .manage(rate_limiter) .manage(tinycloud_config.storage.staging.open().await?); if tinycloud_config.cors { diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 50cb7e8..efde227 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -10,6 +10,7 @@ use crate::{ auth_guards::{DataIn, DataOut, InvOut, ObjectHeaders}, authorization::AuthHeaderGetter, config::Config, + routes::public::is_public_space, tracing::TracingSpan, BlockStage, BlockStores, TinyCloud, }; @@ -22,6 +23,7 @@ use tinycloud_core::{ InvocationOutcome, TxError, TxStoreError, }; +pub mod public; pub mod util; use util::LimitedReader; @@ -183,7 +185,14 @@ pub async fn invoke( .map_err(|e| (Status::InternalServerError, e.to_string()))?; let open_data = d.open(1u8.gigabytes()).compat(); - if let Some(limit) = config.storage.limit { + // Use public space storage limit if applicable, otherwise regular limit + let effective_limit = if is_public_space(space) { + Some(config.public_spaces.storage_limit) + } else { + config.storage.limit + }; + + if let Some(limit) = effective_limit { let current_size = tinycloud .store_size(space) .await diff --git a/src/routes/public.rs b/src/routes/public.rs new file mode 100644 index 0000000..2fc9d94 --- /dev/null +++ b/src/routes/public.rs @@ -0,0 +1,319 @@ +use rocket::{ + futures::io::AsyncRead, + http::{uri::fmt, Header, Status}, + request::{FromRequest, FromSegments, Outcome, Request}, + response::{Responder, Response}, + serde::json::Json, + State, +}; +use std::{collections::HashMap, net::IpAddr, sync::Mutex, time::Instant}; +use tinycloud_core::storage::{Content, ImmutableReadStore}; +use tinycloud_lib::resource::{Path, SpaceId}; +use tokio_util::compat::FuturesAsyncReadCompatExt; + +use crate::{auth_guards::ObjectHeaders, config::PublicSpacesConfig, BlockStores, TinyCloud}; + +/// A key path that allows dot-prefixed segments like `.well-known/profile`. +/// Unlike `std::path::PathBuf`, this does not reject hidden files/dirs. +pub struct RawKeyPath(pub String); + +impl<'r> FromSegments<'r> for RawKeyPath { + type Error = String; + + fn from_segments( + segments: rocket::http::uri::Segments<'r, fmt::Path>, + ) -> Result { + let joined: String = segments.collect::>().join("/"); + if joined.is_empty() { + Err("Empty key path".to_string()) + } else { + Ok(RawKeyPath(joined)) + } + } +} + +/// Check if a space is a public space based on its name. +pub fn is_public_space(space_id: &SpaceId) -> bool { + space_id.name().as_str() == "public" +} + +// --- Rate Limiter --- + +pub struct RateLimiter { + state: Mutex>, + tokens_per_second: f64, + burst: u32, +} + +struct TokenBucket { + tokens: f64, + last_refill: Instant, +} + +impl RateLimiter { + pub fn new(config: &PublicSpacesConfig) -> Self { + Self { + state: Mutex::new(HashMap::new()), + tokens_per_second: config.rate_limit_per_minute as f64 / 60.0, + burst: config.rate_limit_burst, + } + } + + pub fn check(&self, ip: IpAddr) -> Result<(), Status> { + let mut state = self.state.lock().unwrap(); + let now = Instant::now(); + let max_tokens = self.burst as f64 + self.tokens_per_second; + + let bucket = state.entry(ip).or_insert(TokenBucket { + tokens: max_tokens, + last_refill: now, + }); + + // Refill tokens based on elapsed time + let elapsed = now.duration_since(bucket.last_refill).as_secs_f64(); + bucket.tokens = (bucket.tokens + elapsed * self.tokens_per_second).min(max_tokens); + bucket.last_refill = now; + + if bucket.tokens >= 1.0 { + bucket.tokens -= 1.0; + Ok(()) + } else { + Err(Status::TooManyRequests) + } + } +} + +// --- Request Guards --- + +pub struct ClientIp(pub IpAddr); + +#[async_trait] +impl<'r> FromRequest<'r> for ClientIp { + type Error = (); + async fn from_request(request: &'r Request<'_>) -> Outcome { + match request.client_ip() { + Some(ip) => Outcome::Success(ClientIp(ip)), + None => Outcome::Error((Status::BadRequest, ())), + } + } +} + +pub struct IfNoneMatch(pub String); + +#[async_trait] +impl<'r> FromRequest<'r> for IfNoneMatch { + type Error = (); + async fn from_request(request: &'r Request<'_>) -> Outcome { + match request.headers().get_one("If-None-Match") { + Some(val) => Outcome::Success(IfNoneMatch(val.to_string())), + None => Outcome::Forward(Status::NotFound), + } + } +} + +// --- Response Types --- + +const CACHE_CONTROL: &str = "public, max-age=60"; +const CORS_ORIGIN: &str = "*"; +const CORS_METHODS: &str = "GET, HEAD, OPTIONS"; +const CORS_ALLOW_HEADERS: &str = "If-None-Match"; +const CORS_EXPOSE_HEADERS: &str = "ETag, Content-Type, Content-Length"; + +fn add_public_headers(response: &mut Response<'_>, etag: Option<&str>) { + response.set_header(Header::new("Cache-Control", CACHE_CONTROL)); + response.set_header(Header::new("Access-Control-Allow-Origin", CORS_ORIGIN)); + response.set_header(Header::new("Access-Control-Allow-Methods", CORS_METHODS)); + response.set_header(Header::new( + "Access-Control-Allow-Headers", + CORS_ALLOW_HEADERS, + )); + response.set_header(Header::new( + "Access-Control-Expose-Headers", + CORS_EXPOSE_HEADERS, + )); + if let Some(etag) = etag { + response.set_header(Header::new("ETag", etag.to_string())); + } +} + +pub struct PublicKVResponse(Content, ObjectHeaders, String); + +impl<'r, R> Responder<'r, 'static> for PublicKVResponse +where + R: 'static + AsyncRead + Send, +{ + fn respond_to(self, r: &'r Request<'_>) -> rocket::response::Result<'static> { + let mut response = Response::build_from(self.1.respond_to(r)?) + .streamed_body(self.0.compat()) + .finalize(); + add_public_headers(&mut response, Some(&self.2)); + Ok(response) + } +} + +pub struct NotModifiedResponse(String); + +impl<'r> Responder<'r, 'static> for NotModifiedResponse { + fn respond_to(self, _: &'r Request<'_>) -> rocket::response::Result<'static> { + let mut response = Response::build().status(Status::NotModified).finalize(); + add_public_headers(&mut response, Some(&self.0)); + Ok(response) + } +} + +pub struct PublicMetadataResponse(ObjectHeaders, String); + +impl<'r> Responder<'r, 'static> for PublicMetadataResponse { + fn respond_to(self, r: &'r Request<'_>) -> rocket::response::Result<'static> { + let mut response = self.0.respond_to(r)?; + add_public_headers(&mut response, Some(&self.1)); + Ok(response) + } +} + +pub struct PublicListResponse(Json>); + +impl<'r> Responder<'r, 'static> for PublicListResponse { + fn respond_to(self, r: &'r Request<'_>) -> rocket::response::Result<'static> { + let mut response = self.0.respond_to(r)?; + add_public_headers(&mut response, None); + Ok(response) + } +} + +// --- Routes --- + +#[get("/public//kv/")] +pub async fn public_kv_get( + space_id: &str, + key: RawKeyPath, + if_none_match: Option, + client_ip: ClientIp, + rate_limiter: &State, + tinycloud: &State, +) -> Result< + Result::Readable>, NotModifiedResponse>, + (Status, String), +> { + rate_limiter + .check(client_ip.0) + .map_err(|s| (s, "Rate limit exceeded".to_string()))?; + + let space_id: SpaceId = space_id + .parse() + .map_err(|_| (Status::BadRequest, "Invalid space ID".to_string()))?; + + if !is_public_space(&space_id) { + return Err((Status::Forbidden, "Not a public space".to_string())); + } + + let key: Path = key + .0 + .parse() + .map_err(|_| (Status::BadRequest, "Invalid key".to_string()))?; + + let result = tinycloud + .public_kv_get(&space_id, &key) + .await + .map_err(|e| (Status::InternalServerError, e.to_string()))?; + + match result { + Some((md, hash, content)) => { + let etag = format!("\"blake3-{}\"", hex::encode(hash.as_ref())); + + if let Some(IfNoneMatch(client_etag)) = &if_none_match { + if client_etag == &etag { + return Ok(Err(NotModifiedResponse(etag))); + } + } + + Ok(Ok(PublicKVResponse(content, ObjectHeaders(md), etag))) + } + None => Err((Status::NotFound, "Key not found".to_string())), + } +} + +#[head("/public//kv/")] +pub async fn public_kv_head( + space_id: &str, + key: RawKeyPath, + if_none_match: Option, + client_ip: ClientIp, + rate_limiter: &State, + tinycloud: &State, +) -> Result, (Status, String)> { + rate_limiter + .check(client_ip.0) + .map_err(|s| (s, "Rate limit exceeded".to_string()))?; + + let space_id: SpaceId = space_id + .parse() + .map_err(|_| (Status::BadRequest, "Invalid space ID".to_string()))?; + + if !is_public_space(&space_id) { + return Err((Status::Forbidden, "Not a public space".to_string())); + } + + let key: Path = key + .0 + .parse() + .map_err(|_| (Status::BadRequest, "Invalid key".to_string()))?; + + let result = tinycloud + .public_kv_get(&space_id, &key) + .await + .map_err(|e| (Status::InternalServerError, e.to_string()))?; + + match result { + Some((md, hash, _content)) => { + let etag = format!("\"blake3-{}\"", hex::encode(hash.as_ref())); + + if let Some(IfNoneMatch(client_etag)) = &if_none_match { + if client_etag == &etag { + return Ok(Err(NotModifiedResponse(etag))); + } + } + + Ok(Ok(PublicMetadataResponse(ObjectHeaders(md), etag))) + } + None => Err((Status::NotFound, "Key not found".to_string())), + } +} + +#[get("/public//kv?")] +pub async fn public_kv_list( + space_id: &str, + prefix: Option<&str>, + client_ip: ClientIp, + rate_limiter: &State, + tinycloud: &State, +) -> Result { + rate_limiter + .check(client_ip.0) + .map_err(|s| (s, "Rate limit exceeded".to_string()))?; + + let space_id: SpaceId = space_id + .parse() + .map_err(|_| (Status::BadRequest, "Invalid space ID".to_string()))?; + + if !is_public_space(&space_id) { + return Err((Status::Forbidden, "Not a public space".to_string())); + } + + let prefix_path: Path = prefix + .unwrap_or("") + .parse() + .map_err(|_| (Status::BadRequest, "Invalid prefix".to_string()))?; + + let list = tinycloud + .public_kv_list(&space_id, &prefix_path) + .await + .map_err(|e| (Status::InternalServerError, e.to_string()))?; + + Ok(PublicListResponse(Json(list))) +} + +#[options("/public/<_space_id>/kv/<_key..>")] +pub async fn public_kv_options(_space_id: &str, _key: RawKeyPath) -> Status { + Status::NoContent +} diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 17869f0..6f58b7f 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -144,6 +144,36 @@ where } } +impl SpaceDatabase +where + C: ConnectionTrait, + B: ImmutableReadStore, +{ + pub async fn public_kv_get( + &self, + space_id: &SpaceId, + key: &Path, + ) -> Result)>, EitherError> { + get_kv(&self.conn, &self.storage, space_id, key).await + } + + pub async fn public_kv_metadata( + &self, + space_id: &SpaceId, + key: &Path, + ) -> Result, DbErr> { + metadata(&self.conn, space_id, key).await + } + + pub async fn public_kv_list( + &self, + space_id: &SpaceId, + prefix: &Path, + ) -> Result, DbErr> { + list(&self.conn, space_id, prefix).await + } +} + impl SpaceDatabase where C: TransactionTrait,