g1t/services/packages/src/npm_http.rs

625 lines29,733 bytesCodeBlame
1//! The npm registry: `g1t.sh/-/npm/`, for packages scoped by workspace
2//! (`@acme/web`). `.npmrc` names it for the scope, with a g1t token:
3//!
4//! ```text
5//! @acme:registry=https://g1t.sh/-/npm/
6//! //g1t.sh/-/npm/:_authToken=<token>
7//! ```
8//!
9//! npm sends the token as `Authorization: Bearer`; Basic credentials (a
10//! username and a g1t token, `_auth`) work too. Publishing is one `PUT` of
11//! the packument with the tarball attached; deprecating and unpublishing a
12//! version are `PUT`s of the packument as npm changed it; unpublishing a
13//! package is a `DELETE`. A tarball is stored once, by its SHA-256, like
14//! every file here.
15
16use std::collections::{HashMap, HashSet};
17
18use base64::Engine;
19use base64::engine::general_purpose::STANDARD;
20use g1t_contracts::User;
21use g1t_contracts::audit::AuditActor;
22use g1t_contracts::events::PackageEvent;
23use g1t_contracts::new_id;
24use g1t_contracts::time::parse_rfc3339;
25use g1t_kit::now_ms;
26use serde_json::{Value, json};
27use worker::{Context, Headers, Method, Request, Response, ResponseBody, Result, Url};
28
29use crate::access::{self, Action};
30use crate::db::{NewFile, NewVersion, PackageRow, VersionRow};
31use crate::digest::Digest;
32use crate::npm::{self, NpmName, NpmRoute, Packument, StoredVersion};
33use crate::oci::{Credentials, origin, published_by};
34use crate::store::BlobStore;
35use crate::{Caller, Packages, TargetOf};
36
37const NPM: &str = "npm";
38/// The most versions a packument lists.
39const MAX_VERSIONS: u32 = 2000;
40/// The longest README kept for a package's page.
41const MAX_README_BYTES: usize = 1024 * 1024;
42const DOCS: &str = "https://docs.g1t.sh/guides/npm/";
43
44/// npm's error shape: `{"error": "..."}`, which it prints.
45fn error(status: u16, message: impl Into<String>) -> Result<Response> {
46 let mut response = Response::from_json(&json!({ "error": message.into() }))?.with_status(status);
47 if status == 401 {
48 response.headers_mut().set("www-authenticate", "Basic realm=\"g1t\"")?;
49 }
50 Ok(response)
51}
52
53fn ok() -> Result<Response> {
54 Response::from_json(&json!({ "ok": true }))
55}
56
57fn not_found() -> Result<Response> {
58 error(404, "Not found: no such package, or you cannot see it. Private packages need a token in .npmrc.")
59}
60
61/// The decision's reason as a 403, or the not-found answer when the
62/// viewer may not even read the package.
63fn refused(decision: g1t_contracts::credentials::Decision, readable: bool) -> Result<Response> {
64 if !readable {
65 return not_found();
66 }
67 error(403, decision.reason.unwrap_or_else(|| "Not allowed.".to_owned()))
68}
69
70/// When a version was published, in milliseconds.
71fn published_ms(version: &VersionRow) -> u64 {
72 parse_rfc3339(&version.published_at).unwrap_or(0)
73}
74
75impl Packages {
76 /// Answers an npm request.
77 pub async fn npm(&self, request: Request, ctx: &Context) -> Result<Response> {
78 let url = request.url()?;
79 let Some(route) = npm::route(url.path()) else {
80 return error(404, "There is nothing at this address.");
81 };
82 match self.npm_route(request, &url, route, ctx).await {
83 Ok(response) => Ok(response),
84 Err(problem) => {
85 worker::console_error!("packages: npm {}: {problem}", url.path());
86 error(500, "Something went wrong on our side. Try again in a moment.")
87 }
88 }
89 }
90
91 async fn npm_route(&self, mut request: Request, url: &Url, route: NpmRoute, ctx: &Context) -> Result<Response> {
92 let method = request.method();
93 let credentials = self.credentials(&request).await?;
94 if matches!(method, Method::Get | Method::Head)
95 && let Some(refused) = self.limited(&request, &credentials, "a token in .npmrc").await?
96 {
97 return Ok(refused);
98 }
99 let viewer = match credentials {
100 Credentials::Viewer(viewer) => viewer,
101 Credentials::None => None,
102 // The container registry's own tokens are not npm's.
103 Credentials::Token(_) | Credentials::Bad => {
104 return error(401, "The token is not right, or has expired. Put a g1t access token in .npmrc: //g1t.sh/-/npm/:_authToken=<token>");
105 }
106 };
107 match route {
108 NpmRoute::Ping => Response::from_json(&json!({})),
109 NpmRoute::Whoami => match viewer {
110 Some(user) => Response::from_json(&json!({ "username": user.username })),
111 None => error(401, "Not signed in. Put a g1t access token in .npmrc."),
112 },
113 NpmRoute::Login => self.npm_login(&mut request).await,
114 NpmRoute::DistTags { name, tag } => {
115 let name = match npm::parse_name(&name) {
116 Ok(name) => name,
117 Err(message) => return error(400, message),
118 };
119 self.dist_tags(&mut request, method, &name, tag.as_deref(), viewer.as_ref()).await
120 }
121 NpmRoute::Package { name, rev } => {
122 let name = match npm::parse_name(&name) {
123 Ok(name) => name,
124 Err(message) => return error(400, message),
125 };
126 match (method, rev) {
127 (Method::Get | Method::Head, _) => self.packument(&request, url, &name, viewer.as_ref()).await,
128 (Method::Put, _) => self.npm_put(&mut request, &name, viewer.as_ref()).await,
129 (Method::Delete, Some(_)) => self.unpublish_package(&name, viewer.as_ref()).await,
130 _ => error(405, "Not a method this address takes."),
131 }
132 }
133 NpmRoute::Tarball { name, file, rev } => {
134 let name = match npm::parse_name(&name) {
135 Ok(name) => name,
136 Err(message) => return error(400, message),
137 };
138 let Some(version) = npm::version_of_file(&name, &file) else {
139 return error(404, format!("{file} is not a tarball of {}.", name.full()));
140 };
141 let head = method == Method::Head;
142 match (method, rev) {
143 (Method::Get | Method::Head, _) => self.tarball(&name, &version, viewer.as_ref(), head, ctx).await,
144 (Method::Delete, Some(_)) => self.unpublish_tarball(&name, &version, viewer.as_ref()).await,
145 _ => error(405, "Not a method this address takes."),
146 }
147 }
148 }
149 }
150
151 /// `npm login --auth-type=legacy`: the password has to be a g1t token,
152 /// which npm then keeps and sends as its bearer token.
153 async fn npm_login(&self, request: &mut Request) -> Result<Response> {
154 let body: Value = request.json().await.unwrap_or_default();
155 let (name, password) = (body["name"].as_str().unwrap_or(""), body["password"].as_str().unwrap_or(""));
156 if !password.starts_with("g1t_") {
157 return error(401, "Use a g1t access token as the password: https://g1t.sh/settings/tokens");
158 }
159 match self.viewer_for(name, password).await? {
160 Some(user) => Ok(Response::from_json(&json!({ "ok": true, "id": format!("org.couchdb.user:{}", user.username), "token": password }))?
161 .with_status(201)),
162 None => error(401, "That token is not right, or has expired."),
163 }
164 }
165
166 /// The package, if it is there and its workspace is not deleted.
167 async fn npm_package(&self, name: &NpmName) -> Result<Option<PackageRow>> {
168 Ok(self.db.package(&name.workspace, NPM, &name.name).await?.filter(|p| !p.hidden()))
169 }
170
171 /// Whether `viewer` may `action` the package, as the answer when not:
172 /// 401 for someone not signed in who may not read it (npm then says to
173 /// log in), 404 for anyone else who may not read it, and 403 with the
174 /// reason for one who may read it but not do this.
175 fn npm_check(&self, viewer: Option<&User>, package: &PackageRow, action: Action) -> Option<Result<Response>> {
176 let target = TargetOf::package(package);
177 let decision = access::decide(viewer, &target.view(), action);
178 if decision.allowed {
179 return None;
180 }
181 let readable = action != Action::Pull && access::decide(viewer, &target.view(), Action::Pull).allowed;
182 if !readable && viewer.is_none() {
183 return Some(error(401, "Sign in to use this package: put a g1t access token in .npmrc (//g1t.sh/-/npm/:_authToken=<token>)."));
184 }
185 Some(refused(decision, readable))
186 }
187
188 async fn stored_versions(&self, package: &PackageRow) -> Result<Vec<(VersionRow, StoredVersion)>> {
189 let mut rows = self.db.versions(&package.id, MAX_VERSIONS).await?;
190 rows.reverse();
191 Ok(rows
192 .into_iter()
193 .map(|row| {
194 let stored = StoredVersion {
195 version: row.version.clone(),
196 manifest: row.meta(),
197 deprecated: row.deprecated.clone(),
198 published_at: row.published_at.clone(),
199 };
200 (row, stored)
201 })
202 .collect())
203 }
204
205 async fn tag_pairs(&self, package: &PackageRow, versions: &[(VersionRow, StoredVersion)]) -> Result<Vec<(String, String)>> {
206 let by_id: HashMap<&str, &str> = versions.iter().map(|(row, _)| (row.id.as_str(), row.version.as_str())).collect();
207 Ok(self
208 .db
209 .tags(&package.id)
210 .await?
211 .into_iter()
212 .filter_map(|tag| by_id.get(tag.version_id.as_str()).map(|version| (tag.tag, (*version).to_owned())))
213 .collect())
214 }
215
216 async fn packument(&self, request: &Request, url: &Url, name: &NpmName, viewer: Option<&User>) -> Result<Response> {
217 let Some(package) = self.npm_package(name).await? else {
218 return not_found();
219 };
220 if let Some(refusal) = self.npm_check(viewer, &package, Action::Pull) {
221 return refusal;
222 }
223 let versions = self.stored_versions(&package).await?;
224 let tags = self.tag_pairs(&package, &versions).await?;
225 let abbreviated = request
226 .headers()
227 .get("accept")?
228 .is_some_and(|accept| accept.contains(npm::ABBREVIATED))
229 && url.query_pairs().all(|(k, _)| k != "write");
230 let readme = match (abbreviated, self.db.readme_digest(&package.id).await?.and_then(|d| Digest::parse(&d))) {
231 (false, Some(digest)) => match self.db.blob(&digest).await? {
232 Some(blob) => self.store.read(&blob.object_key).await?.map(|b| String::from_utf8_lossy(&b).into_owned()),
233 None => None,
234 },
235 _ => None,
236 };
237 let stored: Vec<StoredVersion> = versions.into_iter().map(|(_, stored)| stored).collect();
238 let base = format!("{}/-/npm", origin(url));
239 let packument = Packument {
240 name,
241 versions: &stored,
242 tags: &tags,
243 created: &package.created_at,
244 modified: &package.updated_at,
245 readme: readme.as_deref(),
246 base: &base,
247 };
248 if abbreviated {
249 let mut response = Response::from_json(&packument.abbreviated())?;
250 response.headers_mut().set("content-type", npm::ABBREVIATED)?;
251 return Ok(response);
252 }
253 Response::from_json(&packument.full())
254 }
255
256 async fn tarball(&self, name: &NpmName, version: &str, viewer: Option<&User>, head: bool, ctx: &Context) -> Result<Response> {
257 let Some(package) = self.npm_package(name).await? else {
258 return not_found();
259 };
260 if let Some(refusal) = self.npm_check(viewer, &package, Action::Pull) {
261 return refusal;
262 }
263 let Some(row) = self.db.version_named(&package.id, version).await? else {
264 return error(404, format!("{}@{version} is not there.", name.full()));
265 };
266 let Some(digest) = Digest::parse(&row.digest) else {
267 return error(404, format!("{}@{version} is not there.", name.full()));
268 };
269 let Some(blob) = self.db.package_blob(&package.id, &digest).await? else {
270 return error(404, format!("{}@{version} is not there.", name.full()));
271 };
272 let headers = Headers::new();
273 headers.set("content-type", "application/octet-stream")?;
274 headers.set("content-length", &blob.size.to_string())?;
275 headers.set("cache-control", "max-age=31536000")?;
276 if head {
277 return Ok(Response::from_body(ResponseBody::Empty)?.with_headers(headers));
278 }
279 let Some(got) = self.store.get(&blob.object_key, None).await? else {
280 return error(404, format!("{}@{version} is not there.", name.full()));
281 };
282 self.count_download(&package.id, ctx);
283 Ok(Response::from_body(got.body)?.with_headers(headers))
284 }
285
286 /// The package publishing makes, linked to the repository its
287 /// `package.json` names on g1t, or else the one named like it.
288 async fn npm_target(&self, name: &NpmName, manifest: &Value) -> Result<TargetOf> {
289 let named = npm::repository_of(&manifest["repository"], &self.host)
290 .filter(|(workspace, _)| workspace == &name.workspace)
291 .map(|(_, repo)| repo);
292 let mut repo = None;
293 for candidate in named.iter().map(String::as_str).chain([name.name.as_str()]) {
294 if let Some(found) = self.repo_by_name(&name.workspace, candidate).await? {
295 repo = Some(found);
296 break;
297 }
298 }
299 Ok(TargetOf {
300 workspace: name.workspace.clone(),
301 repo: repo.map(|r| (r.id, r.name, r.is_private)),
302 public: false,
303 })
304 }
305
306 /// A `PUT` of the packument: a publish when a tarball is attached,
307 /// otherwise npm's change of an existing one (deprecate, tags, a
308 /// version unpublished).
309 async fn npm_put(&self, request: &mut Request, name: &NpmName, viewer: Option<&User>) -> Result<Response> {
310 let declared = request.headers().get("content-length")?.and_then(|n| n.parse::<u64>().ok());
311 let too_large = || {
312 let mb = self.max_request / 1_000_000;
313 error(413, format!("A publish may be at most {mb} MB, tarball and package.json together. See {DOCS}#size"))
314 };
315 if declared.is_some_and(|n| n > self.max_request) {
316 return too_large();
317 }
318 let bytes = request.bytes().await?;
319 if bytes.len() as u64 > self.max_request {
320 return too_large();
321 }
322 let Ok(body) = serde_json::from_slice::<Value>(&bytes) else {
323 return error(400, "The body is not JSON.");
324 };
325 if body["name"].as_str().is_some_and(|sent| sent != name.full()) {
326 return error(400, format!("The package's name is {}, not {}.", body["name"].as_str().unwrap_or(""), name.full()));
327 }
328 let attached = body["_attachments"].as_object().is_some_and(|a| !a.is_empty());
329 if attached {
330 self.publish(name, &body, viewer).await
331 } else {
332 self.change(name, &body, viewer).await
333 }
334 }
335
336 async fn publish(&self, name: &NpmName, body: &Value, viewer: Option<&User>) -> Result<Response> {
337 let Some(versions) = body["versions"].as_object().filter(|v| v.len() == 1) else {
338 return error(400, "A publish names exactly one version.");
339 };
340 let (version, manifest) = versions.iter().next().map(|(v, m)| (v.clone(), m.clone())).unwrap_or_default();
341 if !npm::valid_version(&version) {
342 return error(400, format!("{version} is not a semver version."));
343 }
344 let Some((_, attachment)) = body["_attachments"].as_object().and_then(|a| a.iter().next()) else {
345 return error(400, "The tarball is missing.");
346 };
347 let Some(tarball) = attachment["data"].as_str().and_then(|data| STANDARD.decode(data).ok()) else {
348 return error(400, "The tarball is not base64.");
349 };
350 if attachment["length"].as_u64().is_some_and(|length| length != tarball.len() as u64) {
351 return error(400, "The tarball's length is not what the publish says.");
352 }
353 let computed = npm::integrity(&tarball);
354 let dist = &manifest["dist"];
355 if !npm::agrees(&computed, dist["integrity"].as_str(), dist["shasum"].as_str()) {
356 return error(400, "The tarball's integrity is not what the publish says. Publish again.");
357 }
358
359 let found = self.npm_package(name).await?;
360 if found.is_none() && self.db.workspace_hidden(&name.workspace).await? {
361 return error(403, format!("The workspace {} is deleted; nothing can be published to it.", name.workspace));
362 }
363 let target = match &found {
364 Some(package) => TargetOf::package(package),
365 None => self.npm_target(name, &manifest).await?,
366 };
367 let decision = access::decide(viewer, &target.view(), Action::Push);
368 if !decision.allowed {
369 let readable = access::decide(viewer, &target.view(), Action::Pull).allowed || found.is_none();
370 return refused(decision, readable);
371 }
372 let caller = Caller { actor: viewer.map(AuditActor::of) };
373 let package = match found {
374 Some(package) => package,
375 None => {
376 self.db
377 .create_package(
378 &new_id("pkg", now_ms()),
379 &name.workspace,
380 NPM,
381 &name.name,
382 target.repo.as_ref().map(|(id, repo, private)| (id.as_str(), repo.as_str(), *private)),
383 caller.actor.as_ref().map_or("", |actor| actor.actor_id.as_str()),
384 now_ms(),
385 )
386 .await?
387 }
388 };
389 if self.db.version_named(&package.id, &version).await?.is_some() {
390 return error(403, format!("You cannot publish over the previously published version {version}. Bump the version in package.json."));
391 }
392
393 let digest = Digest::of(&tarball);
394 let size = tarball.len() as u64;
395 if let Some(refusal) = self.storage_refusal(&package, &[(digest.to_string(), size)]).await? {
396 return error(403, refusal);
397 }
398 let now = now_ms();
399 let stored = match self.db.blob(&digest).await? {
400 Some(blob) => self.store.head(&blob.object_key).await?.is_some(),
401 None => false,
402 };
403 if !stored {
404 self.store.put(&digest.object_key(), tarball).await?;
405 }
406 self.db
407 .keep_blob(&package.id, &digest, size, Some("application/octet-stream"), &digest.object_key(), now)
408 .await?;
409
410 // The dist-tags that point to this version; `latest` when none.
411 let mut tags: Vec<String> = body["dist-tags"]
412 .as_object()
413 .map(|tags| {
414 tags.iter()
415 .filter(|(tag, v)| v.as_str() == Some(version.as_str()) && npm::valid_tag(tag))
416 .map(|(tag, _)| tag.clone())
417 .collect()
418 })
419 .unwrap_or_default();
420 if tags.is_empty() {
421 tags.push("latest".to_owned());
422 }
423 let (row, _) = self
424 .db
425 .publish(
426 NewVersion {
427 id: new_id("ver", now),
428 package_id: package.id.clone(),
429 version: version.clone(),
430 digest: digest.to_string(),
431 size,
432 metadata: npm::stored_manifest(&manifest, &computed).to_string(),
433 subject: None,
434 published_by: published_by(&caller),
435 files: vec![NewFile {
436 name: "tarball".to_owned(),
437 digest: digest.to_string(),
438 size,
439 media_type: Some("application/octet-stream".to_owned()),
440 }],
441 },
442 Some(&tags[0]),
443 now,
444 )
445 .await?;
446 for tag in &tags[1..] {
447 self.db.set_tag(&package.id, tag, &row.id, now).await?;
448 }
449 // The README the package page shows: the latest version's.
450 if tags.iter().any(|tag| tag == "latest") {
451 let readme = body["readme"].as_str().or(manifest["readme"].as_str()).unwrap_or("").trim();
452 let description = manifest["description"].as_str();
453 let readme_digest = if readme.is_empty() || readme == "ERROR: No README data found!" || readme.len() > MAX_README_BYTES {
454 None
455 } else {
456 let bytes = readme.as_bytes().to_vec();
457 let digest = Digest::of(&bytes);
458 if self.db.blob(&digest).await?.is_none() {
459 self.store.put(&digest.object_key(), bytes.clone()).await?;
460 }
461 self.db
462 .keep_blob(&package.id, &digest, bytes.len() as u64, Some("text/markdown"), &digest.object_key(), now)
463 .await?;
464 Some(digest.to_string())
465 };
466 self.db.set_readme(&package.id, readme_digest.as_deref(), description, now).await?;
467 }
468 self.db.measure(&package.workspace).await?;
469 let event = PackageEvent {
470 version: Some(version.clone()),
471 digest: Some(digest.to_string()),
472 size: Some(size),
473 tags: Some(tags.clone()),
474 ..self.event_of(&package)
475 };
476 self.announce("package.published", &package, event, &caller).await;
477 self.audit(&caller, "package.publish", &package, Some(&format!("{}@{version}", name.full())), None).await;
478 Ok(Response::from_json(&json!({ "ok": true, "id": name.full(), "rev": format!("1-{}", &digest.hex()[..16]) }))?.with_status(201))
479 }
480
481 /// npm's change of a packument it read: versions it left out are
482 /// unpublished, `deprecated` set or cleared, dist-tags made to match.
483 async fn change(&self, name: &NpmName, body: &Value, viewer: Option<&User>) -> Result<Response> {
484 let Some(package) = self.npm_package(name).await? else {
485 return not_found();
486 };
487 if let Some(refusal) = self.npm_check(viewer, &package, Action::Push) {
488 return refusal;
489 }
490 let Some(sent) = body["versions"].as_object() else {
491 return error(400, "The packument names no versions.");
492 };
493 let caller = Caller { actor: viewer.map(AuditActor::of) };
494 let admin = access::decide(viewer, &TargetOf::package(&package).view(), Action::Delete).allowed;
495 let now = now_ms();
496 let versions = self.stored_versions(&package).await?;
497 let removed: Vec<&VersionRow> = versions.iter().map(|(row, _)| row).filter(|row| !sent.contains_key(&row.version)).collect();
498 if let Some(late) = removed.iter().find(|row| !npm::may_unpublish(published_ms(row), now, admin)) {
499 return error(
500 403,
501 format!("{}@{} was published more than 72 hours ago: unpublishing it needs the Admin role. Deprecate it instead.", name.full(), late.version),
502 );
503 }
504 for (row, stored) in &versions {
505 let Some(version) = sent.get(&row.version) else { continue };
506 let wanted = version["deprecated"].as_str().filter(|m| !m.is_empty()).map(str::to_owned);
507 if wanted != stored.deprecated {
508 self.db.set_deprecated(&row.id, wanted.as_deref()).await?;
509 let action = if wanted.is_some() { "package.deprecate" } else { "package.undeprecate" };
510 self.audit(&caller, action, &package, Some(&format!("{}@{}", name.full(), row.version)), None).await;
511 }
512 }
513 for row in &removed {
514 self.remove_version(&package, row, &caller).await?;
515 }
516 // The dist-tags, as sent, for the versions that are left.
517 if let Some(tags) = body["dist-tags"].as_object() {
518 let left: HashMap<&str, &str> = versions
519 .iter()
520 .filter(|(row, _)| sent.contains_key(&row.version))
521 .map(|(row, _)| (row.version.as_str(), row.id.as_str()))
522 .collect();
523 let current = self.tag_pairs(&package, &versions).await?;
524 let mut wanted = HashSet::new();
525 for (tag, version) in tags {
526 let Some(id) = version.as_str().and_then(|v| left.get(v)) else { continue };
527 wanted.insert(tag.as_str());
528 if !current.iter().any(|(t, v)| t == tag && Some(v.as_str()) == version.as_str()) {
529 self.db.set_tag(&package.id, tag, id, now).await?;
530 }
531 }
532 for (tag, _) in current.iter().filter(|(tag, _)| !wanted.contains(tag.as_str())) {
533 self.db.delete_tag(&package.id, tag).await?;
534 }
535 }
536 self.db.touch_package(&package.id, now).await?;
537 ok()
538 }
539
540 async fn unpublish_package(&self, name: &NpmName, viewer: Option<&User>) -> Result<Response> {
541 let Some(package) = self.npm_package(name).await? else {
542 return not_found();
543 };
544 if let Some(refusal) = self.npm_check(viewer, &package, Action::Push) {
545 return refusal;
546 }
547 let admin = access::decide(viewer, &TargetOf::package(&package).view(), Action::Delete).allowed;
548 let now = now_ms();
549 let versions = self.db.versions(&package.id, MAX_VERSIONS).await?;
550 if let Some(late) = versions.iter().find(|row| !npm::may_unpublish(published_ms(row), now, admin)) {
551 return error(
552 403,
553 format!("{}@{} was published more than 72 hours ago: unpublishing the package needs the Admin role.", name.full(), late.version),
554 );
555 }
556 let caller = Caller { actor: viewer.map(AuditActor::of) };
557 self.db.delete_package(&package.id).await?;
558 self.db.measure(&package.workspace).await?;
559 self.announce("package.deleted", &package, self.event_of(&package), &caller).await;
560 self.audit(&caller, "package.delete", &package, Some(&name.full()), None).await;
561 ok()
562 }
563
564 /// The last step of `npm unpublish <name>@<version>`: the packument
565 /// without the version was sent first, so usually it is gone already.
566 async fn unpublish_tarball(&self, name: &NpmName, version: &str, viewer: Option<&User>) -> Result<Response> {
567 let Some(package) = self.npm_package(name).await? else {
568 return ok();
569 };
570 if let Some(refusal) = self.npm_check(viewer, &package, Action::Push) {
571 return refusal;
572 }
573 let Some(row) = self.db.version_named(&package.id, version).await? else {
574 return ok();
575 };
576 let admin = access::decide(viewer, &TargetOf::package(&package).view(), Action::Delete).allowed;
577 if !npm::may_unpublish(published_ms(&row), now_ms(), admin) {
578 return error(403, format!("{}@{version} was published more than 72 hours ago: unpublishing it needs the Admin role.", name.full()));
579 }
580 let caller = Caller { actor: viewer.map(AuditActor::of) };
581 self.remove_version(&package, &row, &caller).await?;
582 ok()
583 }
584
585 async fn dist_tags(&self, request: &mut Request, method: Method, name: &NpmName, tag: Option<&str>, viewer: Option<&User>) -> Result<Response> {
586 let Some(package) = self.npm_package(name).await? else {
587 return not_found();
588 };
589 let action = if matches!(method, Method::Get | Method::Head) { Action::Pull } else { Action::Push };
590 if let Some(refusal) = self.npm_check(viewer, &package, action) {
591 return refusal;
592 }
593 let versions = self.stored_versions(&package).await?;
594 match (method, tag) {
595 (Method::Get | Method::Head, None) => {
596 let tags = self.tag_pairs(&package, &versions).await?;
597 Response::from_json(&Value::Object(tags.into_iter().map(|(t, v)| (t, json!(v))).collect()))
598 }
599 (Method::Put | Method::Post, Some(tag)) => {
600 if !npm::valid_tag(tag) {
601 return error(400, format!("{tag} is not a valid tag: it may not look like a version."));
602 }
603 let body: Value = request.json().await.unwrap_or_default();
604 let Some(version) = body.as_str() else {
605 return error(400, "Send the version the tag points to, as a JSON string.");
606 };
607 let Some((row, _)) = versions.iter().find(|(row, _)| row.version == version) else {
608 return error(404, format!("{}@{version} is not there.", name.full()));
609 };
610 self.db.set_tag(&package.id, tag, &row.id, now_ms()).await?;
611 self.db.touch_package(&package.id, now_ms()).await?;
612 ok()
613 }
614 (Method::Delete, Some(tag)) => {
615 if tag == "latest" {
616 return error(400, "The latest tag cannot be removed; point it at another version instead.");
617 }
618 self.db.delete_tag(&package.id, tag).await?;
619 self.db.touch_package(&package.id, now_ms()).await?;
620 ok()
621 }
622 _ => error(405, "Not a method this address takes."),
623 }
624 }
625}