From 8fe352be69be87c0436152f352db07ebaac313e5 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:42:00 -0400 Subject: [PATCH] Offer to add the current address to Allowed Addresses --- e2e/allowed-hosts.spec.js | 42 +++++++++++++++++++++++++++++++++++++++ ui/admin/index.html | 4 +++- ui/i18n/de.json | 1 + ui/i18n/en.json | 1 + ui/i18n/es.json | 1 + ui/i18n/fa.json | 1 + ui/i18n/fr.json | 1 + ui/i18n/zh-Hans.json | 1 + ui/js/admin-settings.js | 28 ++++++++++++++++++++++++++ ui/js/admin.js | 2 +- 10 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 e2e/allowed-hosts.spec.js diff --git a/e2e/allowed-hosts.spec.js b/e2e/allowed-hosts.spec.js new file mode 100644 index 00000000..a5583cbd --- /dev/null +++ b/e2e/allowed-hosts.spec.js @@ -0,0 +1,42 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const { seedConfig } = require('./helpers'); +const { BASE_URL } = require('./base-url'); + +const PROTECTED = { enabled: true, authenticated: true, passwordSet: true }; + +test.beforeEach(async ({ request }) => { + await seedConfig(request, { items: [] }); +}); + +async function openGeneral(page, origin) { + await page.goto(`${origin}/admin/`); + await page.locator('body.authed').waitFor({ state: 'attached' }); + await page.locator('.nl[data-sec="general"]').click(); +} + +test('signed in on an unlisted name, Settings offers to list it', async ({ page }) => { + const name = 'dash.example.test'; + await page.route(`http://${name}/**`, async route => { + const url = new URL(route.request().url()); + if (url.pathname === '/api/auth/check') return route.fulfill({ json: PROTECTED }); + const response = await route.fetch({ url: `${BASE_URL}${url.pathname}${url.search}` }); + return route.fulfill({ response }); + }); + await openGeneral(page, `http://${name}`); + + await expect(page.locator('#hosts-add-name')).toHaveText(name); + await expect(page.locator('#hosts-add-tip')).toBeVisible(); + await page.locator('#hosts-add').click(); + await expect(page.locator('#ie-hosts-v')).toHaveText(name); + await expect(page.locator('#hosts-add-wrap')).not.toHaveClass(/\bopen\b/); + await expect(page.locator('#ie-hosts .pe')).toBeFocused(); + await expect(page.locator('#srv-save')).toBeEnabled(); +}); + +test('on a local address Settings offers nothing', async ({ page }) => { + await page.route('**/api/auth/check', route => route.fulfill({ json: PROTECTED })); + await openGeneral(page, BASE_URL); + await expect(page.locator('#sec-en')).toBeChecked(); + await expect(page.locator('#hosts-add-wrap')).not.toHaveClass(/\bopen\b/); +}); diff --git a/ui/admin/index.html b/ui/admin/index.html index 5401639e..998dbaa3 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -106,7 +106,9 @@

Leave blank to keep existing password. Min 8 characters. For stronger protection, consider placing the dashboard behind a dedicated authentication proxy. This built-in password is a lightweight gate, not a full authentication layer.

Allowed AddressesLocal addresses only
+
+

This page is open on an address that isn’t listed. Add it so a page on another site can’t use up your sign-in attempts.

While no password is set, Stackyard answers only on IP addresses, local names such as nas or nas.local, and the addresses listed here. Separate them with commas.

Backup
@@ -300,6 +302,6 @@

General

- + diff --git a/ui/i18n/de.json b/ui/i18n/de.json index 26ee3591..bae7583d 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -43,6 +43,7 @@ "hostIpTip": "Damit können Badges Daten von der eigenen Adresse deines Servers abrufen. Ohne sie werden diese Anfragen blockiert.", "allowedHosts": "Erlaubte Adressen", "allowedHostsNone": "Nur lokale Adressen", + "allowedHostsAddTip": "Diese Seite ist unter einer Adresse geöffnet, die nicht aufgeführt ist. Füge sie hinzu, damit eine Seite einer anderen Website deine Anmeldeversuche nicht aufbrauchen kann.", "allowedHostsTip": "Solange kein Passwort festgelegt ist, antwortet Stackyard nur unter IP-Adressen, lokalen Namen wie nas oder nas.local und den hier aufgeführten Adressen. Trenne sie mit Kommas.", "monitoring": "Überwachung", "loggingLevel": "Protokollstufe", diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 59bb08e2..6f642644 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -43,6 +43,7 @@ "hostIpTip": "Lets badges fetch from your server's own address. Without it, those requests are blocked.", "allowedHosts": "Allowed Addresses", "allowedHostsNone": "Local addresses only", + "allowedHostsAddTip": "This page is open on an address that isn’t listed. Add it so a page on another site can’t use up your sign-in attempts.", "allowedHostsTip": "While no password is set, Stackyard answers only on IP addresses, local names such as nas or nas.local, and the addresses listed here. Separate them with commas.", "monitoring": "Monitoring", "loggingLevel": "Logging Level", diff --git a/ui/i18n/es.json b/ui/i18n/es.json index 2a51eb4d..a1dc4fb6 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -43,6 +43,7 @@ "hostIpTip": "Permite que las insignias obtengan datos de la propia dirección de tu servidor. Sin esto, esas solicitudes se bloquean.", "allowedHosts": "Direcciones permitidas", "allowedHostsNone": "Solo direcciones locales", + "allowedHostsAddTip": "Esta página está abierta en una dirección que no está en la lista. Añádela para que una página de otro sitio no pueda agotar tus intentos de inicio de sesión.", "allowedHostsTip": "Mientras no haya contraseña, Stackyard solo responde en direcciones IP, nombres locales como nas o nas.local, y las direcciones de esta lista. Sepáralas con comas.", "monitoring": "Monitorización", "loggingLevel": "Nivel de registro", diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index 76052143..758887f4 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -43,6 +43,7 @@ "hostIpTip": "به نشان‌ها اجازه می‌دهد داده را از آدرس خود سرور بگیرند. بدون آن، این درخواست‌ها مسدود می‌شوند.", "allowedHosts": "آدرس‌های مجاز", "allowedHostsNone": "فقط آدرس‌های محلی", + "allowedHostsAddTip": "این صفحه روی آدرسی باز است که در این فهرست نیست. آن را اضافه کنید تا صفحه‌ای از سایتی دیگر نتواند تلاش‌های ورود شما را تمام کند.", "allowedHostsTip": "تا وقتی رمز عبوری تعیین نشده، Stackyard فقط در آدرس‌های IP، نام‌های محلی مانند nas یا nas.local و آدرس‌های این فهرست پاسخ می‌دهد. آن‌ها را با ویرگول جدا کنید.", "monitoring": "نظارت", "loggingLevel": "سطح گزارش", diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index f5792076..9f7d55c5 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -43,6 +43,7 @@ "hostIpTip": "Permet aux badges de récupérer des données à l’adresse de votre propre serveur. Sans cela, ces requêtes sont bloquées.", "allowedHosts": "Adresses autorisées", "allowedHostsNone": "Adresses locales uniquement", + "allowedHostsAddTip": "Cette page est ouverte sur une adresse qui n’est pas listée. Ajoutez-la pour qu’une page d’un autre site ne puisse pas épuiser vos tentatives de connexion.", "allowedHostsTip": "Tant qu’aucun mot de passe n’est défini, Stackyard ne répond qu’aux adresses IP, aux noms locaux comme nas ou nas.local, et aux adresses listées ici. Séparez-les par des virgules.", "monitoring": "Surveillance", "loggingLevel": "Niveau de journalisation", diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index aa877f0d..ecb861d5 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -43,6 +43,7 @@ "hostIpTip": "允许徽章从服务器自身地址获取数据。否则这些请求会被拦截。", "allowedHosts": "允许的地址", "allowedHostsNone": "仅本地地址", + "allowedHostsAddTip": "此页面所在的地址不在列表中。添加它,以免其他网站的页面耗尽你的登录尝试次数。", "allowedHostsTip": "在未设置密码时,Stackyard 只在 IP 地址、nas 或 nas.local 这样的本地名称以及此处列出的地址上响应。多个地址用逗号分隔。", "monitoring": "监控", "loggingLevel": "日志级别", diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 2a627df2..965e7426 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -127,6 +127,32 @@ function syncSessionRows(now = false) { const canRevoke = on && _passwordSet; reveal(el('sec-revoke-wrap'), canRevoke, now); reveal(el('revoke-tip-wrap'), canRevoke, now); + syncHostOffer(now); +} + +function syncHostOffer(now = false) { + const here = hostnameOf(location.host); + const listed = parseHostList(inp('srv-hosts')?.value || '') || []; + const show = !!inp('sec-en')?.checked && _passwordSet && !!here && !isLocalAddress(here) && !listed.includes(here); + const name = el('hosts-add-name'); + if (name && here) setUserText(name, here); + reveal(el('hosts-add-wrap'), show, now); + reveal(el('hosts-add-tip-wrap'), show, now); +} + +function addCurrentHost() { + const here = hostnameOf(location.host); + const hostsEl = inp('srv-hosts'); + const list = parseHostList(hostsEl?.value || ''); + if (!hostsEl || !here || !list) return; + hostsEl.value = [...list, here].join(', '); + const valEl = el('ie-hosts-v'); + if (valEl) { + setUserText(valEl, hostsEl.value); + valEl.classList.remove('is-ph'); + } + syncHostOffer(); + /** @type {HTMLElement | null | undefined} */ (el('ie-hosts')?.querySelector('.pe'))?.focus(); } /* Rebuild on every load. The control takes its value at render time and has no @@ -279,6 +305,8 @@ export function loadSettings(c) { } }); secEnEl?.addEventListener('change', () => syncSessionRows()); + el('hosts-add')?.addEventListener('click', addCurrentHost); + inp('srv-hosts')?.addEventListener('blur', () => syncHostOffer()); _srvTrack = trackSave('srv-save', readServerForm); _bgTrack = trackSave('bg-save', readWallpaperForm); diff --git a/ui/js/admin.js b/ui/js/admin.js index 7c349c2f..eb7ab7b7 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -23,7 +23,7 @@ import { showBgFields, showWallpaperFile, wireUnsplashKey, -} from '/js/admin-settings.js?v=41547198'; +} from '/js/admin-settings.js?v=de5b1723'; import { apiGet, apiPost,