| 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 | |
| 16 | use std::collections::{HashMap, HashSet}; |
| 17 | |
| 18 | use base64::Engine; |
| 19 | use base64::engine::general_purpose::STANDARD; |
| 20 | use g1t_contracts::User; |
| 21 | use g1t_contracts::audit::AuditActor; |
| 22 | use g1t_contracts::events::PackageEvent; |
| 23 | use g1t_contracts::new_id; |
| 24 | use g1t_contracts::time::parse_rfc3339; |
| 25 | use g1t_kit::now_ms; |
| 26 | use serde_json::{Value, json}; |
| 27 | use worker::{Context, Headers, Method, Request, Response, ResponseBody, Result, Url}; |
| 28 | |
| 29 | use crate::access::{self, Action}; |
| 30 | use crate::db::{NewFile, NewVersion, PackageRow, VersionRow}; |
| 31 | use crate::digest::Digest; |
| 32 | use crate::npm::{self, NpmName, NpmRoute, Packument, StoredVersion}; |
| 33 | use crate::oci::{Credentials, origin, published_by}; |
| 34 | use crate::store::BlobStore; |
| 35 | use crate::{Caller, Packages, TargetOf}; |
| 36 | |
| 37 | const NPM: &str = "npm"; |
| 38 | /// The most versions a packument lists. |
| 39 | const MAX_VERSIONS: u32 = 2000; |
| 40 | /// The longest README kept for a package's page. |
| 41 | const MAX_README_BYTES: usize = 1024 * 1024; |
| 42 | const DOCS: &str = "https://docs.g1t.sh/guides/npm/"; |
| 43 | |
| 44 | /// npm's error shape: `{"error": "..."}`, which it prints. |
| 45 | fn 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 | |
| 53 | fn ok() -> Result<Response> { |
| 54 | Response::from_json(&json!({ "ok": true })) |
| 55 | } |
| 56 | |
| 57 | fn 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. |
| 63 | fn 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. |
| 71 | fn published_ms(version: &VersionRow) -> u64 { |
| 72 | parse_rfc3339(&version.published_at).unwrap_or(0) |
| 73 | } |
| 74 | |
| 75 | impl 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 | } |