Skip to content

Commit b42f48c

Browse files
authored
Merge pull request #11967 from Turbo87/banner
Add `BANNER_MESSAGE` environment variable
2 parents 4400f07 + 8bf20d1 commit b42f48c

File tree

10 files changed

+88
-4
lines changed

10 files changed

+88
-4
lines changed

app/routes/application.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,16 @@ export default class ApplicationRoute extends Route {
6464
let timeout = Ember.testing ? 0 : 1000;
6565
await rawTimeout(timeout);
6666

67-
let { read_only: readOnly } = await ajax('/api/v1/site_metadata');
68-
if (readOnly) {
69-
let message =
67+
let { read_only, banner_message } = await ajax('/api/v1/site_metadata');
68+
69+
if (!banner_message && read_only) {
70+
banner_message =
7071
'crates.io is currently in read-only mode for maintenance reasons. ' +
7172
'Some functionality will be temporarily unavailable.';
73+
}
7274

73-
this.notifications.info(message, { autoClear: false });
75+
if (banner_message) {
76+
this.notifications.info(banner_message, { autoClear: false });
7477
}
7578
});
7679

e2e/acceptance/read-only-mode.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,25 @@ test.describe('Acceptance | Read-only Mode', { tag: '@acceptance' }, () => {
3939
await checkSentryEventsNumber(ember, 1);
4040
await checkSentryEventsHasName(ember, ['AjaxError']);
4141
});
42+
43+
test('banner message is shown when present', async ({ page, msw }) => {
44+
await msw.worker.use(
45+
http.get('/api/v1/site_metadata', () => HttpResponse.json({ banner_message: 'test message' })),
46+
);
47+
await page.goto('/');
48+
49+
await expect(page.locator('[data-test-notification-message="info"]')).toContainText('test message');
50+
});
51+
52+
test('banner message takes precedence over read-only mode', async ({ page, msw }) => {
53+
await msw.worker.use(
54+
http.get('/api/v1/site_metadata', () => HttpResponse.json({ read_only: true, banner_message: 'test message' })),
55+
);
56+
await page.goto('/');
57+
58+
await expect(page.locator('[data-test-notification-message="info"]')).toContainText('test message');
59+
await expect(page.locator('[data-test-notification-message="info"]')).not.toContainText('read-only mode');
60+
});
4261
});
4362

4463
async function checkSentryEventsNumber(ember: AppFixtures['ember'], expected: number) {

src/config/server.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ pub struct Server {
9797
/// Disables API token creation when set to any non-empty value.
9898
/// The value is used as the error message returned to users.
9999
pub disable_token_creation: Option<String>,
100+
101+
/// Banner message to display on all pages (e.g., for security incidents).
102+
pub banner_message: Option<String>,
100103
}
101104

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

206210
Ok(Server {
207211
db: DatabasePools::full_from_environment(&base)?,
@@ -253,6 +257,7 @@ impl Server {
253257
content_security_policy: Some(content_security_policy.parse()?),
254258
trustpub_audience,
255259
disable_token_creation,
260+
banner_message,
256261
})
257262
}
258263
}

src/controllers/site_metadata.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ pub struct MetadataResponse<'a> {
1515

1616
/// Whether the crates.io service is in read-only mode.
1717
pub read_only: bool,
18+
19+
/// Optional banner message to display on all pages.
20+
pub banner_message: Option<&'a str>,
1821
}
1922

2023
/// Get crates.io metadata.
@@ -37,6 +40,7 @@ pub async fn get_site_metadata(state: AppState) -> impl IntoResponse {
3740
deployed_sha,
3841
commit: deployed_sha,
3942
read_only,
43+
banner_message: state.config.banner_message.as_deref(),
4044
})
4145
.into_response()
4246
}

src/snapshots/crates_io__openapi__tests__openapi_snapshot-2.snap

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4006,6 +4006,13 @@ expression: response.json()
40064006
"application/json": {
40074007
"schema": {
40084008
"properties": {
4009+
"banner_message": {
4010+
"description": "Optional banner message to display on all pages.",
4011+
"type": [
4012+
"string",
4013+
"null"
4014+
]
4015+
},
40094016
"commit": {
40104017
"description": "The SHA1 of the currently deployed commit.",
40114018
"example": "0aebe2cdfacae1229b93853b1c58f9352195f081",

src/tests/routes/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,6 @@ pub mod me;
1919
pub mod metrics;
2020
mod private;
2121
pub mod session;
22+
mod site_metadata;
2223
pub mod summary;
2324
pub mod users;

src/tests/routes/site_metadata.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
use crate::tests::util::{RequestHelper, TestApp};
2+
use insta::{assert_json_snapshot, assert_snapshot};
3+
4+
#[tokio::test(flavor = "multi_thread")]
5+
async fn site_metadata_includes_banner_message() {
6+
let (_app, anon) = TestApp::init()
7+
.with_config(|config| {
8+
config.db.primary.read_only_mode = true;
9+
config.banner_message = Some("Test banner message".to_string());
10+
})
11+
.empty()
12+
.await;
13+
14+
let response = anon.get::<()>("/api/v1/site_metadata").await;
15+
assert_snapshot!(response.status(), @"200 OK");
16+
assert_json_snapshot!(response.json());
17+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
source: src/tests/routes/site_metadata.rs
3+
expression: response.json()
4+
---
5+
{
6+
"banner_message": "Test banner message",
7+
"commit": "unknown",
8+
"deployed_sha": "unknown",
9+
"read_only": true
10+
}

src/tests/util/test_app.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,7 @@ fn simple_config() -> config::Server {
527527
content_security_policy: None,
528528
trustpub_audience: AUDIENCE.to_string(),
529529
disable_token_creation: None,
530+
banner_message: None,
530531
}
531532
}
532533

tests/acceptance/read-only-mode-test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,21 @@ module('Acceptance | Read-only Mode', function (hooks) {
3838
assert.deepEqual(this.owner.lookup('service:sentry').events.length, 1);
3939
assert.true(this.owner.lookup('service:sentry').events[0].error instanceof AjaxError);
4040
});
41+
42+
test('banner message is shown when present', async function (assert) {
43+
this.worker.use(http.get('/api/v1/site_metadata', () => HttpResponse.json({ banner_message: 'test message' })));
44+
45+
await visit('/');
46+
assert.dom('[data-test-notification-message="info"]').includesText('test message');
47+
});
48+
49+
test('banner message takes precedence over read-only mode', async function (assert) {
50+
this.worker.use(
51+
http.get('/api/v1/site_metadata', () => HttpResponse.json({ read_only: true, banner_message: 'test message' })),
52+
);
53+
54+
await visit('/');
55+
assert.dom('[data-test-notification-message="info"]').includesText('test message');
56+
assert.dom('[data-test-notification-message="info"]').doesNotIncludeText('read-only mode');
57+
});
4158
});

0 commit comments

Comments
 (0)