diff --git a/api/src/badge-headers.js b/api/src/badge-headers.js index 91937b30..51b8c701 100644 --- a/api/src/badge-headers.js +++ b/api/src/badge-headers.js @@ -1,6 +1,6 @@ /* Badge and activity header and query rows: { key, value, secret }. A secret row's value never leaves the server. A save that omits it keeps the stored - value. */ + value. The same holds for the login password: { username, password }. */ const SUBKEYS = ['headers', 'params']; @@ -64,10 +64,30 @@ function rowsToObject(rows) { return out; } +/* A login without a username counts as none. A save that clears the username + removes the stored password. */ +/** @param {unknown} login @returns {{ username: string, password: string }|null} */ +function loginOf(login) { + if (!login || typeof login !== 'object') return null; + const { username, password } = /** @type {{ username?: unknown, password?: unknown }} */ (login); + if (typeof username !== 'string' || !username) return null; + return { username, password: typeof password === 'string' ? password : '' }; +} + +/* Header rows go in after the login. A row named Authorization replaces it. */ +/** @param {any} rows @param {unknown} login @returns {Record} */ +function requestHeaders(rows, login) { + /** @type {Record} */ + const out = Object.create(null); + const l = loginOf(login); + if (l) out.Authorization = 'Basic ' + Buffer.from(`${l.username}:${l.password}`).toString('base64'); + return Object.assign(out, rowsToObject(rows)); +} + function requestParts(item) { const src = item?.monitoring?.activity?.enabled ? item.monitoring.activity : item?.badge; return { - headers: rowsToObject(src?.headers), + headers: requestHeaders(src?.headers, src?.login), params: rowsToObject(src?.params), }; } @@ -96,6 +116,20 @@ function preserveRows(newRows, oldRows) { return nrows; } +/** @param {unknown} login */ +function scrubLogin(login) { + const l = loginOf(login); + return l ? { username: l.username, passwordSet: l.password !== '' } : undefined; +} + +/** @param {unknown} login @param {unknown} oldLogin */ +function preserveLogin(login, oldLogin) { + const l = loginOf(login); + if (!l) return undefined; + if (!l.password) l.password = loginOf(oldLogin)?.password || ''; + return l; +} + function eachActivityLike(item, fn) { if (!item || typeof item !== 'object') return; if (item.badge) fn(item.badge); @@ -105,6 +139,7 @@ function eachActivityLike(item, fn) { function scrubItemBadgeSecrets(item) { eachActivityLike(item, block => { for (const k of SUBKEYS) if (block[k] != null) block[k] = scrubRows(block[k]); + if (block.login != null) block.login = scrubLogin(block.login); }); } @@ -112,6 +147,7 @@ function preserveItemBadgeSecrets(newItem, oldItem) { const oldBlocks = { badge: oldItem?.badge, activity: oldItem?.monitoring?.activity }; const apply = (block, old) => { for (const k of SUBKEYS) if (block[k] != null) block[k] = preserveRows(block[k], old?.[k]); + if (block.login != null) block.login = preserveLogin(block.login, old?.login); }; if (newItem?.badge) apply(newItem.badge, oldBlocks.badge); if (newItem?.monitoring?.activity) apply(newItem.monitoring.activity, oldBlocks.activity); @@ -135,6 +171,8 @@ module.exports = { droppedRowCount, firstMalformedRow, rowsToObject, + loginOf, + requestHeaders, requestParts, scrubRows, scrubItemBadgeSecrets, diff --git a/api/src/config-secrets.js b/api/src/config-secrets.js index 806b2c9e..355911b4 100644 --- a/api/src/config-secrets.js +++ b/api/src/config-secrets.js @@ -2,7 +2,7 @@ misses others leaks a stored credential. */ const { scrubConfigSecrets, preserveConfigSecrets, secretSpec } = require('./widget-secrets'); -const { scrubItemBadgeSecrets, preserveItemBadgeSecrets, toRows } = require('./badge-headers'); +const { scrubItemBadgeSecrets, preserveItemBadgeSecrets, toRows, loginOf } = require('./badge-headers'); const { badgeRequestMatchesSaved, alignSavedConfig, rewriteChanged, widgetTlsChanged } = require('./secret-scope'); const { getRegistry } = require('./widgets'); @@ -19,10 +19,10 @@ function rowsHoldSecret(rows) { } function blockHoldsSecret(block) { - return !!block && (rowsHoldSecret(block.headers) || rowsHoldSecret(block.params)); + return !!block && (rowsHoldSecret(block.headers) || rowsHoldSecret(block.params) || !!loginOf(block.login)?.password); } -const asRequest = block => ({ url: block?.url, headers: block?.headers, params: block?.params }); +const asRequest = block => ({ url: block?.url, headers: block?.headers, params: block?.params, login: block?.login }); function widgetHoldsSecret(config, entry) { if (!config || !entry) return false; diff --git a/api/src/routes/badges.js b/api/src/routes/badges.js index e765e46d..5c2ec6c8 100644 --- a/api/src/routes/badges.js +++ b/api/src/routes/badges.js @@ -7,7 +7,14 @@ const { PING_MS, FETCH_MS, BATCH_MS } = require('../timeouts'); const { IS_DEMO } = require('../demo'); const demoData = require('../demo-data'); const { collectNumbers, computeBadgeValue, computeLabelValues, firstFiringLabel } = require('../badge-extract'); -const { requestParts, toRows, preserveItemBadgeSecrets, rowsToObject, droppedRowCount } = require('../badge-headers'); +const { + requestParts, + toRows, + preserveItemBadgeSecrets, + rowsToObject, + requestHeaders, + droppedRowCount, +} = require('../badge-headers'); const log = require('../log'); const { fail, KIND, errorBody } = require('../api-error'); const { badgeRequestMatchesSaved, RETYPE_MESSAGE } = require('../secret-scope'); @@ -173,25 +180,29 @@ on('POST', '/api/badge-proxy', async (req, res) => { picks the URL while the server picks the credential. */ let headerRows = toRows(body.headers); let paramRows = toRows(body.params); + let login = body.login; let declined = false; if (itemId) { const stored = loadConfig().items?.find(i => i && i.id === itemId); if (stored) { const oldSrc = stored.monitoring?.activity?.enabled ? stored.monitoring.activity : stored.badge; if ( - badgeRequestMatchesSaved({ url, headers: headerRows, params: paramRows }, oldSrc) && + badgeRequestMatchesSaved({ url, headers: headerRows, params: paramRows, login }, oldSrc) && (skipTls === true) === (stored.skipTlsVerify === true) ) { - const shim = { badge: { headers: headerRows, params: paramRows } }; - preserveItemBadgeSecrets(shim, { badge: { headers: oldSrc?.headers, params: oldSrc?.params } }); + const shim = { badge: { headers: headerRows, params: paramRows, login } }; + preserveItemBadgeSecrets(shim, { + badge: { headers: oldSrc?.headers, params: oldSrc?.params, login: oldSrc?.login }, + }); headerRows = shim.badge.headers; paramRows = shim.badge.params; + login = shim.badge.login; } else { declined = true; } } } - const headers = rowsToObject(headerRows); + const headers = requestHeaders(headerRows, login); const params = rowsToObject(paramRows); const fullUrl = Object.keys(params).length ? url + (url.includes('?') ? '&' : '?') + new URLSearchParams(params) diff --git a/api/src/secret-scope.js b/api/src/secret-scope.js index dee121e9..804e890a 100644 --- a/api/src/secret-scope.js +++ b/api/src/secret-scope.js @@ -4,7 +4,7 @@ manifest marks cosmetic or transient are excluded. */ const { SAVED_ROW, secretSpec, cosmeticSpec, transientSpec } = require('./widget-secrets'); -const { toRows } = require('./badge-headers'); +const { toRows, loginOf } = require('./badge-headers'); function stableEqual(a, b) { if (a === b) return true; @@ -125,6 +125,7 @@ function rowsMatch(newRows, oldRows) { function badgeRequestMatchesSaved(request, stored) { if (!stored) return false; if ((request.url || '') !== (stored.url || '')) return false; + if ((loginOf(request.login)?.username || '') !== (loginOf(stored.login)?.username || '')) return false; return rowsMatch(request.headers, stored.headers) && rowsMatch(request.params, stored.params); } diff --git a/api/test/badge-headers.test.js b/api/test/badge-headers.test.js index 5d512190..510639f6 100644 --- a/api/test/badge-headers.test.js +++ b/api/test/badge-headers.test.js @@ -261,3 +261,54 @@ test('a row with an empty value is not sent as a header', () => { ]); assert.deepEqual({ ...out }, { Accept: 'application/json' }); }); + +const basic = (u, p) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64'); + +test('a login becomes a Basic Authorization header', () => { + const item = { badge: { login: { username: 'monitor', password: 'pw' } } }; + assert.deepEqual(requestParts(item).headers, { Authorization: basic('monitor', 'pw') }); +}); + +test('a login without a username sends nothing', () => { + for (const login of [{ username: '', password: 'pw' }, { password: 'pw' }, 'monitor:pw', null]) { + assert.deepEqual(requestParts({ badge: { login } }).headers, {}, JSON.stringify(login)); + } +}); + +test('an Authorization header row replaces the login', () => { + const item = { + badge: { + login: { username: 'monitor', password: 'pw' }, + headers: [{ key: 'Authorization', value: 'Bearer T', secret: true }], + }, + }; + assert.deepEqual(requestParts(item).headers, { Authorization: 'Bearer T' }); +}); + +test('scrub hides the login password and keeps the username', () => { + const item = { monitoring: { activity: { login: { username: 'monitor', password: 'pw' } } } }; + scrubItemBadgeSecrets(item); + assert.deepEqual(item.monitoring.activity.login, { username: 'monitor', passwordSet: true }); +}); + +test('scrub drops a login without a username', () => { + const item = { badge: { login: { password: 'pw' } } }; + scrubItemBadgeSecrets(item); + assert.equal(item.badge.login, undefined); +}); + +test('preserve refills an omitted login password and keeps a retyped one', () => { + const old = { badge: { login: { username: 'monitor', password: 'stored' } } }; + const kept = { badge: { login: { username: 'monitor', passwordSet: true } } }; + preserveItemBadgeSecrets(kept, old); + assert.deepEqual(kept.badge.login, { username: 'monitor', password: 'stored' }); + const retyped = { badge: { login: { username: 'monitor', password: 'new' } } }; + preserveItemBadgeSecrets(retyped, old); + assert.deepEqual(retyped.badge.login, { username: 'monitor', password: 'new' }); +}); + +test('clearing the username removes the stored login', () => { + const item = { badge: { login: { username: '' } } }; + preserveItemBadgeSecrets(item, { badge: { login: { username: 'monitor', password: 'stored' } } }); + assert.equal(item.badge.login, undefined); +}); diff --git a/api/test/config-secrets.test.js b/api/test/config-secrets.test.js index bc06bc9f..bd501b6a 100644 --- a/api/test/config-secrets.test.js +++ b/api/test/config-secrets.test.js @@ -387,3 +387,33 @@ test('without savedRow a removed row still withholds by position', () => { assert.equal(next.items[0].widgetConfig.slots[0].dupPass, undefined); assert.deepEqual(withheld, [{ id: 'b1', label: 'b1' }]); }); + +const loginApp = (url, username, login = { username }) => ({ + items: [{ id: 'l1', type: 'app', monitoring: { activity: { enabled: true, url, login } } }], +}); + +test('a login password survives a save that changed nothing about the request', () => { + const stored = loginApp('https://legit.example/api', 'monitor', { username: 'monitor', password: 'REAL' }); + const incoming = loginApp('https://legit.example/api', 'monitor'); + const { withheld } = preserveAllSecrets(incoming, stored); + assert.equal(incoming.items[0].monitoring.activity.login.password, 'REAL'); + assert.deepEqual(withheld, []); +}); + +test('a login password is not refilled once the request points elsewhere', () => { + const stored = loginApp('https://legit.example/api', 'monitor', { username: 'monitor', password: 'REAL' }); + const incoming = loginApp('https://attacker.example/collect', 'monitor'); + const { withheld } = preserveAllSecrets(incoming, stored); + assert.equal(incoming.items[0].monitoring.activity.login.password, ''); + assert.deepEqual( + withheld.map(w => w.id), + ['l1'], + ); +}); + +test('a login password is not refilled for another username', () => { + const stored = loginApp('https://legit.example/api', 'monitor', { username: 'monitor', password: 'REAL' }); + const incoming = loginApp('https://legit.example/api', 'admin'); + preserveAllSecrets(incoming, stored); + assert.equal(incoming.items[0].monitoring.activity.login.password, ''); +}); diff --git a/api/test/secret-scope-integration.test.js b/api/test/secret-scope-integration.test.js index c12a0489..ffd63b27 100644 --- a/api/test/secret-scope-integration.test.js +++ b/api/test/secret-scope-integration.test.js @@ -55,6 +55,17 @@ before(async () => { params: [{ key: 'mode', value: 'full', secret: false }], }, }, + { + id: 'app-login', + type: 'app', + monitoring: { + activity: { + enabled: true, + url: `${realBase}/login-api`, + login: { username: 'monitor', password: SECRET_VALUE }, + }, + }, + }, { id: 'w1', type: 'widget', @@ -171,6 +182,38 @@ test('badge-proxy does not reuse the stored credential with certificate checking assert.ok(!sawSecret(realSeen), 'a changed certificate setting must not reuse the credential'); }); +const basicPassword = seen => { + const auth = seen.at(-1)?.headers.authorization || ''; + return Buffer.from(auth.replace(/^Basic /, ''), 'base64') + .toString() + .split(':')[1]; +}; + +test('badge-proxy signs in with the stored password at the saved destination', async () => { + realSeen.length = 0; + const r = await post('/api/badge-proxy', { + itemId: 'app-login', + url: `${realBase}/login-api`, + login: { username: 'monitor' }, + }); + assert.equal(r.status, 200); + assert.equal(basicPassword(realSeen), SECRET_VALUE); +}); + +test('badge-proxy does not send the stored password to a caller-chosen host', async () => { + evilSeen.length = 0; + await post('/api/badge-proxy', { itemId: 'app-login', url: `${evilBase}/collect`, login: { username: 'monitor' } }); + assert.equal(evilSeen.length, 1); + assert.notEqual(basicPassword(evilSeen), SECRET_VALUE); +}); + +test('badge-proxy does not send the stored password for another username', async () => { + realSeen.length = 0; + await post('/api/badge-proxy', { itemId: 'app-login', url: `${realBase}/login-api`, login: { username: 'admin' } }); + assert.equal(realSeen.length, 1); + assert.notEqual(basicPassword(realSeen), SECRET_VALUE); +}); + /* ── widget-options ───────────────────────────────────────────────────────── */ test('widget-options sends the stored credential to the saved destination', async () => { diff --git a/ui/admin/index.html b/ui/admin/index.html index 18775d97..7875af11 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -302,6 +302,6 @@

General

- + diff --git a/ui/i18n/de.json b/ui/i18n/de.json index ced56f44..105b4f41 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -197,6 +197,10 @@ "value": "Wert", "authentication": "Authentifizierung", "addToHeader": "Zum Header hinzufügen", + "signIn": "Anmeldung", + "username": "Benutzername", + "password": "Passwort", + "urlHasLogin": "Gib Benutzername und Passwort unter Anmeldung ein. Eine Anmeldung in der URL wird nicht gesendet.", "unit": "Einheit", "unitPh": "z. B. GB", "badgeMin": "Anzeigen ab", diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 380ef50b..6c677106 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -197,6 +197,10 @@ "value": "Value", "authentication": "Authentication", "addToHeader": "Add to Header", + "signIn": "Sign In", + "username": "Username", + "password": "Password", + "urlHasLogin": "Enter the username and password under Sign In. A login in the URL is not sent.", "unit": "Unit", "unitPh": "e.g. GB", "badgeMin": "Show From", diff --git a/ui/i18n/es.json b/ui/i18n/es.json index b8ab7391..88941c9c 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -199,6 +199,10 @@ "value": "Valor", "authentication": "Autenticación", "addToHeader": "Añadir a la cabecera", + "signIn": "Inicio de sesión", + "username": "Usuario", + "password": "Contraseña", + "urlHasLogin": "Introduce el usuario y la contraseña en Inicio de sesión. Los datos de acceso de la URL no se envían.", "unit": "Unidad", "unitPh": "p. ej. GB", "badgeMin": "Mostrar desde", diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index e61d6955..d303b5fa 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -197,6 +197,10 @@ "value": "مقدار", "authentication": "احراز هویت", "addToHeader": "افزودن به هدر", + "signIn": "ورود", + "username": "نام کاربری", + "password": "رمز عبور", + "urlHasLogin": "نام کاربری و رمز عبور را در بخش ورود وارد کنید. اطلاعات ورود داخل آدرس ارسال نمی‌شود.", "unit": "واحد", "unitPh": "مثلا GB", "badgeMin": "نمایش از", diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index e50bc459..4bd18c0b 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -199,6 +199,10 @@ "value": "Valeur", "authentication": "Authentification", "addToHeader": "Ajouter à l’en-tête", + "signIn": "Connexion", + "username": "Nom d’utilisateur", + "password": "Mot de passe", + "urlHasLogin": "Saisissez le nom d’utilisateur et le mot de passe sous Connexion. Les identifiants dans l’URL ne sont pas envoyés.", "unit": "Unité", "unitPh": "p. ex. Go", "badgeMin": "Afficher à partir de", diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index 8d2ed40f..5a052c1b 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -195,6 +195,10 @@ "value": "值", "authentication": "身份验证", "addToHeader": "添加到标头", + "signIn": "登录", + "username": "用户名", + "password": "密码", + "urlHasLogin": "请在“登录”中填写用户名和密码。URL 中的登录信息不会发送。", "unit": "单位", "unitPh": "例如 GB", "badgeMin": "显示阈值", diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index 2ea1fa2a..30d91642 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -15,10 +15,11 @@ import { isDockBlocked, clearsStoredSecret, isBareHostUrl, + urlHasLogin, failureIsMissingApiPath, nextActiveIndex, sameIconName, -} from '/js/admin-logic.js?v=546d9ed7'; +} from '/js/admin-logic.js?v=b3befae4'; import { t } from '/js/i18n.js?v=ed7c5565'; import { toast, @@ -28,13 +29,14 @@ import { apiPost, PE_SVG, initInlineEdit, + wireInlineEdit, reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=d479609c'; -import { createListbox } from '/js/listbox.js?v=58e59bd9'; +} from '/js/admin-shared.js?v=7b3a1dac'; +import { createListbox } from '/js/listbox.js?v=95e9b354'; import { MAX_LABELS } from '/js/badge-logic.js?v=5c72bd4c'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=43cc1b76'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=4cfe9a7b'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; @@ -139,6 +141,8 @@ export function buildAppForm(body, item) { const hasStatic = !!staticBadge.enabled; const isPing = !!hc.pingUrl; const skipTls = !!item?.skipTlsVerify; + const login = act.login?.username ? act.login : null; + const hasAuth = !!(act.params?.length || act.headers?.length || login); const ier = _ieRow; /* Split the string around its placeholder. Word order differs per language. */ @@ -207,8 +211,11 @@ export function buildAppForm(body, item) { ${ier('ie-burl', t('app.apiUrl'), 'f-burl', act.url, t('app.apiUrlPh'), 'url')}
-
${t('app.authentication')}${tog('auth-en', !!(act.params || act.headers), t('app.authentication'))}
-
+
${t('app.authentication')}${tog('auth-en', !!(act.params || act.headers || login), t('app.authentication'))}
+
+
${t('app.signIn')}
+ ${ier('ie-buser', t('app.username'), 'f-buser', login?.username, t('common.notSet'))} +
${t('app.password')}${login?.passwordSet ? t('common.configured') : t('common.notSet')}
${t('app.addToUrl')} ${t('app.queryParams')}
${t('app.addToHeader')}
@@ -245,7 +252,20 @@ export function buildAppForm(body, item) { initInlineEdit('ie-hc-con', 'hc-con', { placeholder: t('app.containerPh') }); initInlineEdit('ie-hc-ping', 'hc-ping', { placeholder: t('app.pingUrlPh') }); initInlineEdit('ie-static-label', 'f-static-label', { placeholder: t('app.labelPh') }); - initInlineEdit('ie-burl', 'f-burl', { placeholder: t('app.apiUrlPh') }); + initInlineEdit('ie-burl', 'f-burl', { placeholder: t('app.apiUrlPh'), onCommit: warnUrlLogin }); + initInlineEdit('ie-buser', 'f-buser', { placeholder: t('common.notSet') }); + const passSet = !!login?.passwordSet; + const passRow = el('ie-bpass'); + const passInp = inpById('f-bpass'); + if (passRow && passInp) + wireInlineEdit(passRow, passInp, { + type: 'password', + placeholder: passSet ? t('widgetCfg.replaceSecret') : '', + fill: false, + render: (valEl, v) => { + valEl.textContent = v ? t('widgetCfg.newValueSet') : passSet ? t('common.configured') : t('common.notSet'); + }, + }); /* The markup carries no style attribute, so the preview is painted here. */ const pv0 = el('ipv'); @@ -1027,6 +1047,25 @@ function kvRowEl(host, rows, row, ph) { return rowEl; } +/** @returns {{ username: string, password?: string }|undefined} */ +function badgeLogin() { + const username = inpById('f-buser')?.value?.trim() || ''; + const password = inpById('f-bpass')?.value || ''; + return username ? { username, password: password || undefined } : undefined; +} + +/** @returns {boolean} whether the warning is shown */ +function warnUrlLogin() { + const st = el('bst'); + if (!st) return false; + const has = urlHasLogin(inpById('f-burl')?.value || ''); + if (has) { + st.style.cssText = 'margin-top:4px;color:var(--warning)'; + st.textContent = t('app.urlHasLogin'); + } else if (st.textContent === t('app.urlHasLogin')) st.textContent = ''; + return has; +} + async function fetchBadge() { const url = inpById('f-burl')?.value?.trim(); const st = el('bst'); @@ -1035,6 +1074,7 @@ async function fetchBadge() { if (st) st.textContent = t('app.enterUrlFirst'); return; } + if (warnUrlLogin()) return; if (st) { st.style.cssText = 'margin-top:4px;color:var(--dm)'; st.textContent = t('app.fetching'); @@ -1049,6 +1089,7 @@ async function fetchBadge() { url, params, headers, + login: badgeLogin(), skipTls, itemId: state.eid ?? undefined, }); diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index 6efa1451..105824ee 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,4 +1,4 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=d479609c'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=7b3a1dac'; import { initI18n, t } from '/js/i18n.js?v=ed7c5565'; import { loginErrorKey } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index 800ee20d..5de3f93d 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,9 +1,9 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=d479609c'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=7b3a1dac'; import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa, q } from '/js/utils.js?v=705ca896'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; -import { createListbox } from '/js/listbox.js?v=58e59bd9'; +import { createListbox } from '/js/listbox.js?v=95e9b354'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; diff --git a/ui/js/admin-drag.js b/ui/js/admin-drag.js index a554c72a..0eb3edc2 100644 --- a/ui/js/admin-drag.js +++ b/ui/js/admin-drag.js @@ -12,7 +12,7 @@ import { applyDrop, canDropInGroup, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=1d1d553e'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { state } from '/js/admin-state.js?v=af772a1b'; -import { snapshotItems } from '/js/admin-save-logic.js?v=5613a9fa'; +import { snapshotItems } from '/js/admin-save-logic.js?v=2de65cd5'; import { qa } from '/js/utils.js?v=705ca896'; /** @type {(before: unknown) => void} */ diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index e02738ed..0445f8a4 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -12,17 +12,17 @@ reads and writes them too. */ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { snapshotItems } from '/js/admin-save-logic.js?v=5613a9fa'; -import { isShownChild, reorderItems, shownChildren, topLevelRows } from '/js/admin-logic.js?v=546d9ed7'; +import { snapshotItems } from '/js/admin-save-logic.js?v=2de65cd5'; +import { isShownChild, reorderItems, shownChildren, topLevelRows } from '/js/admin-logic.js?v=b3befae4'; import { listGroup } from '/js/admin-drag-logic.js?v=1d1d553e'; -import { initDrag, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=73c3c8da'; +import { initDrag, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=e18422ae'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { paintIcon, toast } from '/js/admin-shared.js?v=d479609c'; +import { paintIcon, toast } from '/js/admin-shared.js?v=7b3a1dac'; import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=705ca896'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { sizeLabel } from '/js/admin-widget-form.js?v=29567d90'; +import { sizeLabel } from '/js/admin-widget-form.js?v=28393455'; import { widgetGlyph } from '/js/widget-glyphs.js?v=b383b7c8'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/ui/js/admin-logic.js b/ui/js/admin-logic.js index fd343699..8e546849 100644 --- a/ui/js/admin-logic.js +++ b/ui/js/admin-logic.js @@ -349,6 +349,21 @@ export function isBareHostUrl(url) { return parsed.pathname === '/'; } +/** True when the address carries a username or password before the host. + + @param {string} url + @returns {boolean} */ +export function urlHasLogin(url) { + const raw = typeof url === 'string' ? url.trim() : ''; + if (!raw) return false; + try { + const parsed = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : 'http://' + raw); + return !!(parsed.username || parsed.password); + } catch { + return false; + } +} + /** Whether a failed badge fetch should report the missing API path instead of the failure itself. diff --git a/ui/js/admin-save-logic.js b/ui/js/admin-save-logic.js index 048b4543..ba139e71 100644 --- a/ui/js/admin-save-logic.js +++ b/ui/js/admin-save-logic.js @@ -291,6 +291,9 @@ export function buildAppItem(v, orig, takenIds = []) { url: v.actUrl, params: v.actParams?.length ? v.actParams : undefined, headers: v.actHeaders?.length ? v.actHeaders : undefined, + login: v.actLogin?.username + ? { username: v.actLogin.username, password: v.actLogin.password || undefined } + : undefined, extract: spaths.length === 1 ? spaths[0] : spaths.length > 1 ? spaths.map(p => ({ path: p })) : undefined, labels: combine ? undefined : labels, combine: combine || undefined, diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 054343b6..c1e3c3ff 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,4 +1,4 @@ -import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=d479609c'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=7b3a1dac'; import { socketProbeAdvice } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=ed7c5565'; @@ -9,12 +9,12 @@ import { needsCurrentPassword, createDirtyTracker, BLOCK, -} from '/js/admin-logic.js?v=546d9ed7'; +} from '/js/admin-logic.js?v=b3befae4'; import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; import { el, inp, setUserText } from '/js/utils.js?v=705ca896'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { serialWrites } from '/js/admin-save-logic.js?v=5613a9fa'; -import { renderColorControl } from '/js/admin-color-control.js?v=43cc1b76'; +import { serialWrites } from '/js/admin-save-logic.js?v=2de65cd5'; +import { renderColorControl } from '/js/admin-color-control.js?v=4cfe9a7b'; import { BACKDROP } from '/js/background.js?v=25428bf2'; import { ALLOWED_HOSTS_MAX, diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index 5d9d68c9..cfa51037 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -1,5 +1,5 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ -import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=546d9ed7'; +import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=b3befae4'; import { el, isolate, q } from '/js/utils.js?v=705ca896'; import { t } from '/js/i18n.js?v=ed7c5565'; import { errorAdvice } from '/js/admin-error.js?v=b0881957'; diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index bf7d2e04..f9ac2080 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,9 +1,9 @@ import { state } from '/js/admin-state.js?v=af772a1b'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=d479609c'; -import { createListbox } from '/js/listbox.js?v=58e59bd9'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=901eeaee'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=7b3a1dac'; +import { createListbox } from '/js/listbox.js?v=95e9b354'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=19857d5b'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=546d9ed7'; +import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=b3befae4'; import { t } from '/js/i18n.js?v=ed7c5565'; import { focusFirst, q, qi, qa } from '/js/utils.js?v=705ca896'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/ui/js/admin.js b/ui/js/admin.js index dac7905e..8c077ac4 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -4,11 +4,11 @@ import { buildSettingsTileForm, captureActLabels, serializeKvRows, -} from '/js/admin-app-form.js?v=adc67cb0'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=6adaedef'; +} from '/js/admin-app-form.js?v=0888b695'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=9082bfd5'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=0371c091'; -import { resolveAdminSection, settleDock } from '/js/admin-logic.js?v=546d9ed7'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=95504ae8'; +import { resolveAdminSection, settleDock } from '/js/admin-logic.js?v=b3befae4'; import { afterImport, buildAppItem, @@ -21,7 +21,7 @@ import { serialWrites, snapshotItems, upsertItem, -} from '/js/admin-save-logic.js?v=5613a9fa'; +} from '/js/admin-save-logic.js?v=2de65cd5'; import { loadSettings, savedWallpaperUrl, @@ -29,7 +29,7 @@ import { showBgFields, showWallpaperFile, wireUnsplashKey, -} from '/js/admin-settings.js?v=b1897b60'; +} from '/js/admin-settings.js?v=e1d92487'; import { apiGet, apiPost, @@ -43,13 +43,13 @@ import { setReauthHandler, toast, withheldText, -} from '/js/admin-shared.js?v=d479609c'; +} from '/js/admin-shared.js?v=7b3a1dac'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=29567d90'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=28393455'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; -import { createListbox } from '/js/listbox.js?v=58e59bd9'; +import { createListbox } from '/js/listbox.js?v=95e9b354'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=ed7c5565'; import { loadLocalIcons } from '/js/icons.js?v=5ac01d72'; @@ -63,7 +63,7 @@ import { NOTE, parseErrorsAsSkipped, SKIP, -} from '/js/import-foreign.js?v=3b635c16'; +} from '/js/import-foreign.js?v=fee60cb7'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=6b0320bd'; import { @@ -709,6 +709,7 @@ async function doSave(orig) { actInt: Math.min(3600, Math.max(10, parseInt(inp('f-bint')?.value || '30', 10))), actParams: serializeKvRows(state._bpar), actHeaders: serializeKvRows(state._bhdr), + actLogin: { username: inp('f-buser')?.value?.trim() || '', password: inp('f-bpass')?.value || '' }, staticEn: inp('static-en')?.checked || false, staticLabel: inp('f-static-label')?.value?.trim() || '', staticColor: inp('static-col-val')?.value || 'info', diff --git a/ui/js/import-foreign.js b/ui/js/import-foreign.js index 8e9faecf..47c40dbc 100644 --- a/ui/js/import-foreign.js +++ b/ui/js/import-foreign.js @@ -3,7 +3,7 @@ only: a widget in the source becomes a plain app tile, never a Stackyard widget. Keep this module free of the DOM and of the network. */ -import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=5613a9fa'; +import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=2de65cd5'; import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f'; export const SKIP = Object.freeze({ diff --git a/ui/js/listbox.js b/ui/js/listbox.js index 9eb4af69..929af6a9 100644 --- a/ui/js/listbox.js +++ b/ui/js/listbox.js @@ -6,7 +6,7 @@ Do not use popover. The top layer makes iOS collapse its browser toolbar, which exposes a strip of page canvas and misplaces the menu. */ -import { nextActiveIndex, singleChoice } from '/js/admin-logic.js?v=546d9ed7'; +import { nextActiveIndex, singleChoice } from '/js/admin-logic.js?v=b3befae4'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { formatList } from '/js/format-number.js?v=abcd4ae4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index 599e1a6e..369d8b1f 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -3,9 +3,9 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=d479609c'; -import { createListbox } from '/js/listbox.js?v=58e59bd9'; -import { renderColorControl } from '/js/admin-color-control.js?v=43cc1b76'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=7b3a1dac'; +import { createListbox } from '/js/listbox.js?v=95e9b354'; +import { renderColorControl } from '/js/admin-color-control.js?v=4cfe9a7b'; import { seedCarried, applyOptionSet, @@ -13,7 +13,7 @@ import { requiredFieldMissing, groupBounds, visibleFieldFlags, -} from '/js/admin-logic.js?v=546d9ed7'; +} from '/js/admin-logic.js?v=b3befae4'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; import { qi } from '/js/utils.js?v=705ca896'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; diff --git a/ui/test/admin-logic.test.mjs b/ui/test/admin-logic.test.mjs index 09fd1288..cd0effe5 100644 --- a/ui/test/admin-logic.test.mjs +++ b/ui/test/admin-logic.test.mjs @@ -27,6 +27,7 @@ import { toastMs, toastHoldMs, isBareHostUrl, + urlHasLogin, failureIsMissingApiPath, createDirtyTracker, BLOCK, @@ -758,3 +759,12 @@ test('an empty string or nothing is no choice otherwise', () => { assert.deepEqual(singleChoice(undefined, opts), []); assert.deepEqual(singleChoice(3, [{ value: 3 }]), ['3']); }); + +test('urlHasLogin spots a username or password before the host', () => { + for (const u of ['http://admin:pw@adguard.lan/control/stats', 'https://key@opnsense.lan/api', 'user:pw@host/api']) { + assert.equal(urlHasLogin(u), true, u); + } + for (const u of ['http://adguard.lan/control/stats', 'http://host/api?u=a@b', '', 'http://', undefined]) { + assert.equal(urlHasLogin(u), false, String(u)); + } +}); diff --git a/ui/test/admin-save-logic.test.mjs b/ui/test/admin-save-logic.test.mjs index 9e6d7484..6051e2c2 100644 --- a/ui/test/admin-save-logic.test.mjs +++ b/ui/test/admin-save-logic.test.mjs @@ -79,6 +79,15 @@ test('buildAppItem enables healthcheck and activity from their fields', () => { assert.equal(item.monitoring.activity.extract, 'stats.total'); }); +test('buildAppItem keeps a login only when it has a username', () => { + const base = { label: 'A', href: 'http://x', actEn: true, actUrl: 'http://api', actInt: 30 }; + const login = v => buildAppItem({ ...base, actLogin: v }, null).item.monitoring.activity.login; + assert.deepEqual(login({ username: 'monitor', password: 'pw' }), { username: 'monitor', password: 'pw' }); + assert.deepEqual(login({ username: 'monitor', password: '' }), { username: 'monitor', password: undefined }); + assert.equal(login({ username: '', password: 'pw' }), undefined); + assert.equal(login(undefined), undefined); +}); + test('buildAppItem maps multiple extract paths to objects', () => { const { item } = buildAppItem({ label: 'A', href: 'http://x', spaths: ['a', 'b'] }, null); assert.deepEqual(item.monitoring.activity.extract, [{ path: 'a' }, { path: 'b' }]);