max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
16 files changed,
+1281 insertions,
-3 deletions
| @@ -17,6 +17,7 @@ | |||
| 17 | 17 | mod format; | |
| 18 | 18 | mod ota; | |
| 19 | 19 | mod rate_limit; | |
| 20 | + | mod rpm; | |
| 20 | 21 | mod ssh; | |
| 21 | 22 | mod staging; | |
| 22 | 23 | mod tls; | |
| @@ -60,6 +61,11 @@ | |||
| 60 | 61 | if argv.get(1).map(String::as_str) == Some("ota") { | |
| 61 | 62 | return ota::run(&argv[2..]).await; | |
| 62 | 63 | } | |
| 64 | + | // Same shape as `ota` above: an operator one-shot, routed off argv before | |
| 65 | + | // the SSH daemon starts. | |
| 66 | + | if argv.get(1).map(String::as_str) == Some("rpm") { | |
| 67 | + | return rpm::run(&argv[2..]).await; | |
| 68 | + | } | |
| 63 | 69 | ||
| 64 | 70 | tracing_subscriber::fmt() | |
| 65 | 71 | .with_env_filter(EnvFilter::from_default_env().add_directive("mnw_cli=info".parse()?)) |
| @@ -336,7 +336,7 @@ | |||
| 336 | 336 | /// browser to the authorize URL, capture the returned code, and exchange it for | |
| 337 | 337 | /// a session token. The browser must run on the same machine as this command | |
| 338 | 338 | /// (the redirect targets `http://127.0.0.1:<port>/`). | |
| 339 | - | async fn authenticate_oauth(client: &SyncKitClient, key: &str) -> Result<()> { | |
| 339 | + | pub(crate) async fn authenticate_oauth(client: &SyncKitClient, key: &str) -> Result<()> { | |
| 340 | 340 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0") | |
| 341 | 341 | .await | |
| 342 | 342 | .context("failed to bind a localhost listener for the OAuth redirect")?; |
| @@ -176,6 +176,49 @@ | |||
| 176 | 176 | } | |
| 177 | 177 | } | |
| 178 | 178 | ||
| 179 | + | # Alloy hotfix RPM repository: reverse-proxies to Hetzner Object Storage. | |
| 180 | + | # Modelled on the cdn block above and deliberately NOT folded into it: that | |
| 181 | + | # block rewrites into makenotwork-public by path, and its comment is emphatic | |
| 182 | + | # about why that bucket must never be the main one. A separate block for a | |
| 183 | + | # separate bucket keeps that argument intact. | |
| 184 | + | # | |
| 185 | + | # What fetches this is dnf and rpm-ostree, by path, with no auth: repomd.xml, | |
| 186 | + | # then the metadata and packages it names. Nothing the server proper offers is | |
| 187 | + | # used, which is the point — a published hotfix reaches machines with no deploy | |
| 188 | + | # at all. Writes never come through here; they are presigned PUTs straight to | |
| 189 | + | # the bucket, minted by /api/v1/admin/rpm/uploads. | |
| 190 | + | # | |
| 191 | + | # Requires: public-read s3:GetObject policy on makenotwork-rpm, a Cloudflare | |
| 192 | + | # DNS A record for rpm.makenot.work (proxy ON), and the server configured with | |
| 193 | + | # S3_RPM_BUCKET (or RPM_S3_*) plus RPM_BASE_URL=https://rpm.makenot.work. | |
| 194 | + | rpm.makenot.work { | |
| 195 | + | import cloudflare_tls | |
| 196 | + | ||
| 197 | + | # Only allow GET (fetches). Block mutations — publishing does not come | |
| 198 | + | # through Caddy at all. | |
| 199 | + | @not_get not method GET HEAD | |
| 200 | + | respond @not_get 405 | |
| 201 | + | ||
| 202 | + | rewrite * /makenotwork-rpm{uri} | |
| 203 | + | reverse_proxy https://fsn1.your-objectstorage.com { | |
| 204 | + | header_up Host fsn1.your-objectstorage.com | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | header { | |
| 208 | + | X-Content-Type-Options "nosniff" | |
| 209 | + | Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" | |
| 210 | + | # Cache-Control is set on the objects themselves at presign time, and | |
| 211 | + | # the split is load-bearing: packages are immutable and cache forever, | |
| 212 | + | # repomd.xml must not be cached or a published fix never arrives. | |
| 213 | + | # See routes::rpm::cache_control_for. | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | log { | |
| 217 | + | output file /var/log/caddy/rpm.log | |
| 218 | + | format json | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 179 | 222 | # dl.maxj.phd download host retired 2026-06-09. MNW now serves all downloads | |
| 180 | 223 | # (creator product pages / makenot.work DMGs). The maxjphd_tls mTLS snippet and | |
| 181 | 224 | # the dl.maxj.phd file_server block were removed with it; the /etc/caddy/maxj-phd-origin* |
| @@ -1042,6 +1042,8 @@ | |||
| 1042 | 1042 | storage: None, | |
| 1043 | 1043 | synckit_storage: None, | |
| 1044 | 1044 | public_storage: None, | |
| 1045 | + | rpm_storage: None, | |
| 1046 | + | rpm_base_url: None, | |
| 1045 | 1047 | stripe: None, | |
| 1046 | 1048 | admin_user_id: Some(user.id), | |
| 1047 | 1049 | synckit_jwt_secret: None, | |
| @@ -1127,6 +1129,8 @@ | |||
| 1127 | 1129 | storage: None, | |
| 1128 | 1130 | synckit_storage: None, | |
| 1129 | 1131 | public_storage: None, | |
| 1132 | + | rpm_storage: None, | |
| 1133 | + | rpm_base_url: None, | |
| 1130 | 1134 | stripe: None, | |
| 1131 | 1135 | admin_user_id: None, | |
| 1132 | 1136 | synckit_jwt_secret: None, |
| @@ -28,6 +28,22 @@ | |||
| 28 | 28 | /// overridden by `S3_PUBLIC_BUCKET`. Required in production (the CDN serves | |
| 29 | 29 | /// ONLY this bucket); `None` in dev when `S3_PUBLIC_BUCKET` is unset. | |
| 30 | 30 | pub public_storage: Option<StorageConfig>, | |
| 31 | + | /// Bucket holding the Alloy hotfix RPM repository: the `.rpm` files and the | |
| 32 | + | /// `createrepo_c` metadata that `dnf`/`rpm-ostree` fetch by path. Served | |
| 33 | + | /// beside the server by a GET/HEAD-only Caddy block, exactly as the CDN | |
| 34 | + | /// bucket is, so a published fix reaches machines with no deploy at all. | |
| 35 | + | /// | |
| 36 | + | /// Resolved from `RPM_S3_*` when a dedicated credential is provisioned, and | |
| 37 | + | /// otherwise from the main storage with the bucket overridden by | |
| 38 | + | /// `S3_RPM_BUCKET`. `None` when neither is set, which is every dev | |
| 39 | + | /// environment; the publish endpoint then answers 503 rather than 404, so | |
| 40 | + | /// "not configured here" never reads as "the route is gone". | |
| 41 | + | pub rpm_storage: Option<StorageConfig>, | |
| 42 | + | /// Public render base for [`Self::rpm_storage`] (e.g. | |
| 43 | + | /// `https://rpm.makenot.work`), the host the Caddy block answers on. Only | |
| 44 | + | /// used to tell an operator where a published object landed; nothing | |
| 45 | + | /// durable is written from it. `None` when `RPM_BASE_URL` is unset. | |
| 46 | + | pub rpm_base_url: Option<String>, | |
| 31 | 47 | /// Stripe payment configuration (optional) | |
| 32 | 48 | pub stripe: Option<StripeConfig>, | |
| 33 | 49 | /// Admin user ID for waitlist management (optional) | |
| @@ -326,6 +342,28 @@ | |||
| 326 | 342 | }) | |
| 327 | 343 | }); | |
| 328 | 344 | ||
| 345 | + | // The RPM repo bucket. Two ways in, and the prefixed one wins: a | |
| 346 | + | // dedicated `RPM_S3_*` credential is the shape the provisioning task | |
| 347 | + | // (alloy `23f599d9`) hands over, and `S3_RPM_BUCKET` over the main | |
| 348 | + | // credentials is the same fallback `public_storage` takes above, so a | |
| 349 | + | // bucket in the same project needs one variable rather than five. | |
| 350 | + | let rpm_storage = StorageConfig::from_env_prefixed("RPM_S3_").or_else(|| { | |
| 351 | + | std::env::var("S3_RPM_BUCKET") | |
| 352 | + | .ok() | |
| 353 | + | .filter(|s| !s.is_empty()) | |
| 354 | + | .and_then(|bucket| { | |
| 355 | + | storage.as_ref().map(|s| StorageConfig { | |
| 356 | + | bucket, | |
| 357 | + | ..s.clone() | |
| 358 | + | }) | |
| 359 | + | }) | |
| 360 | + | }); | |
| 361 | + | ||
| 362 | + | let rpm_base_url = std::env::var("RPM_BASE_URL") | |
| 363 | + | .ok() | |
| 364 | + | .filter(|s| !s.is_empty()) | |
| 365 | + | .map(|s| s.trim_end_matches('/').to_string()); | |
| 366 | + | ||
| 329 | 367 | // Load Stripe config - optional, returns None if not fully configured | |
| 330 | 368 | let stripe = StripeConfig::from_env(); | |
| 331 | 369 | ||
| @@ -548,6 +586,8 @@ | |||
| 548 | 586 | storage, | |
| 549 | 587 | synckit_storage, | |
| 550 | 588 | public_storage, | |
| 589 | + | rpm_storage, | |
| 590 | + | rpm_base_url, | |
| 551 | 591 | stripe, | |
| 552 | 592 | admin_user_id, | |
| 553 | 593 | synckit_jwt_secret, | |
| @@ -996,6 +1036,13 @@ | |||
| 996 | 1036 | "S3_SECRET_KEY", | |
| 997 | 1037 | "S3_REGION", | |
| 998 | 1038 | "S3_PUBLIC_BUCKET", | |
| 1039 | + | "S3_RPM_BUCKET", | |
| 1040 | + | "RPM_S3_ENDPOINT", | |
| 1041 | + | "RPM_S3_BUCKET", | |
| 1042 | + | "RPM_S3_ACCESS_KEY", | |
| 1043 | + | "RPM_S3_SECRET_KEY", | |
| 1044 | + | "RPM_S3_REGION", | |
| 1045 | + | "RPM_BASE_URL", | |
| 999 | 1046 | "SYNCKIT_S3_ENDPOINT", | |
| 1000 | 1047 | "SYNCKIT_S3_BUCKET", | |
| 1001 | 1048 | "SYNCKIT_S3_ACCESS_KEY", | |
| @@ -1109,6 +1156,8 @@ | |||
| 1109 | 1156 | storage: None, | |
| 1110 | 1157 | synckit_storage: None, | |
| 1111 | 1158 | public_storage: None, | |
| 1159 | + | rpm_storage: None, | |
| 1160 | + | rpm_base_url: None, | |
| 1112 | 1161 | stripe: None, | |
| 1113 | 1162 | admin_user_id: None, | |
| 1114 | 1163 | synckit_jwt_secret: None, |
| @@ -420,6 +420,27 @@ | |||
| 420 | 420 | pub const OTA_READ_RATE_LIMIT_MS: u64 = 100; | |
| 421 | 421 | pub const OTA_READ_RATE_LIMIT_BURST: u32 = 30; | |
| 422 | 422 | ||
| 423 | + | // Alloy hotfix RPM repo publishing (routes::rpm). | |
| 424 | + | /// Presign lifetime for an RPM/repodata PUT. Matches the OTA artifact window: | |
| 425 | + | /// the object is uploaded immediately after the mint, and a short window bounds | |
| 426 | + | /// what a leaked URL is worth. | |
| 427 | + | pub const RPM_PRESIGN_EXPIRY_SECS: u64 = 3600; // 1 hour | |
| 428 | + | /// Largest object the RPM publish endpoint will sign. A single unresumable PUT, | |
| 429 | + | /// so it stays modest; the biggest thing the repo carries is one package, and a | |
| 430 | + | /// package near this size is a packaging mistake rather than a hotfix. | |
| 431 | + | pub const RPM_MAX_OBJECT_BYTES: i64 = 2 * 1024 * 1024 * 1024; // 2 GB | |
| 432 | + | /// Longest object path the endpoint will accept, counted in bytes over the | |
| 433 | + | /// whole key. Well under S3's 1024-byte key limit and far past any real | |
| 434 | + | /// `repodata/<sha256>-primary.xml.zst`. | |
| 435 | + | pub const RPM_MAX_KEY_BYTES: usize = 255; | |
| 436 | + | /// Most path segments an RPM object key may carry: `alloy/f43/x86_64/repodata/repomd.xml` | |
| 437 | + | /// is five. | |
| 438 | + | pub const RPM_MAX_KEY_SEGMENTS: usize = 8; | |
| 439 | + | // RPM publish: burst 20, then 4/sec. A repodata push is several objects back to | |
| 440 | + | // back, so the burst is wider than OTA's while the steady rate stays low. | |
| 441 | + | pub const RPM_WRITE_RATE_LIMIT_MS: u64 = 250; | |
| 442 | + | pub const RPM_WRITE_RATE_LIMIT_BURST: u32 = 20; | |
| 443 | + | ||
| 423 | 444 | // Build pipeline | |
| 424 | 445 | pub const BUILD_TIMEOUT_SECS: u64 = 1800; // 30 min | |
| 425 | 446 | pub const BUILD_MAX_LOG_BYTES: usize = 5_242_880; // 5 MB |
| @@ -94,8 +94,8 @@ | |||
| 94 | 94 | use payments::PaymentProvider; | |
| 95 | 95 | use routes::{ | |
| 96 | 96 | admin_routes, api_routes, auth_routes, build_routes, git_issue_routes, git_routes, | |
| 97 | - | git_write_routes, oauth_routes, ota_routes, page_routes, postmark_routes, sso_routes, | |
| 98 | - | storage_routes, stripe_routes, synckit_routes, | |
| 97 | + | git_write_routes, oauth_routes, ota_routes, page_routes, postmark_routes, rpm_routes, | |
| 98 | + | sso_routes, storage_routes, stripe_routes, synckit_routes, | |
| 99 | 99 | }; | |
| 100 | 100 | use scanning::ScanPipeline; | |
| 101 | 101 | use storage::StorageBackend; | |
| @@ -169,6 +169,11 @@ | |||
| 169 | 169 | /// gallery, item/project images); the scan worker copies Clean image | |
| 170 | 170 | /// objects here cross-bucket. `None` when `S3_PUBLIC_BUCKET` is unset. | |
| 171 | 171 | pub public_s3: Option<Arc<dyn StorageBackend>>, | |
| 172 | + | /// Bucket holding the Alloy hotfix RPM repository. Written only by the | |
| 173 | + | /// admin publish endpoint (`routes::rpm`); read by nobody here, because the | |
| 174 | + | /// repo is fetched straight off the bucket through Caddy. `None` when the | |
| 175 | + | /// bucket is unconfigured, which is every dev environment. | |
| 176 | + | pub rpm_s3: Option<Arc<dyn StorageBackend>>, | |
| 172 | 177 | } | |
| 173 | 178 | ||
| 174 | 179 | /// Derived in-memory caches held by [`AppState`]. All are `Arc<DashMap>` so a | |
| @@ -434,6 +439,13 @@ | |||
| 434 | 439 | ) | |
| 435 | 440 | }) | |
| 436 | 441 | } | |
| 442 | + | ||
| 443 | + | /// Get the RPM-repo S3 storage backend, or error if not configured. | |
| 444 | + | pub fn require_rpm_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> { | |
| 445 | + | self.rpm_s3.as_ref().ok_or_else(|| { | |
| 446 | + | error::AppError::ServiceUnavailable("RPM bucket is not configured".to_string()) | |
| 447 | + | }) | |
| 448 | + | } | |
| 437 | 449 | } | |
| 438 | 450 | ||
| 439 | 451 | impl AppState { | |
| @@ -552,6 +564,7 @@ | |||
| 552 | 564 | .merge(git_issue_routes()) | |
| 553 | 565 | .merge(git_write_routes()) | |
| 554 | 566 | .merge(ota_routes()) | |
| 567 | + | .merge(rpm_routes()) | |
| 555 | 568 | .merge(build_routes()); | |
| 556 | 569 | // The description layer, when a screen is switched on. Inside the CSRF tree | |
| 557 | 570 | // rather than beside it, so a described write is covered by the same |
| @@ -402,6 +402,29 @@ | |||
| 402 | 402 | None | |
| 403 | 403 | }; | |
| 404 | 404 | ||
| 405 | + | // Initialize the RPM-repo bucket client if configured. Written only by the | |
| 406 | + | // admin publish endpoint; served to dnf/rpm-ostree by Caddy off the bucket, | |
| 407 | + | // so nothing here reads it back. | |
| 408 | + | let rpm_s3: Option<std::sync::Arc<dyn makenotwork::storage::StorageBackend>> = if let Some( | |
| 409 | + | ref rpm_storage_config, | |
| 410 | + | ) = | |
| 411 | + | config.rpm_storage | |
| 412 | + | { | |
| 413 | + | match S3Client::new(rpm_storage_config, &config.host_url).await { | |
| 414 | + | Ok(client) => { | |
| 415 | + | tracing::info!(bucket = %rpm_storage_config.bucket, "RPM S3 bucket initialized"); | |
| 416 | + | Some(std::sync::Arc::new(client)) | |
| 417 | + | } | |
| 418 | + | Err(e) => { | |
| 419 | + | tracing::warn!(error = ?e, "Failed to initialize RPM S3 bucket"); | |
| 420 | + | None | |
| 421 | + | } | |
| 422 | + | } | |
| 423 | + | } else { | |
| 424 | + | tracing::info!("RPM S3 bucket not configured"); | |
| 425 | + | None | |
| 426 | + | }; | |
| 427 | + | ||
| 405 | 428 | // Initialize Stripe client if configured | |
| 406 | 429 | let stripe: Option<std::sync::Arc<dyn makenotwork::payments::PaymentProvider>> = if let Some( | |
| 407 | 430 | ref stripe_config, | |
| @@ -557,6 +580,7 @@ | |||
| 557 | 580 | s3, | |
| 558 | 581 | synckit_s3, | |
| 559 | 582 | public_s3, | |
| 583 | + | rpm_s3, | |
| 560 | 584 | }, | |
| 561 | 585 | stripe, | |
| 562 | 586 | email, |
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | use std::str::FromStr; | |
| 9 | 9 | ||
| 10 | 10 | use crate::config::StorageConfig; | |
| 11 | + | use crate::constants; | |
| 11 | 12 | use crate::db::{ItemId, ProjectId, SyncAppId, UserId, VersionId}; | |
| 12 | 13 | use crate::error::{AppError, Result}; | |
| 13 | 14 | ||
| @@ -163,6 +164,15 @@ | |||
| 163 | 164 | const MAX_MEDIA_IMAGE_SIZE: u64 = 10 * 1024 * 1024; // 10 MB | |
| 164 | 165 | const MAX_MEDIA_VIDEO_SIZE: u64 = 20 * 1024 * 1024 * 1024; // 20 GB | |
| 165 | 166 | ||
| 167 | + | /// Extensions an Alloy hotfix RPM repository serves. The package itself, the | |
| 168 | + | /// `createrepo_c` metadata under `repodata/` (XML, in whatever compression the | |
| 169 | + | /// generator chose, or the sqlite variants), and the detached signature and | |
| 170 | + | /// public key that go beside `repomd.xml`. Anything else is a publish mistake: | |
| 171 | + | /// nothing in `dnf`'s fetch path asks for it, so serving it is pure surface. | |
| 172 | + | const RPM_REPO_EXTENSIONS: &[&str] = &[ | |
| 173 | + | "rpm", "xml", "zst", "gz", "xz", "bz2", "sqlite", "asc", "key", "sig", "yaml", | |
| 174 | + | ]; | |
| 175 | + | ||
| 166 | 176 | /// Default presigned URL expiration. | |
| 167 | 177 | /// 1 hour balances usability (large uploads over slow connections) against | |
| 168 | 178 | /// security (limiting the window for URL leakage). Overridable per-call. | |
| @@ -672,6 +682,87 @@ | |||
| 672 | 682 | )) | |
| 673 | 683 | } | |
| 674 | 684 | ||
| 685 | + | /// Key for an object in the Alloy hotfix RPM repository, from the relative | |
| 686 | + | /// path the publisher names (e.g. `alloy/f43/x86_64/repodata/repomd.xml`). | |
| 687 | + | /// | |
| 688 | + | /// The odd one out among the generators, and deliberately so: every other | |
| 689 | + | /// key layout here is derived from ids we hold, but a yum repository *is* a | |
| 690 | + | /// path layout that `createrepo_c` writes and `dnf` re-derives from | |
| 691 | + | /// `repomd.xml`. The server cannot invent it without reimplementing | |
| 692 | + | /// createrepo, so the caller supplies it. That makes this the one generator | |
| 693 | + | /// whose whole job is refusing bad input, and it returns `Result` for that | |
| 694 | + | /// reason. Which layout the repo actually uses is | |
| 695 | + | /// [`86cb87b9`](https://makenot.work)'s business, not this function's, hence | |
| 696 | + | /// no structure is imposed beyond a segment count. | |
| 697 | + | /// | |
| 698 | + | /// Refused: absolute paths, empty segments (so `//` and a trailing `/`), | |
| 699 | + | /// `.` and `..` in any position, a segment starting `.` or `-`, anything | |
| 700 | + | /// outside `[A-Za-z0-9._+~-]`, and a final segment whose extension is not | |
| 701 | + | /// one a yum repository serves. Together those make traversal | |
| 702 | + | /// unrepresentable rather than merely unlikely, and keep a presigned PUT | |
| 703 | + | /// from writing an object the Caddy block would then serve as something it | |
| 704 | + | /// is not. | |
| 705 | + | pub fn generate_rpm_key(path: &str) -> Result<S3Key> { | |
| 706 | + | let bad = |msg: &str| AppError::BadRequest(format!("invalid RPM object path: {msg}")); | |
| 707 | + | ||
| 708 | + | if path.is_empty() { | |
| 709 | + | return Err(bad("empty")); | |
| 710 | + | } | |
| 711 | + | if path.len() > constants::RPM_MAX_KEY_BYTES { | |
| 712 | + | return Err(bad(&format!( | |
| 713 | + | "longer than {} bytes", | |
| 714 | + | constants::RPM_MAX_KEY_BYTES | |
| 715 | + | ))); | |
| 716 | + | } | |
| 717 | + | if path.starts_with('/') { | |
| 718 | + | return Err(bad("must be relative, not absolute")); | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | let segments: Vec<&str> = path.split('/').collect(); | |
| 722 | + | if segments.len() > constants::RPM_MAX_KEY_SEGMENTS { | |
| 723 | + | return Err(bad(&format!( | |
| 724 | + | "more than {} path segments", | |
| 725 | + | constants::RPM_MAX_KEY_SEGMENTS | |
| 726 | + | ))); | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | for segment in &segments { | |
| 730 | + | if segment.is_empty() { | |
| 731 | + | return Err(bad("empty path segment")); | |
| 732 | + | } | |
| 733 | + | if *segment == "." || *segment == ".." { | |
| 734 | + | return Err(bad("`.` and `..` are not path segments")); | |
| 735 | + | } | |
| 736 | + | if segment.starts_with('.') || segment.starts_with('-') { | |
| 737 | + | return Err(bad("a path segment may not start with `.` or `-`")); | |
| 738 | + | } | |
| 739 | + | if !segment | |
| 740 | + | .chars() | |
| 741 | + | .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '~' | '-')) | |
| 742 | + | { | |
| 743 | + | return Err(bad( | |
| 744 | + | "a path segment may hold only letters, digits, and `.` `_` `+` `~` `-`", | |
| 745 | + | )); | |
| 746 | + | } | |
| 747 | + | } | |
| 748 | + | ||
| 749 | + | // Unwrap: `split` on a non-empty string always yields at least one | |
| 750 | + | // segment, and every segment was proven non-empty above. | |
| 751 | + | let filename = segments.last().copied().unwrap_or_default(); | |
| 752 | + | let ext = filename | |
| 753 | + | .rsplit_once('.') | |
| 754 | + | .map(|(_, ext)| ext.to_ascii_lowercase()) | |
| 755 | + | .ok_or_else(|| bad("the final path segment needs a file extension"))?; | |
| 756 | + | if !RPM_REPO_EXTENSIONS.contains(&ext.as_str()) { | |
| 757 | + | return Err(bad(&format!( | |
| 758 | + | "`.{ext}` is not served from an RPM repository. Allowed: {}", | |
| 759 | + | RPM_REPO_EXTENSIONS.join(", ") | |
| 760 | + | ))); | |
| 761 | + | } | |
| 762 | + | ||
| 763 | + | Ok(S3Key(path.to_string())) | |
| 764 | + | } | |
| 765 | + | ||
| 675 | 766 | /// Content-addressed served key: `{user_id}/c/{sha256}.{ext}`. The object's | |
| 676 | 767 | /// name *is* its content hash, so the served bytes are provably the bytes | |
| 677 | 768 | /// that were scanned, a swapped object would hash to a different key. The |
| @@ -12,6 +12,7 @@ | |||
| 12 | 12 | pub mod ota; | |
| 13 | 13 | pub mod pages; | |
| 14 | 14 | pub mod postmark; | |
| 15 | + | pub mod rpm; | |
| 15 | 16 | pub mod sso; | |
| 16 | 17 | pub mod storage; | |
| 17 | 18 | pub mod stripe; | |
| @@ -29,6 +30,7 @@ | |||
| 29 | 30 | pub use ota::ota_routes; | |
| 30 | 31 | pub use pages::page_routes; | |
| 31 | 32 | pub use postmark::postmark_routes; | |
| 33 | + | pub use rpm::rpm_routes; | |
| 32 | 34 | pub use sso::sso_routes; | |
| 33 | 35 | pub use storage::storage_routes; | |
| 34 | 36 | pub use stripe::stripe_routes; |