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