g1t/services/packages/src/nuget_http.rs

611 lines32,095 bytesCodeBlame
1//! The NuGet feed: `g1t.sh/-/nuget/<workspace>/v3/index.json`, a v3 feed
2//! for each workspace. `dotnet nuget push` sends a g1t token as its API key
3//! (`X-NuGet-ApiKey`); restores send Basic credentials (any username, a g1t
4//! token as the password) from `nuget.config`, after the feed answers a
5//! private request with a `401`.
6//!
7//! A `.nupkg` is stored once, by its SHA-256, with its `.nuspec` beside it;
8//! the flat container, registration and search documents are made from the
9//! versions on each read. `dotnet nuget delete` unlists a version, as
10//! nuget.org does: it is still downloaded by those who name it. Each
11//! `.nupkg` download counts for its version as well as its package.
12//!
13//! A symbol package (`.snupkg`, pushed to `api/v2/symbolpackage` after its
14//! `.nupkg`) is kept beside the version, and each portable PDB in it by
15//! the key debuggers ask the symbol server (`symbols/`) with, as the
16//! Simple Symbol Query Protocol names it: `<file>/<guid>ffffffff/<file>`.
17
18use g1t_contracts::User;
19use g1t_contracts::audit::AuditActor;
20use g1t_contracts::events::PackageEvent;
21use g1t_contracts::new_id;
22use g1t_kit::now_ms;
23use serde_json::{Value, json};
24use worker::{Context, Headers, Method, Request, Response, ResponseBody, Result, Url};
25
26use crate::access::{self, Action};
27use crate::db::{NewFile, NewVersion, PackageRow, VersionRow};
28use crate::digest::Digest;
29use crate::npm;
30use crate::nuget::{self, Content, Listed, NugetRoute};
31use crate::oci::{Credentials, origin, published_by};
32use crate::store::BlobStore;
33use crate::{Caller, Packages, TargetOf, token};
34
35const NUGET: &str = "nuget";
36/// The most versions a package's documents list.
37const MAX_VERSIONS: u32 = 5000;
38/// The longest README kept for a package's page.
39const MAX_README_BYTES: usize = 1024 * 1024;
40/// The most packages one search answers with.
41const MAX_SEARCH: u32 = 100;
42const DOCS: &str = "https://docs.g1t.sh/guides/nuget/";
43const TOKENS: &str = "https://g1t.sh/settings/tokens";
44
45/// A plain-text answer, which `dotnet` prints after the status.
46fn error(status: u16, message: impl Into<String>) -> Result<Response> {
47 let mut response = Response::ok(message.into())?.with_status(status);
48 response.headers_mut().set("content-type", "text/plain; charset=utf-8")?;
49 if status == 401 {
50 response.headers_mut().set("www-authenticate", "Basic realm=\"g1t\"")?;
51 }
52 Ok(response)
53}
54
55fn sign_in() -> String {
56 format!("Sign in to use this feed: give the source a username and a g1t access token from {TOKENS} as its password. See {DOCS}")
57}
58
59fn json_response(value: &Value, head: bool) -> Result<Response> {
60 let mut response = if head { Response::empty()? } else { Response::from_json(value)? };
61 response.headers_mut().set("content-type", "application/json")?;
62 response.headers_mut().set("cache-control", "no-cache")?;
63 Ok(response)
64}
65
66impl Packages {
67 /// Answers a NuGet request.
68 pub async fn nuget(&self, request: Request, ctx: &Context) -> Result<Response> {
69 let url = request.url()?;
70 let Some((workspace, route)) = nuget::route(url.path()) else {
71 return error(404, "There is nothing at this address.");
72 };
73 match self.nuget_route(request, &url, &workspace, route, ctx).await {
74 Ok(response) => Ok(response),
75 Err(problem) => {
76 worker::console_error!("packages: nuget {}: {problem}", url.path());
77 error(500, "Something went wrong on our side. Try again in a moment.")
78 }
79 }
80 }
81
82 /// Who the request is from: the push's API key, or Basic credentials
83 /// (or a `Bearer` token) from the source's settings.
84 async fn nuget_credentials(&self, request: &Request) -> Result<Credentials> {
85 if let Some(key) = request.headers().get("x-nuget-apikey")?.map(|k| k.trim().to_owned()).filter(|k| !k.is_empty()) {
86 return Ok(match self.viewer_for("token", &key).await? {
87 Some(user) => Credentials::Viewer(Some(user)),
88 None => Credentials::Bad,
89 });
90 }
91 let Some(header) = request.headers().get("authorization")? else {
92 return Ok(Credentials::None);
93 };
94 let viewer = if let Some((username, secret)) = token::basic(&header) {
95 self.viewer_for(&username, &secret).await?
96 } else if let Some(bearer) = token::bearer(&header) {
97 self.viewer_for("token", bearer).await?
98 } else {
99 None
100 };
101 Ok(match viewer {
102 Some(user) => Credentials::Viewer(Some(user)),
103 None => Credentials::Bad,
104 })
105 }
106
107 async fn nuget_route(&self, mut request: Request, url: &Url, workspace: &str, route: NugetRoute, ctx: &Context) -> Result<Response> {
108 let method = request.method();
109 let credentials = self.nuget_credentials(&request).await?;
110 let read = matches!(method, Method::Get | Method::Head);
111 if read && let Some(refused) = self.limited(&request, &credentials, "a g1t token in the source's credentials").await? {
112 return Ok(refused);
113 }
114 let viewer = match credentials {
115 Credentials::Viewer(viewer) => viewer,
116 Credentials::None => None,
117 Credentials::Token(_) | Credentials::Bad => {
118 return error(401, format!("The token is not right, or has expired. Make an access token at {TOKENS}."));
119 }
120 };
121 let viewer = viewer.as_ref();
122 let base = format!("{}/-/nuget/{workspace}", origin(url));
123 let head = method == Method::Head;
124 match route {
125 NugetRoute::Index if read => {
126 if viewer.is_none() && self.db.has_private(workspace, NUGET).await? {
127 return error(401, sign_in());
128 }
129 json_response(&nuget::service_index(&base), head)
130 }
131 NugetRoute::Versions { id } if read => self.nuget_versions(workspace, &id, viewer, head).await,
132 NugetRoute::Content { id, version, file } if read => self.nuget_content(workspace, &id, &version, file, viewer, head, ctx).await,
133 NugetRoute::Registration { id } if read => self.nuget_registration(&base, workspace, &id, None, viewer, head).await,
134 NugetRoute::Leaf { id, version } if read => self.nuget_registration(&base, workspace, &id, Some(&version), viewer, head).await,
135 NugetRoute::Search if read => self.nuget_search(url, &base, workspace, viewer).await,
136 NugetRoute::Push if method == Method::Put => self.nuget_push(&mut request, workspace, viewer).await,
137 NugetRoute::Listing { id, version } if method == Method::Delete => self.nuget_listing(workspace, &id, &version, false, viewer).await,
138 NugetRoute::Listing { id, version } if method == Method::Post => self.nuget_listing(workspace, &id, &version, true, viewer).await,
139 NugetRoute::SymbolPush if method == Method::Put => self.nuget_symbol_push(&mut request, workspace, viewer).await,
140 NugetRoute::Symbol { file, key } if read => self.nuget_symbol(workspace, &file, &key, viewer, head).await,
141 _ => error(405, "Not a method this address takes."),
142 }
143 }
144
145 /// The package, by its id in any case, if its workspace is not deleted.
146 async fn nuget_package(&self, workspace: &str, id: &str) -> Result<Option<PackageRow>> {
147 Ok(self.db.package_any_case(workspace, NUGET, id).await?.filter(|p| !p.hidden()))
148 }
149
150 /// The answer for something not there: a `401` to someone not signed
151 /// in when the workspace has private packages, so the client sends its
152 /// credentials and a private package looks like a missing one.
153 async fn nuget_absent(&self, workspace: &str, viewer: Option<&User>) -> Result<Response> {
154 if viewer.is_none() && self.db.has_private(workspace, NUGET).await? {
155 return error(401, sign_in());
156 }
157 error(404, "Not found: no such package or version, or you cannot see it.")
158 }
159
160 async fn nuget_check(&self, viewer: Option<&User>, package: &PackageRow, action: Action) -> Result<Option<Response>> {
161 let target = TargetOf::package(package);
162 let decision = access::decide(viewer, &target.view(), action);
163 if decision.allowed {
164 return Ok(None);
165 }
166 let readable = action != Action::Pull && access::decide(viewer, &target.view(), Action::Pull).allowed;
167 if !readable && viewer.is_none() {
168 return Ok(Some(error(401, sign_in())?));
169 }
170 if !readable {
171 return Ok(Some(self.nuget_absent(&package.workspace, viewer).await?));
172 }
173 Ok(Some(error(403, decision.reason.unwrap_or_else(|| "Not allowed.".to_owned()))?))
174 }
175
176 /// The package and its versions, oldest first, when the viewer may read it.
177 async fn nuget_readable(&self, workspace: &str, id: &str, viewer: Option<&User>) -> Result<std::result::Result<(PackageRow, Vec<VersionRow>), Response>> {
178 let Some(package) = self.nuget_package(workspace, id).await? else {
179 return Ok(Err(self.nuget_absent(workspace, viewer).await?));
180 };
181 if let Some(refusal) = self.nuget_check(viewer, &package, Action::Pull).await? {
182 return Ok(Err(refusal));
183 }
184 let mut versions = self.db.versions(&package.id, MAX_VERSIONS).await?;
185 if versions.is_empty() {
186 return Ok(Err(self.nuget_absent(workspace, viewer).await?));
187 }
188 versions.sort_by(|a, b| nuget::compare(&a.version, &b.version));
189 Ok(Ok((package, versions)))
190 }
191
192 /// The flat container's version list: every version, unlisted ones too.
193 async fn nuget_versions(&self, workspace: &str, id: &str, viewer: Option<&User>, head: bool) -> Result<Response> {
194 let (_, versions) = match self.nuget_readable(workspace, id, viewer).await? {
195 Ok(found) => found,
196 Err(refused) => return Ok(refused),
197 };
198 let listed: Vec<String> = versions.iter().map(|v| v.version.to_ascii_lowercase()).collect();
199 json_response(&json!({ "versions": listed }), head)
200 }
201
202 /// A version's `.nupkg` or `.nuspec`.
203 #[allow(clippy::too_many_arguments)]
204 async fn nuget_content(&self, workspace: &str, id: &str, version: &str, file: Content, viewer: Option<&User>, head: bool, ctx: &Context) -> Result<Response> {
205 let (package, versions) = match self.nuget_readable(workspace, id, viewer).await? {
206 Ok(found) => found,
207 Err(refused) => return Ok(refused),
208 };
209 let wanted = nuget::normalize(version).unwrap_or_default().to_ascii_lowercase();
210 let Some(row) = versions.iter().find(|v| v.version.to_ascii_lowercase() == wanted) else {
211 return self.nuget_absent(workspace, viewer).await;
212 };
213 let Some(kept) = self.db.file(&row.id, file.file()).await? else {
214 return self.nuget_absent(workspace, viewer).await;
215 };
216 let Some(digest) = Digest::parse(&kept.digest) else {
217 return self.nuget_absent(workspace, viewer).await;
218 };
219 let Some(blob) = self.db.package_blob(&package.id, &digest).await? else {
220 return self.nuget_absent(workspace, viewer).await;
221 };
222 let headers = Headers::new();
223 headers.set("content-type", if file == Content::Nuspec { "application/xml" } else { "application/octet-stream" })?;
224 headers.set("content-length", &blob.size.to_string())?;
225 headers.set("cache-control", "max-age=31536000")?;
226 if head {
227 return Ok(Response::from_body(ResponseBody::Empty)?.with_headers(headers));
228 }
229 let Some(got) = self.store.get(&blob.object_key, None).await? else {
230 return self.nuget_absent(workspace, viewer).await;
231 };
232 if file == Content::Nupkg {
233 self.count_version_download(&package.id, &row.id, ctx);
234 }
235 Ok(Response::from_body(got.body)?.with_headers(headers))
236 }
237
238 /// A package's registration index, or one version's leaf.
239 async fn nuget_registration(&self, base: &str, workspace: &str, id: &str, version: Option<&str>, viewer: Option<&User>, head: bool) -> Result<Response> {
240 let (package, versions) = match self.nuget_readable(workspace, id, viewer).await? {
241 Ok(found) => found,
242 Err(refused) => return Ok(refused),
243 };
244 let metadata: Vec<Value> = versions.iter().map(VersionRow::meta).collect();
245 let listed: Vec<Listed<'_>> = versions
246 .iter()
247 .zip(&metadata)
248 .map(|(row, metadata)| Listed { version: &row.version, metadata, published: &row.published_at, listed: !row.is_yanked(), downloads: row.downloads })
249 .collect();
250 match version {
251 None => json_response(&nuget::registration(base, &package.name, &listed), head),
252 Some(version) => {
253 let wanted = nuget::normalize(version).unwrap_or_default().to_ascii_lowercase();
254 let Some(one) = listed.iter().find(|v| v.version.to_ascii_lowercase() == wanted) else {
255 return self.nuget_absent(workspace, viewer).await;
256 };
257 json_response(&nuget::leaf(base, &package.name, one), head)
258 }
259 }
260 }
261
262 /// Search: the workspace's packages the viewer may see whose id or
263 /// description holds the query, with their listed versions.
264 async fn nuget_search(&self, url: &Url, base: &str, workspace: &str, viewer: Option<&User>) -> Result<Response> {
265 let query = |key: &str| url.query_pairs().find(|(k, _)| k == key).map(|(_, v)| v.into_owned());
266 let q = query("q").unwrap_or_default().trim().to_ascii_lowercase();
267 let skip = query("skip").and_then(|v| v.parse::<usize>().ok()).unwrap_or(0);
268 let take = query("take").and_then(|v| v.parse::<usize>().ok()).unwrap_or(20).min(MAX_SEARCH as usize);
269 let prerelease = query("prerelease").is_some_and(|v| v.eq_ignore_ascii_case("true"));
270 if viewer.is_none() && self.db.has_private(workspace, NUGET).await? {
271 return error(401, sign_in());
272 }
273 let packages = self.db.packages_of(workspace, NUGET, 1000).await?;
274 let versions = self.db.ecosystem_versions(workspace, NUGET, 20_000).await?;
275 let mut found = Vec::new();
276 for package in &packages {
277 if !access::decide(viewer, &TargetOf::package(package).view(), Action::Pull).allowed {
278 continue;
279 }
280 let rows: Vec<&VersionRow> = versions
281 .iter()
282 .filter(|v| v.package_id == package.id && (prerelease || !nuget::is_prerelease(&v.version)))
283 .collect();
284 let metadata: Vec<Value> = rows.iter().map(|v| v.meta()).collect();
285 let matches = q.is_empty()
286 || package.name.to_ascii_lowercase().contains(&q)
287 || package.description.as_deref().is_some_and(|d| d.to_ascii_lowercase().contains(&q));
288 if !matches {
289 continue;
290 }
291 let mut listed: Vec<Listed<'_>> = rows
292 .iter()
293 .zip(&metadata)
294 .map(|(row, metadata)| Listed { version: &row.version, metadata, published: &row.published_at, listed: !row.is_yanked(), downloads: row.downloads })
295 .collect();
296 listed.sort_by(|a, b| nuget::compare(a.version, b.version));
297 if let Some(mut result) = nuget::search_result(base, &package.name, &listed) {
298 result["totalDownloads"] = json!(package.downloads);
299 found.push(result);
300 }
301 }
302 let total = found.len();
303 let data: Vec<Value> = found.into_iter().skip(skip).take(take).collect();
304 json_response(&json!({ "totalHits": total, "data": data }), false)
305 }
306
307 /// The package a first push makes, linked to the repository its
308 /// `.nuspec` names on g1t, or else one named like its id.
309 async fn nuget_target(&self, workspace: &str, id: &str, repository: Option<&str>) -> Result<TargetOf> {
310 let named = repository
311 .and_then(|url| npm::repository_of(&Value::String(url.to_owned()), &self.host))
312 .filter(|(owner, _)| owner == workspace)
313 .map(|(_, repo)| repo);
314 let lower = id.to_ascii_lowercase();
315 let dashed = lower.replace('.', "-");
316 let mut repo = None;
317 for candidate in named.iter().map(String::as_str).chain([lower.as_str(), dashed.as_str()]) {
318 if let Some(found) = self.repo_by_name(workspace, candidate).await? {
319 repo = Some(found);
320 break;
321 }
322 }
323 Ok(TargetOf { workspace: workspace.to_owned(), repo: repo.map(|r| (r.id, r.name, r.is_private)), public: false })
324 }
325
326 /// `dotnet nuget push`: a `PUT` of the `.nupkg`, in a multipart body.
327 async fn nuget_push(&self, request: &mut Request, workspace: &str, viewer: Option<&User>) -> Result<Response> {
328 let declared = request.headers().get("content-length")?.and_then(|n| n.parse::<u64>().ok());
329 let too_large = || {
330 let mb = self.max_request / 1_000_000;
331 error(413, format!("A push may be at most {mb} MB. See {DOCS}#size"))
332 };
333 if declared.is_some_and(|n| n > self.max_request) {
334 return too_large();
335 }
336 let content_type = request.headers().get("content-type")?;
337 let body = request.bytes().await?;
338 if body.len() as u64 > self.max_request {
339 return too_large();
340 }
341 if viewer.is_none() {
342 return error(401, format!("Push with a g1t access token as the API key: dotnet nuget push <file> --api-key <token>. Make one at {TOKENS}."));
343 }
344 let nupkg = match nuget::pushed_file(content_type.as_deref(), &body) {
345 Ok(file) => file,
346 Err(message) => return error(400, message),
347 };
348 let read = match nuget::read_package(nupkg) {
349 Ok(read) => read,
350 Err(message) => return error(400, message),
351 };
352 let spec = &read.nuspec;
353 if !nuget::valid_id(&spec.id) {
354 return error(400, format!("{} is not a valid package id: letters, digits and _, in parts joined by ., - or _.", spec.id));
355 }
356 let Some(version) = nuget::normalize(&spec.version) else {
357 return error(400, format!("{} is not a version NuGet reads.", spec.version));
358 };
359
360 let found = self.db.package_any_case(workspace, NUGET, &spec.id).await?;
361 if found.as_ref().is_some_and(PackageRow::hidden) || (found.is_none() && self.db.workspace_hidden(workspace).await?) {
362 return error(403, format!("The workspace {workspace} is deleted; nothing can be pushed to it."));
363 }
364 let target = match &found {
365 Some(package) => TargetOf::package(package),
366 None => self.nuget_target(workspace, &spec.id, spec.repository_url.as_deref()).await?,
367 };
368 let decision = access::decide(viewer, &target.view(), Action::Push);
369 if !decision.allowed {
370 let readable = found.is_none() || access::decide(viewer, &target.view(), Action::Pull).allowed;
371 if !readable {
372 return error(404, "Not found: no such package, or you cannot see it.");
373 }
374 return error(403, decision.reason.unwrap_or_else(|| "Not allowed.".to_owned()));
375 }
376 let caller = Caller { actor: viewer.map(AuditActor::of) };
377 let package = match found {
378 Some(package) => package,
379 None => {
380 self.db
381 .create_package(
382 &new_id("pkg", now_ms()),
383 workspace,
384 NUGET,
385 &spec.id,
386 target.repo.as_ref().map(|(id, repo, private)| (id.as_str(), repo.as_str(), *private)),
387 caller.actor.as_ref().map_or("", |actor| actor.actor_id.as_str()),
388 now_ms(),
389 )
390 .await?
391 }
392 };
393 let existing = self.db.versions(&package.id, MAX_VERSIONS).await?;
394 if let Some(taken) = existing.iter().find(|v| v.version.eq_ignore_ascii_case(&version)) {
395 return error(409, format!("{} {} is already pushed, and a version is pushed once. Bump the version.", package.name, taken.version));
396 }
397
398 let nupkg = nupkg.to_vec();
399 let digest = Digest::of(&nupkg);
400 let size = nupkg.len() as u64;
401 let nuspec_digest = Digest::of(&read.nuspec_bytes);
402 let nuspec_size = read.nuspec_bytes.len() as u64;
403 let files = [(digest.to_string(), size), (nuspec_digest.to_string(), nuspec_size)];
404 if let Some(refusal) = self.storage_refusal(&package, &files).await? {
405 return error(403, refusal);
406 }
407 let now = now_ms();
408 for (digest, bytes, media_type) in [(&digest, nupkg, "application/octet-stream"), (&nuspec_digest, read.nuspec_bytes.clone(), "application/xml")] {
409 let stored = match self.db.blob(digest).await? {
410 Some(blob) => self.store.head(&blob.object_key).await?.is_some(),
411 None => false,
412 };
413 let length = bytes.len() as u64;
414 if !stored {
415 self.store.put(&digest.object_key(), bytes).await?;
416 }
417 self.db.keep_blob(&package.id, digest, length, Some(media_type), &digest.object_key(), now).await?;
418 }
419 self.db
420 .publish(
421 NewVersion {
422 id: new_id("ver", now),
423 package_id: package.id.clone(),
424 version: version.clone(),
425 digest: digest.to_string(),
426 size: size + nuspec_size,
427 metadata: nuget::stored(spec, &version).to_string(),
428 subject: None,
429 published_by: published_by(&caller),
430 files: vec![
431 NewFile { name: "nupkg".to_owned(), digest: digest.to_string(), size, media_type: Some("application/octet-stream".to_owned()) },
432 NewFile {
433 name: "nuspec".to_owned(),
434 digest: nuspec_digest.to_string(),
435 size: nuspec_size,
436 media_type: Some("application/xml".to_owned()),
437 },
438 ],
439 },
440 None,
441 now,
442 )
443 .await?;
444 // The README and description the page shows: the highest stable
445 // version's, so a pre-release does not replace them.
446 let highest = !nuget::is_prerelease(&version)
447 && existing.iter().filter(|v| !nuget::is_prerelease(&v.version)).all(|v| nuget::compare(&v.version, &version).is_lt());
448 let first = nuget::is_prerelease(&version) && existing.is_empty();
449 if highest || first {
450 let readme = read.readme.as_deref().map(str::trim).filter(|r| !r.is_empty() && r.len() <= MAX_README_BYTES);
451 let readme_digest = match readme {
452 Some(readme) => {
453 let bytes = readme.as_bytes().to_vec();
454 let digest = Digest::of(&bytes);
455 if self.db.blob(&digest).await?.is_none() {
456 self.store.put(&digest.object_key(), bytes.clone()).await?;
457 }
458 self.db.keep_blob(&package.id, &digest, bytes.len() as u64, Some("text/markdown"), &digest.object_key(), now).await?;
459 Some(digest.to_string())
460 }
461 None => None,
462 };
463 self.db.set_readme(&package.id, readme_digest.as_deref(), spec.description.as_deref(), now).await?;
464 }
465 self.db.measure(&package.workspace).await?;
466 let event = PackageEvent {
467 version: Some(version.clone()),
468 digest: Some(digest.to_string()),
469 size: Some(size),
470 ..self.event_of(&package)
471 };
472 self.announce("package.published", &package, event, &caller).await;
473 self.audit(&caller, "package.publish", &package, Some(&format!("{workspace}/{}@{version}", package.name)), None).await;
474 error(201, format!("{} {version} was pushed.", package.name))
475 }
476
477 /// `dotnet nuget push` of a `.snupkg`, which it sends after the
478 /// `.nupkg` beside it: the symbols of a version already pushed, kept
479 /// with it, and each portable PDB in it kept by its symbol server key.
480 async fn nuget_symbol_push(&self, request: &mut Request, workspace: &str, viewer: Option<&User>) -> Result<Response> {
481 let declared = request.headers().get("content-length")?.and_then(|n| n.parse::<u64>().ok());
482 let too_large = || {
483 let mb = self.max_request / 1_000_000;
484 error(413, format!("A push may be at most {mb} MB. See {DOCS}#size"))
485 };
486 if declared.is_some_and(|n| n > self.max_request) {
487 return too_large();
488 }
489 let content_type = request.headers().get("content-type")?;
490 let body = request.bytes().await?;
491 if body.len() as u64 > self.max_request {
492 return too_large();
493 }
494 if viewer.is_none() {
495 return error(401, format!("Push with a g1t access token as the API key: dotnet nuget push <file> --api-key <token>. Make one at {TOKENS}."));
496 }
497 let snupkg = match nuget::pushed_file(content_type.as_deref(), &body) {
498 Ok(file) => file,
499 Err(message) => return error(400, message),
500 };
501 let symbols = match nuget::read_symbols(snupkg) {
502 Ok(symbols) => symbols,
503 Err(message) => return error(400, message),
504 };
505 let spec = &symbols.nuspec;
506 let Some(version) = nuget::normalize(&spec.version) else {
507 return error(400, format!("{} is not a version NuGet reads.", spec.version));
508 };
509 let push_first = || error(404, format!("Push {} {version} before its symbols: dotnet nuget push pushes the .snupkg beside a .nupkg after it.", spec.id));
510 let Some(package) = self.nuget_package(workspace, &spec.id).await? else {
511 return push_first();
512 };
513 if let Some(refusal) = self.nuget_check(viewer, &package, Action::Push).await? {
514 return Ok(refusal);
515 }
516 let versions = self.db.versions(&package.id, MAX_VERSIONS).await?;
517 let Some(row) = versions.iter().find(|v| v.version.eq_ignore_ascii_case(&version)) else {
518 return push_first();
519 };
520 let snupkg = snupkg.to_vec();
521 let digest = Digest::of(&snupkg);
522 if let Some(kept) = self.db.file(&row.id, Content::Snupkg.file()).await? {
523 if kept.digest == digest.to_string() {
524 return error(201, format!("The symbols of {} {} were pushed.", package.name, row.version));
525 }
526 return error(409, format!("{} {} already has symbols, and a version's symbols are pushed once. Bump the version.", package.name, row.version));
527 }
528 let mut files = vec![(Content::Snupkg.file().to_owned(), digest.clone(), snupkg)];
529 for pdb in symbols.pdbs {
530 let name = nuget::symbol_file(&pdb.file, &pdb.key);
531 if files.iter().all(|(kept, _, _)| *kept != name) {
532 files.push((name, Digest::of(&pdb.bytes), pdb.bytes));
533 }
534 }
535 let sizes: Vec<(String, u64)> = files.iter().map(|(_, d, bytes)| (d.to_string(), bytes.len() as u64)).collect();
536 if let Some(refusal) = self.storage_refusal(&package, &sizes).await? {
537 return error(403, refusal);
538 }
539 let now = now_ms();
540 for (name, digest, bytes) in files {
541 let size = bytes.len() as u64;
542 let stored = match self.db.blob(&digest).await? {
543 Some(blob) => self.store.head(&blob.object_key).await?.is_some(),
544 None => false,
545 };
546 if !stored {
547 self.store.put(&digest.object_key(), bytes).await?;
548 }
549 self.db.keep_blob(&package.id, &digest, size, Some("application/octet-stream"), &digest.object_key(), now).await?;
550 let file = NewFile { name, digest: digest.to_string(), size, media_type: Some("application/octet-stream".to_owned()) };
551 self.db.put_file(&package.id, &row.id, &file, now).await?;
552 }
553 let mut metadata = row.meta();
554 if metadata.is_object() {
555 metadata["symbols"] = json!(true);
556 self.db.set_version(&row.id, &row.digest, &metadata.to_string()).await?;
557 }
558 self.db.measure(&package.workspace).await?;
559 let caller = Caller { actor: viewer.map(AuditActor::of) };
560 self.audit(&caller, "package.publish_symbols", &package, Some(&format!("{workspace}/{}@{}", package.name, row.version)), None).await;
561 error(201, format!("The symbols of {} {} were pushed.", package.name, row.version))
562 }
563
564 /// The symbol server: a PDB by its file name and key, from a package
565 /// of the workspace the viewer may read.
566 async fn nuget_symbol(&self, workspace: &str, file: &str, key: &str, viewer: Option<&User>, head: bool) -> Result<Response> {
567 for found in self.db.files_named(workspace, NUGET, &nuget::symbol_file(file, key), 10).await? {
568 let Some(package) = self.db.package_by_id(&found.package_id).await?.filter(|p| !p.hidden()) else {
569 continue;
570 };
571 if !access::decide(viewer, &TargetOf::package(&package).view(), Action::Pull).allowed {
572 continue;
573 }
574 let Some(digest) = Digest::parse(&found.digest) else { continue };
575 let Some(blob) = self.db.package_blob(&package.id, &digest).await? else { continue };
576 let headers = Headers::new();
577 headers.set("content-type", "application/octet-stream")?;
578 headers.set("content-length", &blob.size.to_string())?;
579 headers.set("cache-control", "max-age=31536000")?;
580 if head {
581 return Ok(Response::from_body(ResponseBody::Empty)?.with_headers(headers));
582 }
583 let Some(got) = self.store.get(&blob.object_key, None).await? else { continue };
584 return Ok(Response::from_body(got.body)?.with_headers(headers));
585 }
586 self.nuget_absent(workspace, viewer).await
587 }
588
589 /// `dotnet nuget delete` unlists a version; a `POST` lists it again.
590 async fn nuget_listing(&self, workspace: &str, id: &str, version: &str, listed: bool, viewer: Option<&User>) -> Result<Response> {
591 let Some(package) = self.nuget_package(workspace, id).await? else {
592 return self.nuget_absent(workspace, viewer).await;
593 };
594 if let Some(refusal) = self.nuget_check(viewer, &package, Action::Push).await? {
595 return Ok(refusal);
596 }
597 let wanted = nuget::normalize(version).unwrap_or_default();
598 let versions = self.db.versions(&package.id, MAX_VERSIONS).await?;
599 let Some(row) = versions.iter().find(|v| v.version.eq_ignore_ascii_case(&wanted)) else {
600 return error(404, format!("{} {version} is not there.", package.name));
601 };
602 if row.is_yanked() == listed {
603 self.db.set_yanked(&row.id, !listed).await?;
604 self.db.touch_package(&package.id, now_ms()).await?;
605 let caller = Caller { actor: viewer.map(AuditActor::of) };
606 let action = if listed { "package.relist" } else { "package.unlist" };
607 self.audit(&caller, action, &package, Some(&format!("{workspace}/{}@{}", package.name, row.version)), None).await;
608 }
609 Ok(Response::empty()?.with_status(if listed { 200 } else { 204 }))
610 }
611}