| 1 | //! The Maven repository: `g1t.sh/-/maven/<workspace>/`, one for each |
| 2 | //! workspace, in the standard layout. `mvn deploy` and Gradle's `publish` |
| 3 | //! upload each file with a `PUT` and Basic credentials (any username, a g1t |
| 4 | //! token as the password); a `Bearer` token works too, for Gradle's header |
| 5 | //! credentials. |
| 6 | //! |
| 7 | //! Files are kept once, by their SHA-256, with their MD5, SHA-1 and SHA-512 |
| 8 | //! worked out as they arrive, so the checksum files beside them are |
| 9 | //! answered without reading them again; checksums uploaded are checked |
| 10 | //! against them, not kept. `maven-metadata.xml` is made from the versions |
| 11 | //! on each read: an uploaded one is taken and let go. |
| 12 | //! |
| 13 | //! A release's files are written once. A SNAPSHOT's builds arrive as |
| 14 | //! timestamped files beside each other, and its metadata names the newest. |
| 15 | //! The POM is the version's record. The artifact's `maven-metadata.xml`, which |
| 16 | //! Maven and Gradle upload last, publishes what the deploy brought, as an |
| 17 | //! event and an audit entry. |
| 18 | |
| 19 | use g1t_contracts::User; |
| 20 | use g1t_contracts::audit::AuditActor; |
| 21 | use g1t_contracts::events::PackageEvent; |
| 22 | use g1t_contracts::new_id; |
| 23 | use g1t_kit::now_ms; |
| 24 | use serde_json::{Value, json}; |
| 25 | use worker::{Context, Headers, Method, Request, Response, ResponseBody, Result}; |
| 26 | |
| 27 | use crate::access::{self, Action}; |
| 28 | use crate::archive; |
| 29 | use crate::db::{Checksums, NewFile, NewVersion, PackageRow, VersionRow}; |
| 30 | use crate::digest::Digest; |
| 31 | use crate::maven::{self, Checksum, MavenPath}; |
| 32 | use crate::npm; |
| 33 | use crate::oci::{Credentials, published_by}; |
| 34 | use crate::store::BlobStore; |
| 35 | use crate::{Caller, Packages, TargetOf}; |
| 36 | |
| 37 | const MAVEN: &str = "maven"; |
| 38 | /// The most versions an artifact's metadata lists. |
| 39 | const MAX_VERSIONS: u32 = 5000; |
| 40 | /// The longest POM read for its description and source. |
| 41 | const MAX_POM_BYTES: usize = 1024 * 1024; |
| 42 | /// The most artifacts of a group read for its plugins. |
| 43 | const MAX_GROUP: u32 = 500; |
| 44 | /// Where a plugin's jar keeps its descriptor, and the most read of it. |
| 45 | const PLUGIN_DESCRIPTOR: &str = "META-INF/maven/plugin.xml"; |
| 46 | const MAX_DESCRIPTOR_BYTES: usize = 4 * 1024 * 1024; |
| 47 | const DOCS: &str = "https://docs.g1t.sh/guides/maven/"; |
| 48 | const TOKENS: &str = "https://g1t.sh/settings/tokens"; |
| 49 | |
| 50 | /// A plain-text answer. Maven and Gradle print the status; the text says |
| 51 | /// why for anyone reading the response. |
| 52 | fn error(status: u16, message: impl Into<String>) -> Result<Response> { |
| 53 | let mut response = Response::ok(format!("{}\n", message.into()))?.with_status(status); |
| 54 | response.headers_mut().set("content-type", "text/plain; charset=utf-8")?; |
| 55 | if status == 401 { |
| 56 | response.headers_mut().set("www-authenticate", "Basic realm=\"g1t\"")?; |
| 57 | } |
| 58 | Ok(response) |
| 59 | } |
| 60 | |
| 61 | fn created() -> Result<Response> { |
| 62 | Ok(Response::empty()?.with_status(201)) |
| 63 | } |
| 64 | |
| 65 | fn sign_in() -> String { |
| 66 | format!("Sign in to use this repository: Basic credentials with a g1t access token from {TOKENS} as the password. See {DOCS}") |
| 67 | } |
| 68 | |
| 69 | /// A file or document as Maven reads it; a `HEAD` gets its headers alone. |
| 70 | fn serve(bytes: Vec<u8>, media_type: &str, head: bool, cache: &str) -> Result<Response> { |
| 71 | let headers = Headers::new(); |
| 72 | headers.set("content-type", media_type)?; |
| 73 | headers.set("content-length", &bytes.len().to_string())?; |
| 74 | headers.set("cache-control", cache)?; |
| 75 | let body = if head { ResponseBody::Empty } else { ResponseBody::Body(bytes) }; |
| 76 | Ok(Response::from_body(body)?.with_headers(headers)) |
| 77 | } |
| 78 | |
| 79 | impl Packages { |
| 80 | /// Answers a Maven request. |
| 81 | pub async fn maven(&self, request: Request, ctx: &Context) -> Result<Response> { |
| 82 | let url = request.url()?; |
| 83 | let Some((workspace, path)) = maven::route(url.path()) else { |
| 84 | return error(404, "There is nothing at this address."); |
| 85 | }; |
| 86 | match self.maven_route(request, &workspace, path, ctx).await { |
| 87 | Ok(response) => Ok(response), |
| 88 | Err(problem) => { |
| 89 | worker::console_error!("packages: maven {}: {problem}", url.path()); |
| 90 | error(500, "Something went wrong on our side. Try again in a moment.") |
| 91 | } |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | async fn maven_route(&self, mut request: Request, workspace: &str, path: MavenPath, ctx: &Context) -> Result<Response> { |
| 96 | let method = request.method(); |
| 97 | let credentials = self.credentials(&request).await?; |
| 98 | let read = matches!(method, Method::Get | Method::Head); |
| 99 | if read && let Some(refused) = self.limited(&request, &credentials, "Basic credentials with a g1t token").await? { |
| 100 | return Ok(refused); |
| 101 | } |
| 102 | let viewer = match credentials { |
| 103 | Credentials::Viewer(viewer) => viewer, |
| 104 | Credentials::None => None, |
| 105 | Credentials::Token(_) | Credentials::Bad => { |
| 106 | return error(401, format!("The token is not right, or has expired. Make an access token at {TOKENS}.")); |
| 107 | } |
| 108 | }; |
| 109 | let viewer = viewer.as_ref(); |
| 110 | let head = method == Method::Head; |
| 111 | match (path, method) { |
| 112 | (MavenPath::ArtifactMetadata { group, artifact, checksum }, Method::Get | Method::Head) => { |
| 113 | self.maven_metadata(workspace, &group, &artifact, None, checksum, viewer, head).await |
| 114 | } |
| 115 | (MavenPath::GroupMetadata { group, checksum }, Method::Get | Method::Head) => { |
| 116 | self.maven_group_metadata(workspace, &group, None, checksum, viewer, head).await |
| 117 | } |
| 118 | (MavenPath::VersionMetadata { group, artifact, version, checksum }, Method::Get | Method::Head) => { |
| 119 | self.maven_metadata(workspace, &group, &artifact, Some(&version), checksum, viewer, head).await |
| 120 | } |
| 121 | (MavenPath::File { group, artifact, version, file, checksum }, Method::Get | Method::Head) => { |
| 122 | self.maven_file(workspace, &maven::package_name(&group, &artifact), &version, &file, checksum, viewer, head, ctx) |
| 123 | .await |
| 124 | } |
| 125 | (MavenPath::File { group, artifact, version, file, checksum: None }, Method::Put) => { |
| 126 | self.maven_upload(&mut request, workspace, &group, &artifact, &version, &file, viewer).await |
| 127 | } |
| 128 | (MavenPath::File { group, artifact, version, file, checksum: Some(checksum) }, Method::Put) => { |
| 129 | let name = maven::package_name(&group, &artifact); |
| 130 | self.maven_checksum(&mut request, workspace, &name, &version, &file, checksum, viewer).await |
| 131 | } |
| 132 | (path @ (MavenPath::ArtifactMetadata { .. } | MavenPath::VersionMetadata { .. } | MavenPath::GroupMetadata { .. }), Method::Put) => { |
| 133 | self.maven_metadata_upload(&mut request, workspace, &path, viewer).await |
| 134 | } |
| 135 | _ => error(405, "Not a method this address takes. Versions are deleted on the package's page."), |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | async fn maven_package(&self, workspace: &str, name: &str) -> Result<Option<PackageRow>> { |
| 140 | Ok(self.db.package(workspace, MAVEN, name).await?.filter(|p| !p.hidden())) |
| 141 | } |
| 142 | |
| 143 | /// The answer for something that is not there: to someone not signed |
| 144 | /// in, a 401 when the workspace has private artifacts (so Maven sends |
| 145 | /// its credentials and asks again, and a private artifact looks like a |
| 146 | /// missing one), else a 404. |
| 147 | async fn maven_absent(&self, workspace: &str, viewer: Option<&User>) -> Result<Response> { |
| 148 | if viewer.is_none() && self.db.has_private(workspace, MAVEN).await? { |
| 149 | return error(401, sign_in()); |
| 150 | } |
| 151 | error(404, "Not found: no such artifact or file, or you cannot see it.") |
| 152 | } |
| 153 | |
| 154 | /// Whether `viewer` may `action` the artifact, as the answer when not. |
| 155 | async fn maven_check(&self, viewer: Option<&User>, package: &PackageRow, action: Action) -> Result<Option<Response>> { |
| 156 | let target = TargetOf::package(package); |
| 157 | let decision = access::decide(viewer, &target.view(), action); |
| 158 | if decision.allowed { |
| 159 | return Ok(None); |
| 160 | } |
| 161 | let readable = action != Action::Pull && access::decide(viewer, &target.view(), Action::Pull).allowed; |
| 162 | if !readable && viewer.is_none() { |
| 163 | return Ok(Some(error(401, sign_in())?)); |
| 164 | } |
| 165 | if !readable { |
| 166 | return Ok(Some(self.maven_absent(&package.workspace, viewer).await?)); |
| 167 | } |
| 168 | Ok(Some(error(403, decision.reason.unwrap_or_else(|| "Not allowed.".to_owned()))?)) |
| 169 | } |
| 170 | |
| 171 | /// `maven-metadata.xml` of an artifact, or of one of its SNAPSHOTs, or |
| 172 | /// a checksum of it. |
| 173 | #[allow(clippy::too_many_arguments)] |
| 174 | async fn maven_metadata( |
| 175 | &self, |
| 176 | workspace: &str, |
| 177 | group: &str, |
| 178 | artifact: &str, |
| 179 | snapshot: Option<&str>, |
| 180 | checksum: Option<Checksum>, |
| 181 | viewer: Option<&User>, |
| 182 | head: bool, |
| 183 | ) -> Result<Response> { |
| 184 | let found = self.maven_package(workspace, &maven::package_name(group, artifact)).await?; |
| 185 | // `com/acme/plugins/maven-metadata.xml` is also the group |
| 186 | // `com.acme.plugins`'s, which lists its plugins. |
| 187 | let Some(package) = found else { |
| 188 | if snapshot.is_none() { |
| 189 | return self.maven_group_metadata(workspace, &format!("{group}.{artifact}"), None, checksum, viewer, head).await; |
| 190 | } |
| 191 | return self.maven_absent(workspace, viewer).await; |
| 192 | }; |
| 193 | if let Some(refusal) = self.maven_check(viewer, &package, Action::Pull).await? { |
| 194 | return Ok(refusal); |
| 195 | } |
| 196 | let xml = match snapshot { |
| 197 | None => { |
| 198 | let versions: Vec<String> = self.db.versions(&package.id, MAX_VERSIONS).await?.into_iter().map(|v| v.version).collect(); |
| 199 | if versions.is_empty() { |
| 200 | return self.maven_absent(workspace, viewer).await; |
| 201 | } |
| 202 | let xml = maven::artifact_metadata(group, artifact, &versions, &package.updated_at); |
| 203 | return self.maven_group_metadata(workspace, &format!("{group}.{artifact}"), Some(xml), checksum, viewer, head).await; |
| 204 | } |
| 205 | Some(version) => { |
| 206 | let Some(row) = self.db.version_named(&package.id, version).await? else { |
| 207 | return self.maven_absent(workspace, viewer).await; |
| 208 | }; |
| 209 | let files: Vec<String> = self.db.files(&row.id).await?.into_iter().map(|f| f.name).collect(); |
| 210 | let Some(xml) = maven::snapshot_metadata(group, artifact, version, &files) else { |
| 211 | return self.maven_absent(workspace, viewer).await; |
| 212 | }; |
| 213 | xml |
| 214 | } |
| 215 | }; |
| 216 | match checksum { |
| 217 | Some(checksum) => serve(checksum.of(xml.as_bytes()).into_bytes(), "text/plain", head, "no-cache"), |
| 218 | None => serve(xml.into_bytes(), "application/xml", head, "no-cache"), |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | /// A group's `maven-metadata.xml`: the plugins among its artifacts the |
| 223 | /// viewer may see, by prefix, so `mvn <prefix>:<goal>` finds them when |
| 224 | /// the group is one of its `<pluginGroups>`. `artifact` is the |
| 225 | /// metadata of an artifact at the same path, which it is added to. |
| 226 | async fn maven_group_metadata( |
| 227 | &self, |
| 228 | workspace: &str, |
| 229 | group: &str, |
| 230 | artifact: Option<String>, |
| 231 | checksum: Option<Checksum>, |
| 232 | viewer: Option<&User>, |
| 233 | head: bool, |
| 234 | ) -> Result<Response> { |
| 235 | let mut plugins = Vec::new(); |
| 236 | for package in self.db.maven_group(workspace, group, MAX_GROUP).await? { |
| 237 | if !access::decide(viewer, &TargetOf::package(&package).view(), Action::Pull).allowed { |
| 238 | continue; |
| 239 | } |
| 240 | let artifact_id = package.name.rsplit(':').next().unwrap_or("").to_owned(); |
| 241 | // The highest version that is a plugin says its prefix and name. |
| 242 | let mut versions = self.db.versions(&package.id, MAX_VERSIONS).await?; |
| 243 | versions.sort_by(|a, b| maven::compare(&b.version, &a.version)); |
| 244 | let Some(meta) = versions.iter().map(VersionRow::meta).find(|m| m["packaging"] == "maven-plugin" || m["plugin"].is_object()) else { |
| 245 | continue; |
| 246 | }; |
| 247 | let text = |value: &Value| value.as_str().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned); |
| 248 | plugins.push(maven::Plugin { |
| 249 | prefix: text(&meta["plugin"]["prefix"]).unwrap_or_else(|| maven::default_prefix(&artifact_id)), |
| 250 | name: text(&meta["name"]).or_else(|| text(&meta["plugin"]["name"])).unwrap_or_else(|| artifact_id.clone()), |
| 251 | artifact: artifact_id, |
| 252 | }); |
| 253 | } |
| 254 | let xml = match (artifact, plugins.is_empty()) { |
| 255 | (artifact, false) => maven::group_metadata(artifact, &plugins), |
| 256 | (Some(xml), true) => xml, |
| 257 | (None, true) => return self.maven_absent(workspace, viewer).await, |
| 258 | }; |
| 259 | match checksum { |
| 260 | Some(checksum) => serve(checksum.of(xml.as_bytes()).into_bytes(), "text/plain", head, "no-cache"), |
| 261 | None => serve(xml.into_bytes(), "application/xml", head, "no-cache"), |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | /// One of a version's files, or a checksum of it. |
| 266 | #[allow(clippy::too_many_arguments)] |
| 267 | async fn maven_file( |
| 268 | &self, |
| 269 | workspace: &str, |
| 270 | name: &str, |
| 271 | version: &str, |
| 272 | file: &str, |
| 273 | checksum: Option<Checksum>, |
| 274 | viewer: Option<&User>, |
| 275 | head: bool, |
| 276 | ctx: &Context, |
| 277 | ) -> Result<Response> { |
| 278 | let Some(package) = self.maven_package(workspace, name).await? else { |
| 279 | return self.maven_absent(workspace, viewer).await; |
| 280 | }; |
| 281 | if let Some(refusal) = self.maven_check(viewer, &package, Action::Pull).await? { |
| 282 | return Ok(refusal); |
| 283 | } |
| 284 | let Some(row) = self.db.version_named(&package.id, version).await? else { |
| 285 | return self.maven_absent(workspace, viewer).await; |
| 286 | }; |
| 287 | let Some(kept) = self.db.file(&row.id, file).await? else { |
| 288 | return self.maven_absent(workspace, viewer).await; |
| 289 | }; |
| 290 | let Some(digest) = Digest::parse(&kept.digest) else { |
| 291 | return self.maven_absent(workspace, viewer).await; |
| 292 | }; |
| 293 | let Some(blob) = self.db.package_blob(&package.id, &digest).await? else { |
| 294 | return self.maven_absent(workspace, viewer).await; |
| 295 | }; |
| 296 | // A release's files never change; a SNAPSHOT's are timestamped, so |
| 297 | // each name is one file too. |
| 298 | let cache = "max-age=31536000"; |
| 299 | if let Some(checksum) = checksum { |
| 300 | let sums = match self.db.checksums(&digest).await? { |
| 301 | Some(sums) => sums, |
| 302 | None => { |
| 303 | let Some(bytes) = self.store.read(&blob.object_key).await? else { |
| 304 | return self.maven_absent(workspace, viewer).await; |
| 305 | }; |
| 306 | let sums = Checksums::of(&bytes); |
| 307 | self.db.set_checksums(&digest, &sums).await?; |
| 308 | sums |
| 309 | } |
| 310 | }; |
| 311 | return serve(checksum.pick(&sums, digest.hex()).into_bytes(), "text/plain", head, cache); |
| 312 | } |
| 313 | let headers = Headers::new(); |
| 314 | headers.set("content-type", maven::media_type(file))?; |
| 315 | headers.set("content-length", &blob.size.to_string())?; |
| 316 | headers.set("cache-control", cache)?; |
| 317 | if head { |
| 318 | return Ok(Response::from_body(ResponseBody::Empty)?.with_headers(headers)); |
| 319 | } |
| 320 | let Some(got) = self.store.get(&blob.object_key, None).await? else { |
| 321 | return self.maven_absent(workspace, viewer).await; |
| 322 | }; |
| 323 | // A download is the artifact itself, not its POM, signature or |
| 324 | // Gradle module file, which are read beside it. |
| 325 | if let Some(parsed) = maven::parse_file(name.rsplit(':').next().unwrap_or(""), version, file) |
| 326 | && parsed.classifier.is_none() |
| 327 | && !matches!(parsed.extension.as_str(), "pom" | "module" | "asc") |
| 328 | && !parsed.extension.ends_with(".asc") |
| 329 | { |
| 330 | self.count_download(&package.id, ctx); |
| 331 | } |
| 332 | Ok(Response::from_body(got.body)?.with_headers(headers)) |
| 333 | } |
| 334 | |
| 335 | /// Who may upload to a new artifact: the repository its artifactId |
| 336 | /// names, else the workspace. |
| 337 | async fn maven_target(&self, workspace: &str, candidates: &[String]) -> Result<TargetOf> { |
| 338 | let mut repo = None; |
| 339 | for candidate in candidates { |
| 340 | if let Some(found) = self.repo_by_name(workspace, candidate).await? { |
| 341 | repo = Some(found); |
| 342 | break; |
| 343 | } |
| 344 | } |
| 345 | Ok(TargetOf { workspace: workspace.to_owned(), repo: repo.map(|r| (r.id, r.name, r.is_private)), public: false }) |
| 346 | } |
| 347 | |
| 348 | /// Whether `viewer` may upload to the artifact (made on its first |
| 349 | /// file), as the answer when not. |
| 350 | fn maven_refusal(&self, viewer: Option<&User>, target: &TargetOf, exists: bool) -> Option<Result<Response>> { |
| 351 | let decision = access::decide(viewer, &target.view(), Action::Push); |
| 352 | if decision.allowed { |
| 353 | return None; |
| 354 | } |
| 355 | if viewer.is_none() { |
| 356 | return Some(error(401, sign_in())); |
| 357 | } |
| 358 | if exists && !access::decide(viewer, &target.view(), Action::Pull).allowed { |
| 359 | return Some(error(404, "Not found: no such artifact, or you cannot see it.")); |
| 360 | } |
| 361 | Some(error(403, decision.reason.unwrap_or_else(|| "Not allowed.".to_owned()))) |
| 362 | } |
| 363 | |
| 364 | async fn maven_body(&self, request: &mut Request) -> Result<std::result::Result<Vec<u8>, Response>> { |
| 365 | let declared = request.headers().get("content-length")?.and_then(|n| n.parse::<u64>().ok()); |
| 366 | let too_large = || { |
| 367 | let mb = self.max_request / 1_000_000; |
| 368 | error(413, format!("A file may be at most {mb} MB. See {DOCS}#size")) |
| 369 | }; |
| 370 | if declared.is_some_and(|n| n > self.max_request) { |
| 371 | return Ok(Err(too_large()?)); |
| 372 | } |
| 373 | let bytes = request.bytes().await?; |
| 374 | if bytes.len() as u64 > self.max_request { |
| 375 | return Ok(Err(too_large()?)); |
| 376 | } |
| 377 | Ok(Ok(bytes)) |
| 378 | } |
| 379 | |
| 380 | /// A `PUT` of one of a version's files. |
| 381 | #[allow(clippy::too_many_arguments)] |
| 382 | async fn maven_upload( |
| 383 | &self, |
| 384 | request: &mut Request, |
| 385 | workspace: &str, |
| 386 | group: &str, |
| 387 | artifact: &str, |
| 388 | version: &str, |
| 389 | file: &str, |
| 390 | viewer: Option<&User>, |
| 391 | ) -> Result<Response> { |
| 392 | let bytes = match self.maven_body(request).await? { |
| 393 | Ok(bytes) => bytes, |
| 394 | Err(refused) => return Ok(refused), |
| 395 | }; |
| 396 | if bytes.is_empty() { |
| 397 | return error(400, format!("{file} is empty.")); |
| 398 | } |
| 399 | let Some(parsed) = maven::parse_file(artifact, version, file) else { |
| 400 | return error(400, format!("{file} is not a file of {artifact} {version}.")); |
| 401 | }; |
| 402 | let is_pom = parsed.classifier.is_none() && parsed.extension == "pom"; |
| 403 | let pom = if is_pom { |
| 404 | let pom = match maven::read_pom(&bytes[..bytes.len().min(MAX_POM_BYTES)]) { |
| 405 | Ok(pom) => pom, |
| 406 | Err(message) => return error(400, message), |
| 407 | }; |
| 408 | if pom.group != group || pom.artifact != artifact || pom.version != version { |
| 409 | return error( |
| 410 | 400, |
| 411 | format!( |
| 412 | "The POM says {}:{}:{}, but it was uploaded as {group}:{artifact}:{version}.", |
| 413 | pom.group, pom.artifact, pom.version |
| 414 | ), |
| 415 | ); |
| 416 | } |
| 417 | Some(pom) |
| 418 | } else { |
| 419 | None |
| 420 | }; |
| 421 | |
| 422 | // The main jar of a Maven plugin holds its descriptor. |
| 423 | let plugin = if parsed.classifier.is_none() && parsed.extension == "jar" { |
| 424 | archive::zip_entries(&bytes) |
| 425 | .ok() |
| 426 | .and_then(|entries| entries.into_iter().find(|e| e.name == PLUGIN_DESCRIPTOR)) |
| 427 | .and_then(|entry| archive::zip_read(&bytes, &entry, MAX_DESCRIPTOR_BYTES).ok()) |
| 428 | .and_then(|xml| maven::plugin_descriptor(&String::from_utf8_lossy(&xml))) |
| 429 | } else { |
| 430 | None |
| 431 | }; |
| 432 | |
| 433 | let name = maven::package_name(group, artifact); |
| 434 | let found = self.db.package(workspace, MAVEN, &name).await?; |
| 435 | if found.as_ref().is_some_and(PackageRow::hidden) || (found.is_none() && self.db.workspace_hidden(workspace).await?) { |
| 436 | return error(403, format!("The workspace {workspace} is deleted; nothing can be published to it.")); |
| 437 | } |
| 438 | let target = match &found { |
| 439 | Some(package) => TargetOf::package(package), |
| 440 | None => { |
| 441 | let lower = artifact.to_ascii_lowercase(); |
| 442 | self.maven_target(workspace, &[lower]).await? |
| 443 | } |
| 444 | }; |
| 445 | if let Some(refusal) = self.maven_refusal(viewer, &target, found.is_some()) { |
| 446 | return refusal; |
| 447 | } |
| 448 | let caller = Caller { actor: viewer.map(AuditActor::of) }; |
| 449 | let now = now_ms(); |
| 450 | let package = match found { |
| 451 | Some(package) => package, |
| 452 | None => { |
| 453 | self.db |
| 454 | .create_package( |
| 455 | &new_id("pkg", now), |
| 456 | workspace, |
| 457 | MAVEN, |
| 458 | &name, |
| 459 | target.repo.as_ref().map(|(id, repo, private)| (id.as_str(), repo.as_str(), *private)), |
| 460 | caller.actor.as_ref().map_or("", |actor| actor.actor_id.as_str()), |
| 461 | now, |
| 462 | ) |
| 463 | .await? |
| 464 | } |
| 465 | }; |
| 466 | |
| 467 | let digest = Digest::of(&bytes); |
| 468 | let size = bytes.len() as u64; |
| 469 | let (row, _) = self |
| 470 | .db |
| 471 | .version_or_new( |
| 472 | &NewVersion { |
| 473 | id: new_id("ver", now), |
| 474 | package_id: package.id.clone(), |
| 475 | version: version.to_owned(), |
| 476 | digest: digest.to_string(), |
| 477 | size: 0, |
| 478 | metadata: "{}".to_owned(), |
| 479 | subject: None, |
| 480 | published_by: published_by(&caller), |
| 481 | files: Vec::new(), |
| 482 | }, |
| 483 | now, |
| 484 | ) |
| 485 | .await?; |
| 486 | if let Some(kept) = self.db.file(&row.id, file).await? { |
| 487 | if kept.digest == digest.to_string() { |
| 488 | return created(); |
| 489 | } |
| 490 | if !maven::is_snapshot(version) { |
| 491 | return error( |
| 492 | 409, |
| 493 | format!("{file} is already published in {name} {version}, and a release's files are published once. Bump the version."), |
| 494 | ); |
| 495 | } |
| 496 | } |
| 497 | if let Some(refusal) = self.storage_refusal(&package, &[(digest.to_string(), size)]).await? { |
| 498 | return error(403, refusal); |
| 499 | } |
| 500 | let stored = match self.db.blob(&digest).await? { |
| 501 | Some(blob) => self.store.head(&blob.object_key).await?.is_some(), |
| 502 | None => false, |
| 503 | }; |
| 504 | let sums = Checksums::of(&bytes); |
| 505 | if !stored { |
| 506 | self.store.put(&digest.object_key(), bytes).await?; |
| 507 | } |
| 508 | let media_type = maven::media_type(file); |
| 509 | self.db.keep_blob(&package.id, &digest, size, Some(media_type), &digest.object_key(), now).await?; |
| 510 | self.db.set_checksums(&digest, &sums).await?; |
| 511 | self.db |
| 512 | .put_file(&package.id, &row.id, &NewFile { name: file.to_owned(), digest: digest.to_string(), size, media_type: Some(media_type.to_owned()) }, now) |
| 513 | .await?; |
| 514 | self.db.measure(&package.workspace).await?; |
| 515 | |
| 516 | if let Some(pom) = pom { |
| 517 | self.maven_pom(&package, &row, &digest, &pom, viewer).await?; |
| 518 | } else if let Some((prefix, title)) = plugin { |
| 519 | // A plugin's jar names the prefix it is called by. |
| 520 | let mut metadata = self.db.version_named(&package.id, version).await?.map(|v| v.meta()).unwrap_or_default(); |
| 521 | if !metadata.is_object() { |
| 522 | metadata = json!({}); |
| 523 | } |
| 524 | metadata["plugin"] = json!({ "prefix": prefix, "name": title }); |
| 525 | self.db.set_version(&row.id, &row.digest, &metadata.to_string()).await?; |
| 526 | } |
| 527 | created() |
| 528 | } |
| 529 | |
| 530 | /// The POM arrived: it is the version's record. Its description is |
| 531 | /// the package's when it is the highest release, and a new artifact |
| 532 | /// whose POM names its source on g1t is linked to that repository. |
| 533 | async fn maven_pom(&self, package: &PackageRow, row: &VersionRow, digest: &Digest, pom: &maven::Pom, viewer: Option<&User>) -> Result<()> { |
| 534 | let now = now_ms(); |
| 535 | let version = row.version.as_str(); |
| 536 | let mut metadata = row.meta(); |
| 537 | if !metadata.is_object() { |
| 538 | metadata = json!({}); |
| 539 | } |
| 540 | metadata["pom"] = json!(true); |
| 541 | metadata["name"] = json!(pom.name); |
| 542 | metadata["description"] = json!(pom.description); |
| 543 | metadata["source"] = json!(pom.source); |
| 544 | metadata["packaging"] = json!(pom.packaging); |
| 545 | self.db.set_version(&row.id, &digest.to_string(), &metadata.to_string()).await?; |
| 546 | let versions = self.db.versions(&package.id, MAX_VERSIONS).await?; |
| 547 | let releases: Vec<&str> = versions.iter().map(|v| v.version.as_str()).filter(|v| !maven::is_snapshot(v)).collect(); |
| 548 | let highest = if maven::is_snapshot(version) { |
| 549 | releases.is_empty() |
| 550 | } else { |
| 551 | releases.iter().all(|other| maven::compare(other, version) != std::cmp::Ordering::Greater) |
| 552 | }; |
| 553 | if highest { |
| 554 | self.db.set_readme(&package.id, None, pom.description.as_deref().or(pom.name.as_deref()), now).await?; |
| 555 | } |
| 556 | if package.repo_id.is_none() |
| 557 | && versions.len() == 1 |
| 558 | && let Some((owner, repo)) = pom.source.as_ref().and_then(|source| npm::repository_of(&Value::String(source.clone()), &self.host)) |
| 559 | && owner == package.workspace |
| 560 | && let Some(repo) = self.repo_by_name(&package.workspace, &repo).await? |
| 561 | { |
| 562 | let linked = TargetOf { |
| 563 | workspace: package.workspace.clone(), |
| 564 | repo: Some((repo.id.clone(), repo.name.clone(), repo.is_private)), |
| 565 | public: false, |
| 566 | }; |
| 567 | if access::decide(viewer, &linked.view(), Action::Push).allowed { |
| 568 | let visibility = if repo.is_private { "private" } else { "public" }; |
| 569 | self.db.set_link(&package.id, Some((&repo.id, &repo.name)), visibility, now).await?; |
| 570 | self.db.measure(&package.workspace).await?; |
| 571 | } |
| 572 | } |
| 573 | Ok(()) |
| 574 | } |
| 575 | |
| 576 | /// The artifact's `maven-metadata.xml` is what Maven and Gradle upload |
| 577 | /// last: each version (or SNAPSHOT build) whose POM arrived since the |
| 578 | /// last one is published now, with all its files, as an event and an |
| 579 | /// audit entry. |
| 580 | async fn maven_announce(&self, package: &PackageRow, caller: &Caller) -> Result<()> { |
| 581 | let artifact = package.name.rsplit(':').next().unwrap_or("").to_owned(); |
| 582 | for row in self.db.versions(&package.id, MAX_VERSIONS).await? { |
| 583 | let mut metadata = row.meta(); |
| 584 | if metadata["pom"] != json!(true) { |
| 585 | continue; |
| 586 | } |
| 587 | let mark = if maven::is_snapshot(&row.version) { |
| 588 | let files: Vec<String> = self.db.files(&row.id).await?.into_iter().map(|f| f.name).collect(); |
| 589 | match maven::newest_build(&artifact, &row.version, &files) { |
| 590 | Some((timestamp, build)) => format!("{timestamp}-{build}"), |
| 591 | None => "unique".to_owned(), |
| 592 | } |
| 593 | } else { |
| 594 | "release".to_owned() |
| 595 | }; |
| 596 | if metadata["published"].as_str() == Some(mark.as_str()) { |
| 597 | continue; |
| 598 | } |
| 599 | metadata["published"] = json!(mark); |
| 600 | self.db.set_version(&row.id, &row.digest, &metadata.to_string()).await?; |
| 601 | let event = PackageEvent { |
| 602 | version: Some(row.version.clone()), |
| 603 | digest: Some(row.digest.clone()), |
| 604 | size: Some(row.size), |
| 605 | ..self.event_of(package) |
| 606 | }; |
| 607 | self.announce("package.published", package, event, caller).await; |
| 608 | self.audit(caller, "package.publish", package, Some(&format!("{}/{}@{}", package.workspace, package.name, row.version)), None).await; |
| 609 | } |
| 610 | Ok(()) |
| 611 | } |
| 612 | |
| 613 | /// A checksum uploaded beside a file: checked against the file's. |
| 614 | #[allow(clippy::too_many_arguments)] |
| 615 | async fn maven_checksum( |
| 616 | &self, |
| 617 | request: &mut Request, |
| 618 | workspace: &str, |
| 619 | name: &str, |
| 620 | version: &str, |
| 621 | file: &str, |
| 622 | checksum: Checksum, |
| 623 | viewer: Option<&User>, |
| 624 | ) -> Result<Response> { |
| 625 | let bytes = match self.maven_body(request).await? { |
| 626 | Ok(bytes) => bytes, |
| 627 | Err(refused) => return Ok(refused), |
| 628 | }; |
| 629 | let Some(package) = self.maven_package(workspace, name).await? else { |
| 630 | return self.maven_absent(workspace, viewer).await; |
| 631 | }; |
| 632 | if let Some(refusal) = self.maven_check(viewer, &package, Action::Push).await? { |
| 633 | return Ok(refusal); |
| 634 | } |
| 635 | let Some(row) = self.db.version_named(&package.id, version).await? else { |
| 636 | return error(404, format!("Upload {file} before its checksum.")); |
| 637 | }; |
| 638 | let Some(kept) = self.db.file(&row.id, file).await? else { |
| 639 | return error(404, format!("Upload {file} before its checksum.")); |
| 640 | }; |
| 641 | let Some(digest) = Digest::parse(&kept.digest) else { |
| 642 | return error(404, format!("Upload {file} before its checksum.")); |
| 643 | }; |
| 644 | let Some(sums) = self.db.checksums(&digest).await? else { |
| 645 | return created(); |
| 646 | }; |
| 647 | if maven::sent_checksum(&bytes) != checksum.pick(&sums, digest.hex()) { |
| 648 | return error(400, format!("The {} checksum sent is not {file}'s. Upload the file again.", &checksum.suffix()[1..])); |
| 649 | } |
| 650 | created() |
| 651 | } |
| 652 | |
| 653 | /// An uploaded `maven-metadata.xml`, or its checksum: the repository |
| 654 | /// makes its own, so it is taken from anyone who may upload and let go. |
| 655 | /// The artifact's own (not a SNAPSHOT's, nor its checksum) ends a |
| 656 | /// deploy, and publishes what it brought. |
| 657 | async fn maven_metadata_upload(&self, request: &mut Request, workspace: &str, path: &MavenPath, viewer: Option<&User>) -> Result<Response> { |
| 658 | if let Err(refused) = self.maven_body(request).await? { |
| 659 | return Ok(refused); |
| 660 | } |
| 661 | let found = match path { |
| 662 | MavenPath::ArtifactMetadata { group, artifact, .. } | MavenPath::VersionMetadata { group, artifact, .. } | MavenPath::File { group, artifact, .. } => { |
| 663 | self.maven_package(workspace, &maven::package_name(group, artifact)).await? |
| 664 | } |
| 665 | MavenPath::GroupMetadata { .. } => None, |
| 666 | }; |
| 667 | let target = match &found { |
| 668 | Some(package) => TargetOf::package(package), |
| 669 | // A plugin group's metadata names no artifact of its own. |
| 670 | None => self.maven_target(workspace, &[]).await?, |
| 671 | }; |
| 672 | if let Some(refusal) = self.maven_refusal(viewer, &target, found.is_some()) { |
| 673 | return refusal; |
| 674 | } |
| 675 | if let (Some(package), MavenPath::ArtifactMetadata { checksum: None, .. }) = (&found, path) { |
| 676 | let caller = Caller { actor: viewer.map(AuditActor::of) }; |
| 677 | self.maven_announce(package, &caller).await?; |
| 678 | } |
| 679 | created() |
| 680 | } |
| 681 | } |