forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
5.1 KiB
1//! Raw-origin route `/-/snippets/{id}/{file}`. The literal `-` keeps it apart from repo raw paths,
2//! as it does for media. Snippet files are served as text under the same sandbox headers as raw
3//! files. Public and unlisted snippets are open. A private snippet needs a signed `?token=`,
4//! signed over the snippet id by the same `sign_raw` that guards private repo files.
5
6use forge_core::raw_token::{sign_raw, verify_raw};
7use forge_core::store::snippets::{Snippet, SnippetVisibility};
8use serde::Deserialize;
9use sha2::{Digest, Sha256};
10use topcoat::context::{Cx, app_context};
11use topcoat::router::request::{headers, method};
12use topcoat::router::response::Response;
13use topcoat::router::{
14 Body, Method, RouteFuture, StatusCode, header, parse_query_params, path_param_segment,
15 path_param_segments,
16};
17
18use crate::raw::{
19 CACHE_NONE, CACHE_PRIVATE, CACHE_PUBLIC, LINK_TTL_SECS, Meta, encode_path, finish, now_unix,
20 plain,
21};
22use crate::state::AppState;
23
24const TEXT: &str = "text/plain; charset=utf-8";
25const MARKDOWN: &str = "text/markdown; charset=utf-8";
26const JSON: &str = "application/json; charset=utf-8";
27
28#[derive(Deserialize)]
29struct TokenQuery {
30 token: Option<String>,
31}
32
33/// Absolute URL of one file of a snippet on the user-content origin. A private snippet's URL
34/// carries a signed `?token=` valid for the same 10 minutes as private raw URLs.
35pub fn snippet_raw_url(state: &AppState, snippet: &Snippet, file: &str) -> String {
36 let base = state.config.user_content_base_url.trim_end_matches('/');
37 let mut url = format!(
38 "{base}/-/snippets/{}/{}",
39 encode_path(&snippet.id),
40 encode_path(file)
41 );
42 if snippet.visibility == SnippetVisibility::Private {
43 let expires = now_unix().saturating_add(LINK_TTL_SECS);
44 url.push_str("?token=");
45 url.push_str(&sign_raw(&state.key, &snippet.id, expires));
46 }
47 url
48}
49
50pub(crate) fn snippet_file(cx: &Cx, _body: Body) -> RouteFuture<'_> {
51 Box::pin(async move { Ok(serve(cx).await) })
52}
53
54/// Content type for a snippet file, chosen from its extension. Everything is text.
55fn content_type(file_name: &str) -> &'static str {
56 match file_name
57 .rsplit_once('.')
58 .map(|(_, ext)| ext.to_ascii_lowercase())
59 .as_deref()
60 {
61 Some("md" | "markdown") => MARKDOWN,
62 Some("json") => JSON,
63 _ => TEXT,
64 }
65}
66
67/// Serves one file. Every failure is turned into a response here, so the route never errors.
68async fn serve(cx: &Cx) -> Response {
69 let state = app_context::<AppState>(cx).clone();
70 let id = path_param_segment(cx, "id").to_owned();
71 let segments: Vec<&str> = path_param_segments(cx, "rest").collect();
72 let [name] = segments.as_slice() else {
73 return plain(StatusCode::NOT_FOUND, CACHE_NONE, None);
74 };
75 let name = (*name).to_owned();
76 let is_head = method(cx) == Method::HEAD;
77 let token = parse_query_params::<TokenQuery>(cx)
78 .ok()
79 .and_then(|query| query.token);
80 let if_none_match = headers(cx)
81 .get(header::IF_NONE_MATCH)
82 .and_then(|v| v.to_str().ok())
83 .map(str::to_owned);
84
85 let snippet = match state.store.get_snippet(&id).await {
86 Ok(Some(snippet)) => snippet,
87 // A missing snippet and a private one without a valid token look the same.
88 Ok(None) => return plain(StatusCode::NOT_FOUND, CACHE_NONE, None),
89 Err(e) => {
90 tracing::error!(error = %e, "snippet raw: lookup failed");
91 return plain(StatusCode::INTERNAL_SERVER_ERROR, CACHE_NONE, None);
92 }
93 };
94 let cache = match snippet.visibility {
95 SnippetVisibility::Public => CACHE_PUBLIC,
96 SnippetVisibility::Unlisted => CACHE_PRIVATE,
97 SnippetVisibility::Private => {
98 let valid = token
99 .as_deref()
100 .is_some_and(|t| verify_raw(&state.key, t, &snippet.id, now_unix()));
101 if !valid {
102 return plain(StatusCode::NOT_FOUND, CACHE_NONE, None);
103 }
104 CACHE_PRIVATE
105 }
106 };
107
108 let Some(file) = snippet.file(&name) else {
109 return plain(StatusCode::NOT_FOUND, CACHE_NONE, None);
110 };
111 let etag = hex::encode(Sha256::digest(file.content.as_bytes()));
112 let quoted = format!("\"{etag}\"");
113 if if_none_match.is_some_and(|value| {
114 value
115 .split(',')
116 .map(str::trim)
117 .any(|c| c == "*" || c.strip_prefix("W/").unwrap_or(c) == quoted)
118 }) {
119 return finish(StatusCode::NOT_MODIFIED, Body::empty(), &etag, cache, None);
120 }
121
122 let meta = Meta {
123 content_type: content_type(&name),
124 attachment: false,
125 len: file.content.len() as u64,
126 };
127 let body = if is_head {
128 Body::empty()
129 } else {
130 Body::from(bytes::Bytes::from(file.content.clone().into_bytes()))
131 };
132 finish(StatusCode::OK, body, &etag, cache, Some(meta))
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn content_types_follow_extensions() {
141 assert_eq!(content_type("notes.md"), MARKDOWN);
142 assert_eq!(content_type("data.JSON"), JSON);
143 assert_eq!(content_type("main.rs"), TEXT);
144 assert_eq!(content_type("README"), TEXT);
145 }
146}