//! generated nginx maps and generation-specific config (packet §15). #[cfg(test)] mod tests; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write as _; use std::path::Path; use anyhow::Context as _; use crate::config::ValidatedConfig; use crate::manifest::GenerationManifest; use crate::project::errors::StaticXrpcError; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct LocationClass { pub status: u16, pub content_type: String, pub headers: Vec<(String, String)>, } fn escape_nginx(s: &str) -> String { s.replace('\\', "\\\\") .replace('"', "\\\"") .replace('$', "\\$") } /// invariant: nginx keeps query values encoded; cover literal and form-encoded spellings. fn repo_wire_aliases(aliases: &BTreeMap) -> BTreeMap { aliases .iter() .flat_map(|(param, token)| { let encoded: String = url::form_urlencoded::byte_serialize(param.as_bytes()).collect(); [(param.clone(), token.clone()), (encoded, token.clone())] }) .collect() } /// invariant: Tangled clients encode query values; live knot-xrpc treats absent cursor as offset 0. fn insert_page_wired( map: &mut BTreeMap, token: &str, limit: &str, cursor: &str, resp_path: &str, ) { insert_wired(map, &format!("{token}|{limit}|{cursor}"), resp_path); if cursor == "0" { insert_wired(map, &format!("{token}|{limit}|"), resp_path); } } // invariant: composite map keys join on '|'; escape each segment and rejoin on literal '|'. fn wire_keys(key: &str) -> Vec { if !key.contains('%') && key.contains('|') { let encoded: Vec = key .split('|') .map(|segment| { let mut out = String::with_capacity(segment.len()); for byte in segment.bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(byte as char); } _ => { let _ = write!(out, "%{byte:02X}"); } } } out }) .collect(); let joined = encoded.join("|"); if joined != key { return vec![key.to_string(), joined]; } } vec![key.to_string()] } fn insert_wired(map: &mut BTreeMap, key: &str, value: &str) { for spelling in wire_keys(key) { map.insert(spelling, value.to_string()); } } fn compute_response_path(generation_dir: &Path, body_hash: &str) -> String { let flat = format!("/_responses/{body_hash}"); if body_hash.len() >= 4 && !generation_dir.join("www").join(&flat[1..]).exists() { format!("/_responses/{}/{}", &body_hash[..2], &body_hash[2..]) } else { flat } } fn emit_map_file( cas: &crate::cas::Cas, maps_dir: &Path, filename: &str, source_var: &str, target_var: &str, entries: &BTreeMap, ) -> anyhow::Result<()> { let mut out = format!("map {source_var} {target_var} {{\n default \"\";\n"); for (k, v) in entries { writeln!(out, " \"{}\" \"{}\";", escape_nginx(k), escape_nginx(v))?; } out.push_str("}\n"); let dest = maps_dir.join(filename); cas.put_and_link(out.as_bytes(), &dest) .with_context(|| format!("write map file {}", dest.display()))?; Ok(()) } /// emit generation-specific nginx configuration and maps. /// /// map values and error bodies are generation-independent; only the conf and activation /// wiring carry the generation path. pub fn emit( config: &ValidatedConfig, manifest: &GenerationManifest, generation_dir: &Path, ) -> anyhow::Result<()> { emit_at(config, manifest, generation_dir, generation_dir) } /// build privately while embedding only the final, published serving path. pub fn emit_at( config: &ValidatedConfig, manifest: &GenerationManifest, generation_dir: &Path, served_dir: &Path, ) -> anyhow::Result<()> { let served_dir = if served_dir.exists() { std::fs::canonicalize(served_dir)? } else { let parent = served_dir.parent().context("generation parent")?; std::fs::canonicalize(parent)?.join(served_dir.file_name().context("generation name")?) }; let nginx_dir = generation_dir.join("nginx"); let maps_dir = nginx_dir.join("maps"); let www_dir = generation_dir.join("www"); let errors_dir = www_dir.join("_errors"); // invariant: maps and error bodies are content-stable across generations; writing through CAS dedupes at file granularity. let cas = crate::cas::Cas::open(config.output_root.join("cas")); std::fs::create_dir_all(&maps_dir) .with_context(|| format!("create maps dir: {}", maps_dir.display()))?; std::fs::create_dir_all(&errors_dir) .with_context(|| format!("create errors dir: {}", errors_dir.display()))?; // invariant: runtime writes must not alter the immutable generation inventory. let runtime_dir = config .output_root .join("runtime") .join(served_dir.file_name().context("generation runtime name")?); for name in [ "client_body_temp", "proxy_temp", "fastcgi_temp", "uwsgi_temp", "scgi_temp", ] { std::fs::create_dir_all(runtime_dir.join(name))?; } let runtime_path = std::fs::canonicalize(&runtime_dir)?.display().to_string(); let error_defs = [ ("repo_not_found", StaticXrpcError::repo_not_found()), ("ref_not_found", StaticXrpcError::ref_not_found()), ("path_not_found", StaticXrpcError::path_not_found()), ("file_not_found", StaticXrpcError::file_not_found()), ("branch_not_found", StaticXrpcError::branch_not_found()), ("tag_not_found", StaticXrpcError::tag_not_found()), ( "revision_not_found", StaticXrpcError::revision_not_found("unknown"), ), ( "invalid_request", StaticXrpcError::invalid_request("invalid request"), ), ("disallowed_raw", StaticXrpcError::disallowed_raw_mime()), ( "compare_error", StaticXrpcError::compare_error("comparison is not available from this static knot"), ), ("blob_too_large", StaticXrpcError::blob_too_large()), ( "request_too_large", StaticXrpcError::request_too_large( "request or response exceeds configured maximum size", ), ), ( "method_not_allowed", StaticXrpcError::new(405, "MethodNotAllowed", "method not allowed"), ), ( "internal_error", StaticXrpcError::internal("internal server error"), ), ]; for (name, err) in &error_defs { let err_path = errors_dir.join(format!("{name}.json")); let bytes = serde_json::to_vec_pretty(&err.body())?; cas.put_and_link(&bytes, &err_path) .with_context(|| format!("write error body {}", err_path.display()))?; } let mut classes: Vec = Vec::new(); let mut class_to_id: BTreeMap = BTreeMap::new(); let mut response_class_map: BTreeMap = BTreeMap::new(); let mut describe_repo_map: BTreeMap = BTreeMap::new(); let mut get_default_branch_map: BTreeMap = BTreeMap::new(); let mut branch_map: BTreeMap = BTreeMap::new(); let mut branches_map: BTreeMap = BTreeMap::new(); let mut tag_map: BTreeMap = BTreeMap::new(); let mut tags_map: BTreeMap = BTreeMap::new(); let mut list_refs_map: BTreeMap = BTreeMap::new(); let mut list_repos_map: BTreeMap = BTreeMap::new(); let mut tree_map: BTreeMap = BTreeMap::new(); let mut blob_map: BTreeMap = BTreeMap::new(); let mut languages_map: BTreeMap = BTreeMap::new(); let mut diff_map: BTreeMap = BTreeMap::new(); let mut compare_map: BTreeMap = BTreeMap::new(); let mut log_map: BTreeMap = BTreeMap::new(); let mut archive_map: BTreeMap = BTreeMap::new(); let mut blame_map: BTreeMap = BTreeMap::new(); let mut did_route: Option<(usize, String)> = None; let mut version_route: Option<(usize, String)> = None; let mut owner_route: Option<(usize, String)> = None; let mut health_route: Option<(usize, String)> = None; for (route_key, response) in &manifest.routes { let resp_path = compute_response_path(generation_dir, &response.body_hash); let mut sorted_headers: Vec<(String, String)> = response .headers .iter() .filter(|(k, _)| !k.eq_ignore_ascii_case("content-type")) .cloned() .collect(); sorted_headers.sort(); let loc_class = LocationClass { status: response.status, content_type: response.content_type.clone(), headers: sorted_headers, }; let class_id = *class_to_id.entry(loc_class.clone()).or_insert_with(|| { let id = classes.len(); classes.push(loc_class); id }); response_class_map.insert(resp_path.clone(), class_id.to_string()); let (endpoint, query_str) = route_key .split_once('?') .unwrap_or((route_key.as_str(), "")); let norm_endpoint = endpoint.trim_start_matches('/'); let params: BTreeMap<&str, &str> = query_str .split('&') .filter_map(|pair| pair.split_once('=')) .collect(); let param = |k| params.get(k).copied().unwrap_or(""); // invariant: describeRepo wire param is repoDid; everything else uses repo. let repo_param = params .get("repo") .or_else(|| params.get("repoDid")) .copied() .unwrap_or(""); let repo_token = manifest .repo_aliases .get(repo_param) .map_or(repo_param, String::as_str); let ref_param = param("ref"); // invariant: knot2 wire bodies echo the requested ref; never collapse spellings through ref_aliases. // invariant: nginx map matching is case-insensitive; repo token must prefix key to prevent collisions. let commit_token = format!("{repo_token}|c_{ref_param}"); match norm_endpoint { ".well-known/did.json" | "did.json" => { did_route = Some((class_id, resp_path)); } "xrpc/sh.tangled.knot.version" | "sh.tangled.knot.version" | "version" => { version_route = Some((class_id, resp_path)); } "xrpc/sh.tangled.owner" | "sh.tangled.owner" | "owner" => { owner_route = Some((class_id, resp_path)); } "xrpc/_health" | "_health" | "health" => { health_route = Some((class_id, resp_path)); } "xrpc/sh.tangled.repo.describeRepo" | "sh.tangled.repo.describeRepo" => { describe_repo_map.insert(repo_token.to_string(), resp_path); } "xrpc/sh.tangled.repo.getDefaultBranch" | "sh.tangled.repo.getDefaultBranch" => { get_default_branch_map.insert(repo_token.to_string(), resp_path); } "xrpc/sh.tangled.repo.branch" | "sh.tangled.repo.branch" => { branch_map.insert(format!("{repo_token}|{}", param("name")), resp_path); } "xrpc/sh.tangled.repo.branches" | "sh.tangled.repo.branches" => { insert_page_wired( &mut branches_map, repo_token, param("limit"), param("cursor"), &resp_path, ); } "xrpc/sh.tangled.repo.tag" | "sh.tangled.repo.tag" => { // Invariant: wire param is tag= (not name=) per knot-xrpc TagParams. insert_wired( &mut tag_map, &format!("{repo_token}|{}", param("tag")), &resp_path, ); } "xrpc/sh.tangled.repo.tags" | "sh.tangled.repo.tags" => { insert_page_wired( &mut tags_map, repo_token, param("limit"), param("cursor"), &resp_path, ); } "xrpc/sh.tangled.git.listRefs" | "sh.tangled.git.listRefs" => { insert_page_wired( &mut list_refs_map, repo_token, param("limit"), param("cursor"), &resp_path, ); } "xrpc/sh.tangled.sync.listRepos" | "sh.tangled.sync.listRepos" => { let limit = param("limit"); let cursor = param("cursor"); let order = param("order"); insert_wired( &mut list_repos_map, &format!("{limit}|{cursor}|{order}"), &resp_path, ); if cursor == "0" { insert_wired(&mut list_repos_map, &format!("{limit}|{order}"), &resp_path); } } "xrpc/sh.tangled.repo.tree" | "sh.tangled.repo.tree" => { insert_wired( &mut tree_map, &format!("{commit_token}|{}", param("path")), &resp_path, ); } "xrpc/sh.tangled.repo.blob" | "sh.tangled.repo.blob" => { insert_wired( &mut blob_map, &format!("{commit_token}|{}|{}", param("path"), param("raw")), &resp_path, ); } "xrpc/sh.tangled.repo.languages" | "sh.tangled.repo.languages" => { languages_map.insert(commit_token, resp_path); } "xrpc/sh.tangled.repo.diff" | "sh.tangled.repo.diff" => { diff_map.insert(commit_token, resp_path); } "xrpc/sh.tangled.repo.compare" | "sh.tangled.repo.compare" => { insert_wired( &mut compare_map, &format!("{repo_token}|{}|{}", param("rev1"), param("rev2")), &resp_path, ); } "xrpc/sh.tangled.repo.log" | "sh.tangled.repo.log" => { insert_page_wired( &mut log_map, &commit_token, param("limit"), param("cursor"), &resp_path, ); } "xrpc/sh.tangled.repo.archive" | "sh.tangled.repo.archive" => { archive_map.insert(format!("{commit_token}|{}", param("format")), resp_path); } "xrpc/sh.tangled.repo.blame" | "sh.tangled.repo.blame" => { insert_wired( &mut blame_map, &format!("{commit_token}|{}", param("path")), &resp_path, ); } _ => {} } } emit_map_file( &cas, &maps_dir, "00_repo.conf", "$arg_repo", "$ks_repo", &repo_wire_aliases(&manifest.repo_aliases), )?; // invariant: repoverify/Go clients spell describeRepo with repoDid=; Bobbin spells repo=. let repo_did_entries: BTreeMap = manifest .repo_aliases .iter() .filter(|(param, _)| param.starts_with("did:") && !param.contains('/')) .map(|(param, token)| (param.clone(), token.clone())) .collect(); emit_map_file( &cas, &maps_dir, "00_repo_did.conf", "$arg_repoDid", "$ks_repo_did", &repo_wire_aliases(&repo_did_entries), )?; let mut ref_map_entries: BTreeMap = BTreeMap::new(); for (key, commit_oid) in &manifest.ref_aliases { insert_wired(&mut ref_map_entries, key, &format!("c_{commit_oid}")); } emit_map_file( &cas, &maps_dir, "01_ref.conf", "\"$ks_repo|$arg_ref\"", "$ks_commit", &ref_map_entries, )?; emit_map_file( &cas, &maps_dir, "02_response_class.conf", "$ks_response_path", "$ks_location_class", &response_class_map, )?; // invariant: describe matches either spelling: "$ks_repo|$ks_repo_did" composite. let describe_entries: BTreeMap = describe_repo_map .iter() .flat_map(|(token, resp_path)| { [ (format!("{token}|"), resp_path.clone()), (format!("|{token}"), resp_path.clone()), ] }) .collect(); let endpoint_maps = [ ( "10_describe_repo.conf", "\"$ks_repo|$ks_repo_did\"", "$ks_res_describe_repo", &describe_entries, ), ( "11_get_default_branch.conf", "$ks_repo", "$ks_res_get_default_branch", &get_default_branch_map, ), ( "12_branch.conf", "\"$ks_repo|$arg_name\"", "$ks_res_branch", &branch_map, ), ( "13_branches.conf", "\"$ks_repo|$arg_limit|$arg_cursor\"", "$ks_res_branches", &branches_map, ), ( "14_tag.conf", "\"$ks_repo|$arg_tag\"", "$ks_res_tag", &tag_map, ), ( "15_tags.conf", "\"$ks_repo|$arg_limit|$arg_cursor\"", "$ks_res_tags", &tags_map, ), ( "16_list_refs.conf", "\"$ks_repo|$arg_limit|$arg_cursor\"", "$ks_res_list_refs", &list_refs_map, ), ( "17_list_repos.conf", "\"$arg_limit|$arg_cursor|$arg_order\"", "$ks_res_list_repos", &list_repos_map, ), ( "18_tree.conf", "\"$ks_repo|$ks_commit|$arg_path\"", "$ks_res_tree", &tree_map, ), ( "19_blob.conf", "\"$ks_repo|$ks_commit|$arg_path|$arg_raw\"", "$ks_res_blob", &blob_map, ), ( "20_languages.conf", "\"$ks_repo|$ks_commit\"", "$ks_res_languages", &languages_map, ), ( "21_diff.conf", "\"$ks_repo|$ks_commit\"", "$ks_res_diff", &diff_map, ), ( "22_compare.conf", "\"$ks_repo|$arg_rev1|$arg_rev2\"", "$ks_res_compare", &compare_map, ), ( "23_log.conf", "\"$ks_repo|$ks_commit|$arg_limit|$arg_cursor\"", "$ks_res_log", &log_map, ), ( "24_archive.conf", "\"$ks_repo|$ks_commit|$arg_format\"", "$ks_res_archive", &archive_map, ), ( "25_blame.conf", "\"$ks_repo|$ks_commit|$arg_path\"", "$ks_res_blame", &blame_map, ), ]; for (filename, source_var, target_var, map) in endpoint_maps { emit_map_file(&cas, &maps_dir, filename, source_var, target_var, map)?; } let gen_path_str = served_dir.display().to_string(); let mut conf = String::new(); // invariant: pid file lives in active.conf at a generation-independent path so reloads signal the running master. write!( conf, "error_log {runtime_path}/error.log;\n\nevents {{\n worker_connections 1024;\n}}\n\nhttp {{\n access_log {runtime_path}/access.log;\n client_body_temp_path {runtime_path}/client_body_temp;\n proxy_temp_path {runtime_path}/proxy_temp;\n fastcgi_temp_path {runtime_path}/fastcgi_temp;\n uwsgi_temp_path {runtime_path}/uwsgi_temp;\n scgi_temp_path {runtime_path}/scgi_temp;\n\n" )?; // invariant: large maps need larger hash tables; must precede the maps include because nginx builds hashes at parse time. write!( conf, " map_hash_bucket_size 512;\n map_hash_max_size 131072;\n\n include {gen_path_str}/nginx/maps/*.conf;\n\n server {{\n listen {};\n server_name {};\n\n", config.activate.listen, config.hostname )?; conf.push_str(" # JSON Error dispatch locations (packet §22)\n"); for (name, err) in &error_defs { write!( conf, " location = /_ks_error/{name} {{\n internal;\n default_type application/json;\n add_header Cache-Control \"no-store\" always;\n error_page {status} ={status} /_ks_error_body/{name}.json;\n return {status};\n }}\n location = /_ks_error_body/{name}.json {{\n internal;\n default_type application/json;\n add_header Cache-Control \"no-store\" always;\n alias {gen_path_str}/www/_errors/{name}.json;\n }}\n\n", status = err.status )?; } conf.push_str(" # Location classes selected by response-path map\n"); if classes.is_empty() { write!( conf, " location /_ks_class/0/ {{\n internal;\n default_type application/json;\n alias {gen_path_str}/www/;\n }}\n\n" )?; } else { for (id, loc_class) in classes.iter().enumerate() { write!( conf, " location /_ks_class/{id}/ {{\n internal;\n default_type \"{}\";\n", escape_nginx(&loc_class.content_type) )?; for (header_name, header_val) in &loc_class.headers { write!( conf, " add_header {header_name} \"{}\" always;\n", escape_nginx(header_val) )?; } write!( conf, " alias {gen_path_str}/www/;\n }}\n\n" )?; } } conf.push_str(" # Service / identity endpoints\n"); if let Some((did_class, did_path)) = did_route { write!( conf, " location = /.well-known/did.json {{\n rewrite ^ /_ks_class/{did_class}{did_path} last;\n }}\n\n" )?; } else if generation_dir.join("www/.well-known/did.json").exists() { write!( conf, " location = /.well-known/did.json {{\n default_type application/did+ld+json;\n add_header Cache-Control \"public, max-age=300\" always;\n alias {gen_path_str}/www/.well-known/did.json;\n }}\n\n" )?; } else { conf.push_str( " location = /.well-known/did.json {\n default_type application/json;\n add_header Cache-Control \"no-store\" always;\n return 404 '{\"error\":\"InvalidRequest\",\"message\":\"did document not found\"}';\n }\n\n" ); } if generation_dir.join("www/oauth/client-metadata.json").exists() { write!( conf, " location = /oauth/client-metadata.json {{\n default_type application/json;\n add_header Cache-Control \"public, max-age=300\" always;\n alias {gen_path_str}/www/oauth/client-metadata.json;\n }}\n\n" )?; } conf.push_str( " location = /oauth/callback {\n proxy_pass http://127.0.0.1:8918;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n }\n\n", ); for (location, route, not_found_msg) in [ ( "/xrpc/sh.tangled.knot.version", version_route, "version not found", ), ("/xrpc/sh.tangled.owner", owner_route, "owner not found"), ("/xrpc/_health", health_route, "health check not found"), ] { if let Some((class_id, resp_path)) = route { write!( conf, " location = {location} {{\n rewrite ^ /_ks_class/{class_id}{resp_path} last;\n }}\n\n" )?; } else { write!( conf, " location = {location} {{\n default_type application/json;\n add_header Cache-Control \"no-store\" always;\n return 404 '{{\"error\":\"InvalidRequest\",\"message\":\"{not_found_msg}\"}}';\n }}\n\n" )?; } } conf.push_str(" # XRPC Endpoints (staged maps dispatch)\n"); // invariant: describeRepo accepts repoDid= or repo=; staged maps resolve either to repo token; both-empty falls to repo_not_found. let endpoint_dispatch = [ ( "sh.tangled.repo.describeRepo", "$ks_res_describe_repo", false, false, "repo_not_found", ), ( "sh.tangled.repo.getDefaultBranch", "$ks_res_get_default_branch", true, false, "ref_not_found", ), ( "sh.tangled.repo.branch", "$ks_res_branch", true, false, "branch_not_found", ), ( "sh.tangled.repo.branches", "$ks_res_branches", true, false, "invalid_request", ), ( "sh.tangled.repo.tag", "$ks_res_tag", true, false, "tag_not_found", ), ( "sh.tangled.repo.tags", "$ks_res_tags", true, false, "invalid_request", ), ( "sh.tangled.git.listRefs", "$ks_res_list_refs", true, false, "invalid_request", ), ( "sh.tangled.sync.listRepos", "$ks_res_list_repos", false, false, "invalid_request", ), ( "sh.tangled.repo.tree", "$ks_res_tree", true, true, "path_not_found", ), ( "sh.tangled.repo.blob", "$ks_res_blob", true, true, "file_not_found", ), ( "sh.tangled.repo.languages", "$ks_res_languages", true, true, "ref_not_found", ), ( "sh.tangled.repo.diff", "$ks_res_diff", true, true, "ref_not_found", ), ( "sh.tangled.repo.compare", "$ks_res_compare", true, false, "compare_error", ), ( "sh.tangled.repo.log", "$ks_res_log", true, true, "invalid_request", ), ( "sh.tangled.repo.archive", "$ks_res_archive", true, true, "invalid_request", ), ( "sh.tangled.repo.blame", "$ks_res_blame", true, true, "file_not_found", ), ]; for (ep, res_var, req_repo, req_commit, err) in endpoint_dispatch { write!( conf, " location = /xrpc/{ep} {{\n set $ks_response_path {res_var};\n" )?; if req_repo { conf.push_str(" if ($ks_repo = \"\") {\n rewrite ^ /_ks_error/repo_not_found last;\n }\n"); } if req_commit { conf.push_str(" if ($ks_commit = \"\") {\n rewrite ^ /_ks_error/ref_not_found last;\n }\n"); } write!( conf, " if ($ks_response_path = \"\") {{\n rewrite ^ /_ks_error/{err} last;\n }}\n if ($ks_location_class = \"\") {{\n rewrite ^ /_ks_error/internal_error last;\n }}\n rewrite ^ /_ks_class/$ks_location_class$ks_response_path last;\n }}\n\n" )?; } conf.push_str( " # Unknown NSID fallback (packet §22): JSON error, never nginx HTML\n location /xrpc/ {\n default_type application/json;\n add_header Cache-Control \"no-store\" always;\n return 404 '{\"error\":\"InvalidRequest\",\"message\":\"unknown endpoint\"}';\n }\n\n" ); conf.push_str(" # Dumb-HTTP Git exports (packet §17)\n"); let mut seen_aliases: BTreeSet<&str> = BTreeSet::new(); for (alias_param, repo_token) in &manifest.repo_aliases { let norm_alias = alias_param.trim_start_matches('/'); if !seen_aliases.insert(norm_alias) { continue; } // invariant: smart upload-pack negotiation; advertisement and canned want-all pack are static artifacts. // invariant: nginx static handler 405s POST bodies; remap to 200 re-serve. error_page re-entering as GET must reach static file. write!( conf, " location = /{norm_alias}/info/refs {{\n limit_except GET HEAD {{ deny all; }}\n if ($arg_service !~ ^git-upload-pack$) {{ return 403 \"service unsupported\\n\"; }}\n types {{ }} default_type application/x-git-upload-pack-advertisement;\n add_header Cache-Control \"no-store\" always;\n alias {gen_path_str}/www/_git/{repo_token}/smart/advertisement;\n }}\n\n location = /{norm_alias}/git-upload-pack {{\n error_page 405 =200 $uri;\n types {{ }} default_type application/x-git-upload-pack-result;\n add_header Cache-Control \"no-store\" always;\n alias {gen_path_str}/www/_git/{repo_token}/smart/upload-pack;\n }}\n\n location /{norm_alias}/HEAD {{\n limit_except GET HEAD {{ deny all; }}\n default_type text/plain;\n add_header Cache-Control \"public, max-age=60, stale-while-revalidate=300\" always;\n alias {gen_path_str}/www/_git/{repo_token}/HEAD;\n }}\n\n location /{norm_alias}/objects/ {{\n limit_except GET HEAD {{ deny all; }}\n add_header Cache-Control \"public, max-age=31536000, immutable\" always;\n alias {gen_path_str}/www/_git/{repo_token}/objects/;\n }}\n\n" )?; } conf.push_str( " location / {\n default_type application/json;\n add_header Cache-Control \"no-store\" always;\n return 404 '{\"error\":\"InvalidRequest\",\"message\":\"not found\"}';\n }\n }\n}\n" ); let conf_path = nginx_dir.join("knot-static.conf"); std::fs::write(&conf_path, conf.as_bytes()) .with_context(|| format!("write {}", conf_path.display()))?; Ok(()) }