//! Raw-origin route `/-/snippets/{id}/{file}`. The literal `-` keeps it apart from repo raw paths, //! as it does for media. Snippet files are served as text under the same sandbox headers as raw //! files. Public and unlisted snippets are open. A private snippet needs a signed `?token=`, //! signed over the snippet id by the same `sign_raw` that guards private repo files. use forge_core::raw_token::{sign_raw, verify_raw}; use forge_core::store::snippets::{Snippet, SnippetVisibility}; use serde::Deserialize; use sha2::{Digest, Sha256}; use topcoat::context::{Cx, app_context}; use topcoat::router::request::{headers, method}; use topcoat::router::response::Response; use topcoat::router::{ Body, Method, RouteFuture, StatusCode, header, parse_query_params, path_param_segment, path_param_segments, }; use crate::raw::{ CACHE_NONE, CACHE_PRIVATE, CACHE_PUBLIC, LINK_TTL_SECS, Meta, encode_path, finish, now_unix, plain, }; use crate::state::AppState; const TEXT: &str = "text/plain; charset=utf-8"; const MARKDOWN: &str = "text/markdown; charset=utf-8"; const JSON: &str = "application/json; charset=utf-8"; #[derive(Deserialize)] struct TokenQuery { token: Option, } /// Absolute URL of one file of a snippet on the user-content origin. A private snippet's URL /// carries a signed `?token=` valid for the same 10 minutes as private raw URLs. pub fn snippet_raw_url(state: &AppState, snippet: &Snippet, file: &str) -> String { let base = state.config.user_content_base_url.trim_end_matches('/'); let mut url = format!( "{base}/-/snippets/{}/{}", encode_path(&snippet.id), encode_path(file) ); if snippet.visibility == SnippetVisibility::Private { let expires = now_unix().saturating_add(LINK_TTL_SECS); url.push_str("?token="); url.push_str(&sign_raw(&state.key, &snippet.id, expires)); } url } pub(crate) fn snippet_file(cx: &Cx, _body: Body) -> RouteFuture<'_> { Box::pin(async move { Ok(serve(cx).await) }) } /// Content type for a snippet file, chosen from its extension. Everything is text. fn content_type(file_name: &str) -> &'static str { match file_name .rsplit_once('.') .map(|(_, ext)| ext.to_ascii_lowercase()) .as_deref() { Some("md" | "markdown") => MARKDOWN, Some("json") => JSON, _ => TEXT, } } /// Serves one file. Every failure is turned into a response here, so the route never errors. async fn serve(cx: &Cx) -> Response { let state = app_context::(cx).clone(); let id = path_param_segment(cx, "id").to_owned(); let segments: Vec<&str> = path_param_segments(cx, "rest").collect(); let [name] = segments.as_slice() else { return plain(StatusCode::NOT_FOUND, CACHE_NONE, None); }; let name = (*name).to_owned(); let is_head = method(cx) == Method::HEAD; let token = parse_query_params::(cx) .ok() .and_then(|query| query.token); let if_none_match = headers(cx) .get(header::IF_NONE_MATCH) .and_then(|v| v.to_str().ok()) .map(str::to_owned); let snippet = match state.store.get_snippet(&id).await { Ok(Some(snippet)) => snippet, // A missing snippet and a private one without a valid token look the same. Ok(None) => return plain(StatusCode::NOT_FOUND, CACHE_NONE, None), Err(e) => { tracing::error!(error = %e, "snippet raw: lookup failed"); return plain(StatusCode::INTERNAL_SERVER_ERROR, CACHE_NONE, None); } }; let cache = match snippet.visibility { SnippetVisibility::Public => CACHE_PUBLIC, SnippetVisibility::Unlisted => CACHE_PRIVATE, SnippetVisibility::Private => { let valid = token .as_deref() .is_some_and(|t| verify_raw(&state.key, t, &snippet.id, now_unix())); if !valid { return plain(StatusCode::NOT_FOUND, CACHE_NONE, None); } CACHE_PRIVATE } }; let Some(file) = snippet.file(&name) else { return plain(StatusCode::NOT_FOUND, CACHE_NONE, None); }; let etag = hex::encode(Sha256::digest(file.content.as_bytes())); let quoted = format!("\"{etag}\""); if if_none_match.is_some_and(|value| { value .split(',') .map(str::trim) .any(|c| c == "*" || c.strip_prefix("W/").unwrap_or(c) == quoted) }) { return finish(StatusCode::NOT_MODIFIED, Body::empty(), &etag, cache, None); } let meta = Meta { content_type: content_type(&name), attachment: false, len: file.content.len() as u64, }; let body = if is_head { Body::empty() } else { Body::from(bytes::Bytes::from(file.content.clone().into_bytes())) }; finish(StatusCode::OK, body, &etag, cache, Some(meta)) } #[cfg(test)] mod tests { use super::*; #[test] fn content_types_follow_extensions() { assert_eq!(content_type("notes.md"), MARKDOWN); assert_eq!(content_type("data.JSON"), JSON); assert_eq!(content_type("main.rs"), TEXT); assert_eq!(content_type("README"), TEXT); } }