Skip to content

g1t/services/packages/src/npm_http.rs

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