Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions app/routes/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,16 @@ export default class ApplicationRoute extends Route {
let timeout = Ember.testing ? 0 : 1000;
await rawTimeout(timeout);

let { read_only: readOnly } = await ajax('/api/v1/site_metadata');
if (readOnly) {
let message =
let { read_only, banner_message } = await ajax('/api/v1/site_metadata');

if (!banner_message && read_only) {
banner_message =
'crates.io is currently in read-only mode for maintenance reasons. ' +
'Some functionality will be temporarily unavailable.';
}

this.notifications.info(message, { autoClear: false });
if (banner_message) {
this.notifications.info(banner_message, { autoClear: false });
}
});

Expand Down
19 changes: 19 additions & 0 deletions e2e/acceptance/read-only-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,25 @@ test.describe('Acceptance | Read-only Mode', { tag: '@acceptance' }, () => {
await checkSentryEventsNumber(ember, 1);
await checkSentryEventsHasName(ember, ['AjaxError']);
});

test('banner message is shown when present', async ({ page, msw }) => {
await msw.worker.use(
http.get('/api/v1/site_metadata', () => HttpResponse.json({ banner_message: 'test message' })),
);
await page.goto('/');

await expect(page.locator('[data-test-notification-message="info"]')).toContainText('test message');
});

test('banner message takes precedence over read-only mode', async ({ page, msw }) => {
await msw.worker.use(
http.get('/api/v1/site_metadata', () => HttpResponse.json({ read_only: true, banner_message: 'test message' })),
);
await page.goto('/');

await expect(page.locator('[data-test-notification-message="info"]')).toContainText('test message');
await expect(page.locator('[data-test-notification-message="info"]')).not.toContainText('read-only mode');
});
});

async function checkSentryEventsNumber(ember: AppFixtures['ember'], expected: number) {
Expand Down
5 changes: 5 additions & 0 deletions src/config/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ pub struct Server {
/// Disables API token creation when set to any non-empty value.
/// The value is used as the error message returned to users.
pub disable_token_creation: Option<String>,

/// Banner message to display on all pages (e.g., for security incidents).
pub banner_message: Option<String>,
}

impl Server {
Expand Down Expand Up @@ -202,6 +205,7 @@ impl Server {
let domain_name = dotenvy::var("DOMAIN_NAME").unwrap_or_else(|_| "crates.io".into());
let trustpub_audience = var("TRUSTPUB_AUDIENCE")?.unwrap_or_else(|| domain_name.clone());
let disable_token_creation = var("DISABLE_TOKEN_CREATION")?.filter(|s| !s.is_empty());
let banner_message = var("BANNER_MESSAGE")?.filter(|s| !s.is_empty());

Ok(Server {
db: DatabasePools::full_from_environment(&base)?,
Expand Down Expand Up @@ -253,6 +257,7 @@ impl Server {
content_security_policy: Some(content_security_policy.parse()?),
trustpub_audience,
disable_token_creation,
banner_message,
})
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/controllers/site_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ pub struct MetadataResponse<'a> {

/// Whether the crates.io service is in read-only mode.
pub read_only: bool,

/// Optional banner message to display on all pages.
pub banner_message: Option<&'a str>,
}

/// Get crates.io metadata.
Expand All @@ -37,6 +40,7 @@ pub async fn get_site_metadata(state: AppState) -> impl IntoResponse {
deployed_sha,
commit: deployed_sha,
read_only,
banner_message: state.config.banner_message.as_deref(),
})
.into_response()
}
Original file line number Diff line number Diff line change
Expand Up @@ -4006,6 +4006,13 @@ expression: response.json()
"application/json": {
"schema": {
"properties": {
"banner_message": {
"description": "Optional banner message to display on all pages.",
"type": [
"string",
"null"
]
},
"commit": {
"description": "The SHA1 of the currently deployed commit.",
"example": "0aebe2cdfacae1229b93853b1c58f9352195f081",
Expand Down
1 change: 1 addition & 0 deletions src/tests/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ pub mod me;
pub mod metrics;
mod private;
pub mod session;
mod site_metadata;
pub mod summary;
pub mod users;
17 changes: 17 additions & 0 deletions src/tests/routes/site_metadata.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
use crate::tests::util::{RequestHelper, TestApp};
use insta::{assert_json_snapshot, assert_snapshot};

#[tokio::test(flavor = "multi_thread")]
async fn site_metadata_includes_banner_message() {
let (_app, anon) = TestApp::init()
.with_config(|config| {
config.db.primary.read_only_mode = true;
config.banner_message = Some("Test banner message".to_string());
})
.empty()
.await;

let response = anon.get::<()>("/api/v1/site_metadata").await;
assert_snapshot!(response.status(), @"200 OK");
assert_json_snapshot!(response.json());
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
source: src/tests/routes/site_metadata.rs
expression: response.json()
---
{
"banner_message": "Test banner message",
"commit": "unknown",
"deployed_sha": "unknown",
"read_only": true
}
1 change: 1 addition & 0 deletions src/tests/util/test_app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ fn simple_config() -> config::Server {
content_security_policy: None,
trustpub_audience: AUDIENCE.to_string(),
disable_token_creation: None,
banner_message: None,
}
}

Expand Down
17 changes: 17 additions & 0 deletions tests/acceptance/read-only-mode-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,21 @@ module('Acceptance | Read-only Mode', function (hooks) {
assert.deepEqual(this.owner.lookup('service:sentry').events.length, 1);
assert.true(this.owner.lookup('service:sentry').events[0].error instanceof AjaxError);
});

test('banner message is shown when present', async function (assert) {
this.worker.use(http.get('/api/v1/site_metadata', () => HttpResponse.json({ banner_message: 'test message' })));

await visit('/');
assert.dom('[data-test-notification-message="info"]').includesText('test message');
});

test('banner message takes precedence over read-only mode', async function (assert) {
this.worker.use(
http.get('/api/v1/site_metadata', () => HttpResponse.json({ read_only: true, banner_message: 'test message' })),
);

await visit('/');
assert.dom('[data-test-notification-message="info"]').includesText('test message');
assert.dom('[data-test-notification-message="info"]').doesNotIncludeText('read-only mode');
});
});
Loading