From df94f321a461cc33995728d6619854a8acd61110 Mon Sep 17 00:00:00 2001 From: hcphat Date: Wed, 15 Apr 2026 14:31:23 +0700 Subject: [PATCH 1/5] =?UTF-8?q?ref=202.4.=E7=B5=B1=E5=90=88=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E8=80=85=E3=81=AE=E3=83=86=E3=83=AD=E3=83=83=E3=83=97?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E3=81=AB=E3=81=8A=E3=81=91=E3=82=8B=E3=80=81?= =?UTF-8?q?=E8=A4=87=E6=95=B0=E8=A1=8C=E8=A1=A8=E7=A4=BA=E3=81=AE=E6=94=B9?= =?UTF-8?q?=E4=BF=AE:=20Commit=20code=20handle=20multi=20line=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../maintenance-banner/component.ts | 28 ++++++++ .../maintenance-banner/template.hbs | 2 +- .../maintenance-banner/component-test.ts | 67 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/lib/osf-components/addon/components/maintenance-banner/component.ts b/lib/osf-components/addon/components/maintenance-banner/component.ts index 3c07e4af8..28a7cb284 100644 --- a/lib/osf-components/addon/components/maintenance-banner/component.ts +++ b/lib/osf-components/addon/components/maintenance-banner/component.ts @@ -1,6 +1,7 @@ import Component from '@ember/component'; import { action, computed } from '@ember/object'; import { inject as service } from '@ember/service'; +import { htmlSafe } from '@ember/string'; import { task } from 'ember-concurrency-decorators'; import Cookies from 'ember-cookies/services/cookies'; import { localClassNames } from 'ember-css-modules'; @@ -67,6 +68,33 @@ export default class MaintenanceBanner extends Component { return this.maintenance && this.maintenance.level ? levelMap[this.maintenance.level - 1] : undefined; } + @computed('maintenance.message') + get renderedMessage() { + const text = this.maintenance && this.maintenance.message ? this.maintenance.message : ''; + if (!text) { + return htmlSafe(''); + } + + const escapeHTML = (str: string) => str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + const urlRegex = /(https?:\/\/[^\s<>"]+)/g; + + const result = text.split(urlRegex).map(part => { + if (part.match(urlRegex)) { + const safeUrl = escapeHTML(part); + return `${safeUrl}`; + } + return escapeHTML(part); + }).join(''); + const withBreaks = result.replace(/\n/g, '
'); + return htmlSafe(withBreaks); + } + didReceiveAttrs(): void { if (!this.cookies.exists(maintenanceCookie)) { this.getMaintenanceStatus.perform(); diff --git a/lib/osf-components/addon/components/maintenance-banner/template.hbs b/lib/osf-components/addon/components/maintenance-banner/template.hbs index 159b9cefb..223ac0e93 100644 --- a/lib/osf-components/addon/components/maintenance-banner/template.hbs +++ b/lib/osf-components/addon/components/maintenance-banner/template.hbs @@ -6,7 +6,7 @@ > {{t 'maintenance.title'}} {{#if this.maintenance.message.length}} - {{this.maintenance.message}} + {{this.renderedMessage}} {{else}} {{t 'maintenance.line1' start=this.start end=this.end utc=this.utc htmlSafe=true}} {{t 'maintenance.line2'}} diff --git a/tests/integration/components/maintenance-banner/component-test.ts b/tests/integration/components/maintenance-banner/component-test.ts index f2d45fdd2..1ca79ba47 100644 --- a/tests/integration/components/maintenance-banner/component-test.ts +++ b/tests/integration/components/maintenance-banner/component-test.ts @@ -36,4 +36,71 @@ module('Integration | Component | maintenance-banner', hooks => { await render(hbs`{{maintenance-banner}}`); assert.dom('.alert').includesText('longstringy'); }); + + test('it renders line breaks as
', async assert => { + server.urlPrefix = apiUrl; + server.namespace = '/v2'; + server.get('/status', () => ({ + maintenance: { + message: 'line1\nline2', + level: 1, + }, + })); + + await render(hbs`{{maintenance-banner}}`); + + assert.dom('.alert').hasTextContaining('line1'); + assert.dom('.alert').hasTextContaining('line2'); + assert.dom('.alert br').exists({ count: 1 }); + }); + + test('it converts URLs to clickable links', async assert => { + server.urlPrefix = apiUrl; + server.namespace = '/v2'; + server.get('/status', () => ({ + maintenance: { + message: 'Visit https://abc-test.com', + level: 1, + }, + })); + + await render(hbs`{{maintenance-banner}}`); + + assert.dom('.alert a') + .hasAttribute('href', 'https://abc-test.com') + .hasText('https://abc-test.com'); + }); + + test('it escapes HTML to prevent XSS', async assert => { + server.urlPrefix = apiUrl; + server.namespace = '/v2'; + server.get('/status', () => ({ + maintenance: { + message: '', + level: 1, + }, + })); + + await render(hbs`{{maintenance-banner}}`); + + assert.dom('.alert script').doesNotExist(); + assert.dom('.alert').includesText(''); + }); + + test('it handles mixed content (text + url + newline)', async assert => { + server.urlPrefix = apiUrl; + server.namespace = '/v2'; + server.get('/status', () => ({ + maintenance: { + message: 'line1\nhttps://abc-test.com\nline3', + level: 1, + }, + })); + + await render(hbs`{{maintenance-banner}}`); + + assert.dom('.alert br').exists({ count: 2 }); + assert.dom('.alert a').exists({ count: 1 }); + assert.dom('.alert a').hasAttribute('href', 'https://abc-test.com'); + }); }); From 4338bb7f96a39da7bd0fffcb6b0cd669c52622c0 Mon Sep 17 00:00:00 2001 From: hcphat Date: Wed, 29 Apr 2026 18:14:05 +0700 Subject: [PATCH 2/5] =?UTF-8?q?ref=202.4.=E7=B5=B1=E5=90=88=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E8=80=85=E3=81=AE=E3=83=86=E3=83=AD=E3=83=83=E3=83=97?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E3=81=AB=E3=81=8A=E3=81=91=E3=82=8B=E3=80=81?= =?UTF-8?q?=E8=A4=87=E6=95=B0=E8=A1=8C=E8=A1=A8=E7=A4=BA=E3=81=AE=E6=94=B9?= =?UTF-8?q?=E4=BF=AE:=20Commit=20code=20fix=20IT=20issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/utils/string-utils.ts | 49 +++++++ .../maintenance-banner/component.ts | 98 +++++++++++-- .../maintenance-banner/component-test.ts | 138 ++++++++++++++++-- tests/unit/utils/string-utils-test.ts | 76 ++++++++++ 4 files changed, 334 insertions(+), 27 deletions(-) create mode 100644 app/utils/string-utils.ts create mode 100644 tests/unit/utils/string-utils-test.ts diff --git a/app/utils/string-utils.ts b/app/utils/string-utils.ts new file mode 100644 index 000000000..89c6c5fd0 --- /dev/null +++ b/app/utils/string-utils.ts @@ -0,0 +1,49 @@ +export const escapeHTML = (s: string): string => ( + s.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +); + +export const isEmail = (s: string): boolean => ( + /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(s) +); + +export const isValidDomain = (host: string): boolean => { + if (!/^[a-zA-Z0-9.-]+$/.test(host)) { return false; } + if (host.includes('..')) { return false; } + if (!host.includes('.')) { return false; } + + const labels = host.split('.'); + if (labels.some(label => /^-|-$/.test(label) || label === '')) { + return false; + } + + const tld = labels[labels.length - 1]; + return /^[a-zA-Z]{2,}$/.test(tld); +}; + +export interface TrimEdgesResult { + leading: string; + core: string; + trailing: string; +} + +export const trimEdges = (s: string): TrimEdgesResult => { + let core = s; + let leading = ''; + let trailing = ''; + + while (/^[([]/.test(core)) { + leading += core.charAt(0); + core = core.slice(1); + } + + while (/[.,!?)\]]$/.test(core)) { + trailing = core.slice(-1) + trailing; + core = core.slice(0, -1); + } + + return { leading, core, trailing }; +}; diff --git a/lib/osf-components/addon/components/maintenance-banner/component.ts b/lib/osf-components/addon/components/maintenance-banner/component.ts index 28a7cb284..6932a2a60 100644 --- a/lib/osf-components/addon/components/maintenance-banner/component.ts +++ b/lib/osf-components/addon/components/maintenance-banner/component.ts @@ -12,6 +12,12 @@ import { layout } from 'ember-osf-web/decorators/component'; import Analytics from 'ember-osf-web/services/analytics'; import CurrentUser from 'ember-osf-web/services/current-user'; +import { + escapeHTML, + isEmail, + isValidDomain, + trimEdges, +} from 'ember-osf-web/utils/string-utils'; import styles from './styles'; import template from './template'; @@ -75,24 +81,88 @@ export default class MaintenanceBanner extends Component { return htmlSafe(''); } - const escapeHTML = (str: string) => str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + const parts: string[] = text.split(/(\s+)/); - const urlRegex = /(https?:\/\/[^\s<>"]+)/g; + const result = parts.map((part: string) => { + if (!part) { + return ''; + } - const result = text.split(urlRegex).map(part => { - if (part.match(urlRegex)) { - const safeUrl = escapeHTML(part); - return `${safeUrl}`; + // preserve whitespace + if (/^\s+$/.test(part)) { + return part; } - return escapeHTML(part); + + // split by dangerous characters to support partial parsing + const chunks = part.split(/([<>"])/); + + return chunks.map((chunk: string) => { + if (!chunk) { + return ''; + } + + // escape dangerous characters + if (/[<>"]/.test(chunk)) { + return escapeHTML(chunk); + } + + // extract surrounding punctuation + const { leading, core, trailing } = trimEdges(chunk); + + if (isEmail(core)) { + return `${escapeHTML(leading)}` + + `${escapeHTML(core)}${escapeHTML(trailing)}`; + } + + // scheme:// (loose handling, e.g. htp://) + if (/^[a-zA-Z]+:\/\//.test(core)) { + try { + const fake = core.replace(/^([a-zA-Z]+):\/\//, 'http://'); + const u = new URL(fake); + + if (isValidDomain(u.hostname)) { + return `${escapeHTML(leading)}` + + `${escapeHTML(core)}${escapeHTML(trailing)}`; + } + } catch { + // ignore invalid URL parsing + } + + // fallback: link the prefix part (no strict domain validation) + const match = core.match(/^([a-zA-Z]+:\/\/([a-zA-Z0-9-]+))/); + if (match) { + const full = match[1]; + const host = match[2]; + + // reject invalid host patterns + if (!host.startsWith('-') && /^[a-zA-Z0-9-]+$/.test(host)) { + const rest = core.slice(full.length); + return `${escapeHTML(leading)}` + + `${escapeHTML(full)}${escapeHTML(rest + trailing)}`; + } + } + + return escapeHTML(chunk); + } + + // domain (e.g. abc.com, www.google.com) + const domainMatch = core.match(/^((?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,})(?=$|[^a-zA-Z0-9-])/); + if (domainMatch) { + const domain = domainMatch[1]; + const host = domain.replace(/^www\./, ''); + + if (isValidDomain(host)) { + const rest = core.slice(domain.length); + return `${escapeHTML(leading)}` + + `${escapeHTML(domain)}${escapeHTML(rest + trailing)}`; + } + } + + return escapeHTML(chunk); + }).join(''); }).join(''); - const withBreaks = result.replace(/\n/g, '
'); - return htmlSafe(withBreaks); + + return htmlSafe(result.replace(/\n/g, '
')); } didReceiveAttrs(): void { diff --git a/tests/integration/components/maintenance-banner/component-test.ts b/tests/integration/components/maintenance-banner/component-test.ts index 1ca79ba47..b704cdc6b 100644 --- a/tests/integration/components/maintenance-banner/component-test.ts +++ b/tests/integration/components/maintenance-banner/component-test.ts @@ -15,10 +15,13 @@ module('Integration | Component | maintenance-banner', hooks => { setupMirage(hooks); test('it renders no maintenance', async assert => { - server.get('/v2/status', () => ({ + server.urlPrefix = apiUrl; + server.namespace = '/v2'; + server.get('/status', () => ({ meta: { version: '2.8' }, maintenance: null, })); + await render(hbs`{{maintenance-banner}}`); assert.dom('.alert').doesNotExist(); }); @@ -26,6 +29,7 @@ module('Integration | Component | maintenance-banner', hooks => { test('it renders maintenance message', async assert => { server.urlPrefix = apiUrl; server.namespace = '/v2'; + server.get('/status', () => ({ meta: { version: '2.8' }, maintenance: { @@ -33,6 +37,7 @@ module('Integration | Component | maintenance-banner', hooks => { level: 1, }, })); + await render(hbs`{{maintenance-banner}}`); assert.dom('.alert').includesText('longstringy'); }); @@ -41,6 +46,7 @@ module('Integration | Component | maintenance-banner', hooks => { server.urlPrefix = apiUrl; server.namespace = '/v2'; server.get('/status', () => ({ + meta: { version: '2.8' }, maintenance: { message: 'line1\nline2', level: 1, @@ -49,17 +55,33 @@ module('Integration | Component | maintenance-banner', hooks => { await render(hbs`{{maintenance-banner}}`); - assert.dom('.alert').hasTextContaining('line1'); - assert.dom('.alert').hasTextContaining('line2'); assert.dom('.alert br').exists({ count: 1 }); }); - test('it converts URLs to clickable links', async assert => { + test('it escapes HTML to prevent XSS', async assert => { + server.urlPrefix = apiUrl; + server.namespace = '/v2'; + server.get('/status', () => ({ + meta: { version: '2.8' }, + maintenance: { + message: '', + level: 1, + }, + })); + + await render(hbs`{{maintenance-banner}}`); + + assert.dom('.alert script').doesNotExist(); + assert.dom('.alert').includesText(''); + }); + + test('it converts email to mailto link', async assert => { server.urlPrefix = apiUrl; server.namespace = '/v2'; server.get('/status', () => ({ + meta: { version: '2.8' }, maintenance: { - message: 'Visit https://abc-test.com', + message: 'test@example.com', level: 1, }, })); @@ -67,32 +89,104 @@ module('Integration | Component | maintenance-banner', hooks => { await render(hbs`{{maintenance-banner}}`); assert.dom('.alert a') - .hasAttribute('href', 'https://abc-test.com') - .hasText('https://abc-test.com'); + .hasAttribute('href', 'mailto:test@example.com') + .hasText('test@example.com'); }); - test('it escapes HTML to prevent XSS', async assert => { + test('it converts valid URL to link', async assert => { server.urlPrefix = apiUrl; server.namespace = '/v2'; server.get('/status', () => ({ + meta: { version: '2.8' }, maintenance: { - message: '', + message: 'https://google.com', level: 1, }, })); await render(hbs`{{maintenance-banner}}`); - assert.dom('.alert script').doesNotExist(); - assert.dom('.alert').includesText(''); + assert.dom('.alert a') + .hasAttribute('href', 'https://google.com') + .hasText('https://google.com'); + }); + + test('it supports loose scheme (htp:// still becomes link)', async assert => { + server.urlPrefix = apiUrl; + server.namespace = '/v2'; + server.get('/status', () => ({ + meta: { version: '2.8' }, + maintenance: { + message: 'htp://google.com', + level: 1, + }, + })); + + await render(hbs`{{maintenance-banner}}`); + + assert.dom('.alert a') + .hasAttribute('href', 'htp://google.com') + .hasText('htp://google.com'); + }); + + test('it does NOT link invalid domain', async assert => { + server.urlPrefix = apiUrl; + server.namespace = '/v2'; + server.get('/status', () => ({ + meta: { version: '2.8' }, + maintenance: { + message: 'http://-google...com', + level: 1, + }, + })); + + await render(hbs`{{maintenance-banner}}`); + + assert.dom('.alert a').doesNotExist(); + assert.dom('.alert').includesText('http://-google...com'); + }); + + test('it converts domain without scheme', async assert => { + server.urlPrefix = apiUrl; + server.namespace = '/v2'; + server.get('/status', () => ({ + meta: { version: '2.8' }, + maintenance: { + message: 'abc.com', + level: 1, + }, + })); + + await render(hbs`{{maintenance-banner}}`); + + assert.dom('.alert a') + .hasAttribute('href', 'http://abc.com') + .hasText('abc.com'); + }); + + test('it handles partial broken URL (g<>gle.com)', async assert => { + server.urlPrefix = apiUrl; + server.namespace = '/v2'; + server.get('/status', () => ({ + meta: { version: '2.8' }, + maintenance: { + message: 'http://g<>gle.com', + level: 1, + }, + })); + + await render(hbs`{{maintenance-banner}}`); + + assert.dom('.alert a').exists({ count: 2 }); }); test('it handles mixed content (text + url + newline)', async assert => { server.urlPrefix = apiUrl; server.namespace = '/v2'; server.get('/status', () => ({ + meta: { version: '2.8' }, maintenance: { - message: 'line1\nhttps://abc-test.com\nline3', + message: 'line1\nhttps://abc.com\nline3', level: 1, }, })); @@ -101,6 +195,24 @@ module('Integration | Component | maintenance-banner', hooks => { assert.dom('.alert br').exists({ count: 2 }); assert.dom('.alert a').exists({ count: 1 }); - assert.dom('.alert a').hasAttribute('href', 'https://abc-test.com'); + }); + + test('it handles URL surrounded by parentheses', async assert => { + server.urlPrefix = apiUrl; + server.namespace = '/v2'; + server.get('/status', () => ({ + meta: { version: '2.8' }, + maintenance: { + message: 'Please visit (https://google.com) for more info.', + level: 1, + }, + })); + + await render(hbs`{{maintenance-banner}}`); + + assert.dom('.alert a') + .hasAttribute('href', 'https://google.com') + .hasText('https://google.com'); + assert.dom('.alert').includesText('Please visit (https://google.com) for more info.'); }); }); diff --git a/tests/unit/utils/string-utils-test.ts b/tests/unit/utils/string-utils-test.ts new file mode 100644 index 000000000..18cd40ba9 --- /dev/null +++ b/tests/unit/utils/string-utils-test.ts @@ -0,0 +1,76 @@ +import { + escapeHTML, + isEmail, + isValidDomain, + trimEdges, +} from 'ember-osf-web/utils/string-utils'; + +import { module, test } from 'qunit'; + +module('Unit | Utility | string-utils', () => { + test('escapeHTML escapes dangerous characters', assert => { + const cases: Array<[string, string]> = [ + ['', ''], + ['abc', 'abc'], + [''); }); - test('it converts email to mailto link', async assert => { - server.urlPrefix = apiUrl; - server.namespace = '/v2'; - server.get('/status', () => ({ - meta: { version: '2.8' }, - maintenance: { - message: 'test@example.com', - level: 1, - }, - })); - - await render(hbs`{{maintenance-banner}}`); - - assert.dom('.alert a') - .hasAttribute('href', 'mailto:test@example.com') - .hasText('test@example.com'); - }); - test('it converts valid URL to link', async assert => { server.urlPrefix = apiUrl; server.namespace = '/v2'; @@ -111,24 +93,6 @@ module('Integration | Component | maintenance-banner', hooks => { .hasText('https://google.com'); }); - test('it supports loose scheme (htp:// still becomes link)', async assert => { - server.urlPrefix = apiUrl; - server.namespace = '/v2'; - server.get('/status', () => ({ - meta: { version: '2.8' }, - maintenance: { - message: 'htp://google.com', - level: 1, - }, - })); - - await render(hbs`{{maintenance-banner}}`); - - assert.dom('.alert a') - .hasAttribute('href', 'htp://google.com') - .hasText('htp://google.com'); - }); - test('it does NOT link invalid domain', async assert => { server.urlPrefix = apiUrl; server.namespace = '/v2'; @@ -146,40 +110,6 @@ module('Integration | Component | maintenance-banner', hooks => { assert.dom('.alert').includesText('http://-google...com'); }); - test('it converts domain without scheme', async assert => { - server.urlPrefix = apiUrl; - server.namespace = '/v2'; - server.get('/status', () => ({ - meta: { version: '2.8' }, - maintenance: { - message: 'abc.com', - level: 1, - }, - })); - - await render(hbs`{{maintenance-banner}}`); - - assert.dom('.alert a') - .hasAttribute('href', 'http://abc.com') - .hasText('abc.com'); - }); - - test('it handles partial broken URL (g<>gle.com)', async assert => { - server.urlPrefix = apiUrl; - server.namespace = '/v2'; - server.get('/status', () => ({ - meta: { version: '2.8' }, - maintenance: { - message: 'http://g<>gle.com', - level: 1, - }, - })); - - await render(hbs`{{maintenance-banner}}`); - - assert.dom('.alert a').exists({ count: 2 }); - }); - test('it handles mixed content (text + url + newline)', async assert => { server.urlPrefix = apiUrl; server.namespace = '/v2';