diff --git a/api/src/routes/wallpaper.js b/api/src/routes/wallpaper.js index baa70d82..3e632edf 100644 --- a/api/src/routes/wallpaper.js +++ b/api/src/routes/wallpaper.js @@ -108,16 +108,49 @@ function mayWrite(req, res) { /* Keep the cache. Without it every request, including one sent by a page on another site, spends a call of the key's hourly quota. */ const PHOTO_TTL_MS = 24 * 60 * 60 * 1000; -/** @type {{ key: string, at: number, url: string } | null} */ +/** @typedef {{ name: string, profile: string, site: string }} PhotoCredit */ +/** @type {{ key: string, at: number, url: string, credit: PhotoCredit|null } | null} */ let _photo = null; -/** @type {{ key: string, promise: Promise<{ url: string|null, error?: string }> } | null} */ +/** @type {{ key: string, promise: Promise<{ url: string|null, credit?: PhotoCredit|null, error?: string }> } | null} */ let _pending = null; +const UTM = 'utm_source=stackyard&utm_medium=referral'; +const USERNAME_RE = /^[A-Za-z0-9_-]{1,64}$/; + +/** The photographer and Unsplash links the API terms require beside the photo. + + @param {any} photo a `/photos/random` reply @returns {PhotoCredit|null} */ +function photoCredit(photo) { + const username = photo?.user?.username; + if (typeof username !== 'string' || !USERNAME_RE.test(username)) return null; + const name = typeof photo.user.name === 'string' && photo.user.name.trim() ? photo.user.name.trim() : username; + return { + name: name.slice(0, 100), + profile: `https://unsplash.com/@${username}?${UTM}`, + site: `https://unsplash.com/?${UTM}`, + }; +} + +/** Unsplash counts a photo as used only through this request. + + @param {any} photo @param {string} apiKey */ +function reportDownload(photo, apiKey) { + const loc = photo?.links?.download_location; + if (typeof loc !== 'string' || !loc.startsWith('https://api.unsplash.com/')) return; + const u = new URL(loc); + u.searchParams.set('client_id', apiKey); + fetchUnchecked(u.href) + .then(r => { + if (r.status < 200 || r.status >= 300) log.warn('Unsplash download report failed', { status: r.status }); + }) + .catch(e => log.warn('Unsplash download report failed', { error: e instanceof Error ? e.message : String(e) })); +} + /** @param {{ apiKey?: string, collection?: string }} bg */ function randomPhoto(bg) { const key = `${bg.apiKey || ''}\n${bg.collection || ''}`; if (_photo && _photo.key === key && Date.now() - _photo.at < PHOTO_TTL_MS) - return Promise.resolve({ url: _photo.url }); + return Promise.resolve({ url: _photo.url, credit: _photo.credit }); if (_pending?.key === key) return _pending.promise; const promise = (async () => { const p = new URLSearchParams({ orientation: 'landscape', content_filter: 'high', client_id: bg.apiKey || '' }); @@ -129,8 +162,10 @@ function randomPhoto(bg) { return { url: null, error: 'No image returned' }; } const url = `${raw}&w=2800&h=1800&q=85&fm=jpg&fit=crop&crop=entropy`; - _photo = { key, at: Date.now(), url }; - return { url }; + const credit = photoCredit(r.data); + _photo = { key, at: Date.now(), url, credit }; + reportDownload(r.data, bg.apiKey || ''); + return { url, credit }; })().finally(() => { if (_pending?.promise === promise) _pending = null; }); diff --git a/api/test/wallpaper-unsplash.test.js b/api/test/wallpaper-unsplash.test.js index 265ddf46..57702689 100644 --- a/api/test/wallpaper-unsplash.test.js +++ b/api/test/wallpaper-unsplash.test.js @@ -9,12 +9,21 @@ const http = require('node:http'); const proxy = require('../src/proxy'); /** @type {string[]} */ let calls = []; -/** @type {(url: string) => Promise} */ -let reply = async () => ({ - status: 200, - data: { urls: { raw: `https://images.unsplash.com/photo-${calls.length}?ixid=x` } }, +/** @type {string[]} */ +let pings = []; +const photo = (extra = {}) => ({ + urls: { raw: `https://images.unsplash.com/photo-${calls.length}?ixid=x` }, + user: { username: 'anniespratt', name: 'Annie Spratt' }, + links: { download_location: `https://api.unsplash.com/photos/p${calls.length}/download?ixid=x` }, + ...extra, }); +/** @type {(url: string) => Promise} */ +let reply = async () => ({ status: 200, data: photo() }); proxy.fetchUnchecked = url => { + if (url.includes('/download')) { + pings.push(url); + return Promise.resolve({ status: 200, data: {} }); + } calls.push(url); return reply(url); }; @@ -52,6 +61,7 @@ after(() => new Promise(r => server.close(r))); beforeEach(() => { calls = []; + pings = []; _resetPhoto(); unsplash(); clock += 1000; @@ -90,9 +100,61 @@ test("Unsplash's own error text is not passed on, and a failure is not kept", as await getWallpaper(); assert.equal(calls.length, 2); } finally { - reply = async () => ({ - status: 200, - data: { urls: { raw: `https://images.unsplash.com/photo-${calls.length}?ixid=x` } }, - }); + reply = async () => ({ status: 200, data: photo() }); + } +}); + +test('the photo comes with the photographer and Unsplash links', async () => { + const r = await getWallpaper(); + assert.deepEqual(r.credit, { + name: 'Annie Spratt', + profile: 'https://unsplash.com/@anniespratt?utm_source=stackyard&utm_medium=referral', + site: 'https://unsplash.com/?utm_source=stackyard&utm_medium=referral', + }); + assert.deepEqual((await getWallpaper()).credit, r.credit, 'the cached photo keeps its credit'); +}); + +test('each new photo is reported as used once, with the key', async () => { + await getWallpaper(); + await getWallpaper(); + await new Promise(r => setImmediate(r)); + assert.equal(pings.length, 1); + const u = new URL(pings[0]); + assert.equal(u.origin, 'https://api.unsplash.com'); + assert.equal(u.pathname, '/photos/p1/download'); + assert.equal(u.searchParams.get('client_id'), 'key-1'); + assert.equal(u.searchParams.get('ixid'), 'x'); +}); + +/* The key goes on this request. Another host would receive it. */ +test('a download link on another host is not followed', async () => { + reply = async () => ({ + status: 200, + data: photo({ links: { download_location: 'https://evil.example/photos/p/download' } }), + }); + try { + await getWallpaper(); + await new Promise(r => setImmediate(r)); + assert.equal(pings.length, 0); + } finally { + reply = async () => ({ status: 200, data: photo() }); + } +}); + +test('a photographer handle that cannot form a profile link gives no credit', async () => { + for (const user of [undefined, {}, { username: '../x', name: 'X' }, { username: 'a b' }]) { + _resetPhoto(); + reply = async () => ({ status: 200, data: photo({ user }) }); + assert.equal((await getWallpaper()).credit, null, JSON.stringify(user)); + } + reply = async () => ({ status: 200, data: photo() }); +}); + +test('a photographer with no display name is credited by handle', async () => { + reply = async () => ({ status: 200, data: photo({ user: { username: 'anniespratt', name: ' ' } }) }); + try { + assert.equal((await getWallpaper()).credit.name, 'anniespratt'); + } finally { + reply = async () => ({ status: 200, data: photo() }); } }); diff --git a/tsconfig.frontend.json b/tsconfig.frontend.json index 90d72e0f..369790ac 100644 --- a/tsconfig.frontend.json +++ b/tsconfig.frontend.json @@ -338,6 +338,12 @@ "/js/palette.js?v=*": [ "./ui/js/palette.js" ], + "/js/photo-credit.js": [ + "./ui/js/photo-credit.js" + ], + "/js/photo-credit.js?v=*": [ + "./ui/js/photo-credit.js" + ], "/js/password-strength.js": [ "./ui/js/password-strength.js" ], diff --git a/ui/admin/index.html b/ui/admin/index.html index 47b991a8..d60a71ee 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -319,6 +319,6 @@

General

- + diff --git a/ui/css/dashboard.css b/ui/css/dashboard.css index 785783fa..9ea4f33f 100644 --- a/ui/css/dashboard.css +++ b/ui/css/dashboard.css @@ -409,6 +409,28 @@ /* Mirrored by the no-dock reserve in desktopSlots(). */ body.no-dock:not(.is-mob) #dots { bottom:24px } + /* ── Photo credit ── */ + #photo-credit { + position:fixed; z-index:300; + inset-block-end:calc(14px + env(safe-area-inset-bottom)); inset-inline-start:calc(20px + env(safe-area-inset-left)); + overflow:hidden; text-overflow:ellipsis; white-space:nowrap; + font-weight:500; + font-size:var(--fs-caption-2);line-height:var(--lh-caption-2);letter-spacing:var(--tr-caption-2); + color:var(--label-color-2); text-shadow:var(--label-shadow); + transition:color .2s; + } + #photo-credit[data-place="top"] { inset-block:calc(12px + env(safe-area-inset-top)) auto } + #photo-credit[data-tone="dark"] { color:var(--label-color-2-dark); text-shadow:var(--label-shadow-dark) } + #photo-credit:hover, #photo-credit:focus-within { color:var(--label-color) } + #photo-credit[data-tone="dark"]:hover, #photo-credit[data-tone="dark"]:focus-within { color:var(--label-color-dark) } + #photo-credit a { color:inherit; text-decoration:none } + #photo-credit a:hover { text-decoration:underline } + #photo-credit a:focus-visible { outline:2px solid currentColor; outline-offset:2px; border-radius:2px } + body.is-mob #photo-credit { + inset-block:max(4px, calc(var(--sa-top) - 16px)) auto; inset-inline:0; + width:fit-content; max-width:70vw; margin-inline:auto; + } + /* ── Search pill — mobile only ── */ #mob-search-pill { position:fixed; left:50%; z-index:500; @@ -981,6 +1003,8 @@ body.is-mob .spot-field:focus-within { outline:2px solid var(--accent); outline- } .empty-state-mark, .empty-state-title, .empty-state-btn { color:#FFFFFF; } .empty-state-mark[data-tone="dark"], .empty-state-title[data-tone="dark"], .empty-state-btn[data-tone="dark"] { color:#000000; } + #photo-credit { color:#FFFFFF; } + #photo-credit[data-tone="dark"] { color:#000000; } .srn { color:var(--label-primary); } .srh, .sr-section { color:rgba(235,235,245,.85); } .widget, .mob-widget-card { border-color:rgba(255,255,255,.35); } diff --git a/ui/i18n/de.json b/ui/i18n/de.json index 6715c7f3..9f7aa86a 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -316,6 +316,7 @@ "searchInput": "Apps und Ordner durchsuchen", "noResults": "Keine Ergebnisse", "pagination": "Dashboard-Seiten", + "photoCredit": "Fotonachweis", "pageAnnounce": "Seite {page} von {total}", "goToPage": "Zu Seite {page} von {total}", "appsAndFolders": "Apps & Ordner", diff --git a/ui/i18n/en.json b/ui/i18n/en.json index d9a75e52..e4da224a 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -316,6 +316,7 @@ "searchInput": "Search apps and folders", "noResults": "No results", "pagination": "Dashboard pages", + "photoCredit": "Photo credit", "pageAnnounce": "Page {page} of {total}", "goToPage": "Go to page {page} of {total}", "appsAndFolders": "Apps & Folders", diff --git a/ui/i18n/es.json b/ui/i18n/es.json index 38892f33..5ec587f7 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -322,6 +322,7 @@ "searchInput": "Buscar aplicaciones y carpetas", "noResults": "Sin resultados", "pagination": "Páginas del panel", + "photoCredit": "Crédito de la foto", "pageAnnounce": "Página {page} de {total}", "goToPage": "Ir a la página {page} de {total}", "appsAndFolders": "Aplicaciones y carpetas", diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index 50c02409..85e0fe14 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -316,6 +316,7 @@ "searchInput": "جستجوی برنامه‌ها و پوشه‌ها", "noResults": "نتیجه‌ای یافت نشد", "pagination": "صفحه‌های داشبورد", + "photoCredit": "منبع عکس", "pageAnnounce": "صفحه {page} از {total}", "goToPage": "رفتن به صفحه {page} از {total}", "appsAndFolders": "برنامه‌ها و پوشه‌ها", diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index 956233c2..263bb037 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -322,6 +322,7 @@ "searchInput": "Rechercher des applications et des dossiers", "noResults": "Aucun résultat", "pagination": "Pages du tableau de bord", + "photoCredit": "Crédit photo", "pageAnnounce": "Page {page} sur {total}", "goToPage": "Aller à la page {page} sur {total}", "appsAndFolders": "Applications et dossiers", diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index 79372d63..372b5a97 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -310,6 +310,7 @@ "searchInput": "搜索应用和文件夹", "noResults": "没有结果", "pagination": "仪表板页面", + "photoCredit": "照片来源", "pageAnnounce": "第 {page} 页,共 {total} 页", "goToPage": "转到第 {page} 页,共 {total} 页", "appsAndFolders": "应用与文件夹", diff --git a/ui/index.html b/ui/index.html index 7072cf5f..12c528be 100644 --- a/ui/index.html +++ b/ui/index.html @@ -26,7 +26,7 @@ Stackyard - + @@ -43,6 +43,7 @@
+
@@ -56,5 +57,5 @@
- + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index c3324ba1..167fce41 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -1,4 +1,4 @@ -import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=017012d7'; +import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=ab3cac1d'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { loadLocalIcons, @@ -33,10 +33,10 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=c509d11e'; -import { createListbox } from '/js/listbox.js?v=42aec241'; +} from '/js/admin-shared.js?v=f4752f33'; +import { createListbox } from '/js/listbox.js?v=b9f71853'; import { MAX_LABELS } from '/js/badge-logic.js?v=85b1bc96'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=b47faa12'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=7d6a7623'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=1c258dc1'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=eb6cc81c'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index 51a0b67d..b50a0c92 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,8 +1,8 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=c509d11e'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=f4752f33'; import { initI18n, t } from '/js/i18n.js?v=ed7c5565'; import { loginErrorKey } from '/js/admin-error.js?v=1c258dc1'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; -import { el, inp as inpById, qa } from '/js/utils.js?v=017012d7'; +import { el, inp as inpById, qa } from '/js/utils.js?v=ab3cac1d'; import { blockingScreenFor } from '/js/config-recovery.js?v=85138697'; export async function checkAuth(onLogin) { diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index 60b50a47..ab10365f 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=c509d11e'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=f4752f33'; 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=017012d7'; +import { qa, q } from '/js/utils.js?v=ab3cac1d'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; -import { createListbox } from '/js/listbox.js?v=42aec241'; +import { createListbox } from '/js/listbox.js?v=b9f71853'; 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 554a59fd..93a2acfc 100644 --- a/ui/js/admin-drag.js +++ b/ui/js/admin-drag.js @@ -19,7 +19,7 @@ import { import { DOCK_MAX } from '/js/limits.js?v=ea61a441'; import { state } from '/js/admin-state.js?v=064b9b85'; import { snapshotItems } from '/js/admin-save-logic.js?v=b83791fb'; -import { qa } from '/js/utils.js?v=017012d7'; +import { qa } from '/js/utils.js?v=ab3cac1d'; /** @type {(before: unknown) => void} */ let _save = () => {}; diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index a72b4075..2cac61d2 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -15,14 +15,14 @@ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=064b9b85'; import { snapshotItems } from '/js/admin-save-logic.js?v=b83791fb'; import { isShownChild, reorderItems, shownChildren, topLevelRows } from '/js/admin-logic.js?v=28471483'; import { listGroup } from '/js/admin-drag-logic.js?v=4aae5094'; -import { initDrag, redrawHeld, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=e2c9c061'; +import { initDrag, redrawHeld, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=1c4004a0'; import { DOCK_MAX } from '/js/limits.js?v=ea61a441'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { paintIcon, toast } from '/js/admin-shared.js?v=c509d11e'; -import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=017012d7'; +import { paintIcon, toast } from '/js/admin-shared.js?v=f4752f33'; +import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=ab3cac1d'; 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=b480a481'; +import { sizeLabel } from '/js/admin-widget-form.js?v=a5539971'; import { widgetGlyph } from '/js/widget-glyphs.js?v=479a53ee'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 41f2ba72..0aca9e30 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=c509d11e'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=f4752f33'; import { socketProbeAdvice } from '/js/admin-error.js?v=1c258dc1'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=ed7c5565'; @@ -11,11 +11,11 @@ import { BLOCK, } from '/js/admin-logic.js?v=28471483'; import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; -import { el, inp, setUserText } from '/js/utils.js?v=017012d7'; +import { el, inp, setUserText } from '/js/utils.js?v=ab3cac1d'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { serialWrites } from '/js/admin-save-logic.js?v=b83791fb'; -import { renderColorControl } from '/js/admin-color-control.js?v=b47faa12'; -import { BACKDROP } from '/js/background.js?v=4d651887'; +import { renderColorControl } from '/js/admin-color-control.js?v=7d6a7623'; +import { BACKDROP } from '/js/background.js?v=288bfc2a'; import { ALLOWED_HOSTS_MAX, firstBadHost, diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index ed926c13..7652e73d 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -1,6 +1,6 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=28471483'; -import { el, isolate, q } from '/js/utils.js?v=017012d7'; +import { el, isolate, q } from '/js/utils.js?v=ab3cac1d'; import { t } from '/js/i18n.js?v=ed7c5565'; import { errorAdvice } from '/js/admin-error.js?v=1c258dc1'; import { formatList, formatNumber } from '/js/format-number.js?v=abcd4ae4'; diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index 4b5affff..a7d63206 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,11 +1,11 @@ import { state } from '/js/admin-state.js?v=064b9b85'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=c509d11e'; -import { createListbox } from '/js/listbox.js?v=42aec241'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=5c591625'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=f4752f33'; +import { createListbox } from '/js/listbox.js?v=b9f71853'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=92ca09de'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=28471483'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { focusFirst, q, qi, qa } from '/js/utils.js?v=017012d7'; +import { focusFirst, q, qi, qa } from '/js/utils.js?v=ab3cac1d'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']); diff --git a/ui/js/admin.js b/ui/js/admin.js index 69838c6c..94916cb1 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -4,10 +4,10 @@ import { buildSettingsTileForm, captureActLabels, serializeKvRows, -} from '/js/admin-app-form.js?v=63c9ce52'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=d4c794a0'; +} from '/js/admin-app-form.js?v=925a4b11'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=ec5cd673'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=24b894eb'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=511689a7'; import { resolveAdminSection, settleDock } from '/js/admin-logic.js?v=28471483'; import { afterImport, @@ -31,7 +31,7 @@ import { wireUnsplashKey, wireProxmox, applyHealthChecks, -} from '/js/admin-settings.js?v=649babe5'; +} from '/js/admin-settings.js?v=8eb7db69'; import { apiGet, apiPost, @@ -45,13 +45,13 @@ import { setReauthHandler, toast, withheldText, -} from '/js/admin-shared.js?v=c509d11e'; +} from '/js/admin-shared.js?v=f4752f33'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=064b9b85'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=b480a481'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=a5539971'; import { initFluidHover } from '/js/fluid-hover.js?v=eb6cc81c'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=75a71ab4'; -import { createListbox } from '/js/listbox.js?v=42aec241'; +import { createListbox } from '/js/listbox.js?v=b9f71853'; 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'; @@ -92,8 +92,8 @@ import { storeGet, storeSet, tgt, -} from '/js/utils.js?v=017012d7'; -import { applyBackground, resolveBackground } from '/js/background.js?v=4d651887'; +} from '/js/utils.js?v=ab3cac1d'; +import { applyBackground, resolveBackground } from '/js/background.js?v=288bfc2a'; import { parseYamlTolerant, YamlLiteError, YamlTooLargeError } from '/js/yaml-lite.js?v=a42ef914'; ensureSprite(); diff --git a/ui/js/background.js b/ui/js/background.js index c1cb427d..9d0cd467 100644 --- a/ui/js/background.js +++ b/ui/js/background.js @@ -1,17 +1,18 @@ // @ts-check -import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=017012d7'; -import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; +import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=ab3cac1d'; +import { loadWallpaper, saveWallpaper, validCredit } from '/js/wallpaper-cache.js?v=321c8003'; export const BACKDROP = '#0d1117'; const DEFAULT_BRIGHTNESS = 0.62; +/** @typedef {{ name: string, profile: string, site: string }} PhotoCredit */ /** @typedef {{ image: string, color: string, brightness: string, size: string, - url: string|null, fit: 'fit'|'fill' }} Background */ + url: string|null, fit: 'fit'|'fill', credit: PhotoCredit|null }} Background */ -/** @param {any} bg @param {string|null} [wallpaperUrl] +/** @param {any} bg @param {string|null} [wallpaperUrl] @param {PhotoCredit|null} [credit] @returns {Background|null} */ -export function backgroundFor(bg, wallpaperUrl = null) { +export function backgroundFor(bg, wallpaperUrl = null, credit = null) { const s = bg || {}; if (s.type === 'color' && s.color) { return { @@ -21,6 +22,7 @@ export function backgroundFor(bg, wallpaperUrl = null) { size: 'cover', url: null, fit: 'fill', + credit: null, }; } const url = s.type === 'url' ? s.url : s.type === 'unsplash' ? wallpaperUrl : null; @@ -34,6 +36,7 @@ export function backgroundFor(bg, wallpaperUrl = null) { size: fit === 'fit' ? 'contain' : 'cover', url: sanitizeCssUrl(url), fit, + credit: s.type === 'unsplash' ? credit : null, }; } @@ -45,14 +48,16 @@ export function applyBackground(root, b) { root.style.setProperty('--bg-size', b.size); } -async function unsplashUrl(bg) { +/** @param {any} bg @returns {Promise<{ url: string, credit: PhotoCredit|null }|null>} */ +async function unsplashPhoto(bg) { const cached = loadWallpaper(bg); if (cached) return cached; const r = await fetch('/api/wallpaper', { cache: 'no-store' }); const d = await r.json(); - const url = d.url || null; - if (url) saveWallpaper(url, bg); - return url; + if (typeof d.url !== 'string' || !d.url) return null; + const photo = { url: d.url, credit: validCredit(d.credit) }; + saveWallpaper(photo, bg); + return photo; } const decoded = url => @@ -67,9 +72,9 @@ const decoded = url => export async function resolveBackground(bg) { const s = bg || {}; if (s.type === 'unsplash') { - const url = await unsplashUrl(s); - if (!url) return null; - return (await decoded(url)) ? backgroundFor(s, url) : null; + const photo = await unsplashPhoto(s); + if (!photo) return null; + return (await decoded(photo.url)) ? backgroundFor(s, photo.url, photo.credit) : null; } return backgroundFor(s); } diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index 91e7e8aa..750c2d98 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -28,9 +28,9 @@ import { storeSet, teardownWidgets, titleWhenTruncated, -} from '/js/utils.js?v=017012d7'; +} from '/js/utils.js?v=ab3cac1d'; import { initFluidHover } from '/js/fluid-hover.js?v=eb6cc81c'; -import { initSpotlight } from '/js/spotlight.js?v=73fdaf2e'; +import { initSpotlight } from '/js/spotlight.js?v=93d9e423'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; import { initI18n, t, currentLang } from '/js/i18n.js?v=ed7c5565'; import { blockingScreenFor, showBlockingScreen } from '/js/config-recovery.js?v=85138697'; @@ -47,7 +47,7 @@ import { resetMobileChrome, mkFolderGlyph, swipePager, -} from '/js/ui.js?v=9af2a487'; +} from '/js/ui.js?v=dbd45ff7'; import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=85b1bc96'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=23793736'; @@ -60,11 +60,12 @@ import { restorePage, wheelPager, } from '/js/dashboard-logic.js?v=5fa66027'; -import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=4d651887'; +import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=288bfc2a'; +import { creditPlacement, showPhotoCredit } from '/js/photo-credit.js?v=1801451c'; import { repeatJittered } from '/js/jitter.js?v=087a1fcf'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=6680c0bf'; import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf'; -import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=4e97555e'; +import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=46ab91e5'; import { ensureSprite, iconSvg } from '/js/icon-set.js?v=26655aca'; import { pageTheme, paletteColor } from '/js/palette.js?v=3fb8ae43'; import { THEME_KEY, applyTheme, prefersDark, readMode, resolveTheme } from '/js/theme.js?v=eeafa4b5'; @@ -539,8 +540,30 @@ function syncMobPages() { the labels as they are. */ let bgTone = { grid: null, tone: null }; +const CREDIT_EDGE = 20; + +function placeCredit() { + const credit = el('photo-credit'); + if (!credit || credit.hidden) return; + if (document.body.classList.contains('is-mob')) { + credit.removeAttribute('data-place'); + credit.style.removeProperty('max-width'); + return; + } + const dock = document.body.classList.contains('no-dock') ? null : el('dock').getBoundingClientRect(); + const { place, maxWidth } = creditPlacement( + window.innerWidth, + dock, + document.documentElement.dir === 'rtl', + CREDIT_EDGE, + ); + credit.dataset.place = place; + credit.style.maxWidth = `${maxWidth}px`; +} + function retone() { requestAnimationFrame(() => { + placeCredit(); applyLabelTones(bgTone); /* Same frame: both need the layout the build produced, and both re-run on a rebuild and a resize for the same reason. */ @@ -582,6 +605,7 @@ async function applyBg() { bgTone = { grid: null, tone: themeTone() }; retone(); const bg = await resolveBackground(S.background || {}); + showPhotoCredit(el('photo-credit'), bg?.credit ?? null); if (!bg) return; applyBackground(root, bg); if (bg.url) { diff --git a/ui/js/label-contrast.js b/ui/js/label-contrast.js index 34bde3de..6e666b2e 100644 --- a/ui/js/label-contrast.js +++ b/ui/js/label-contrast.js @@ -234,8 +234,9 @@ export function loadSamplingImage(url) { } /* The page indicator is chrome over the same wallpaper, and it is tinted rather - than inked, so it needs the same reading. So does the empty dashboard's text. */ -const LABEL_SELECTOR = '.ilabel, .dyn-mob-label, .dyn-fold-label, #dots, .empty-state > *'; + than inked, so it needs the same reading. So do the photo credit and the empty + dashboard's text. */ +const LABEL_SELECTOR = '.ilabel, .dyn-mob-label, .dyn-fold-label, #dots, #photo-credit, .empty-state > *'; /* A folder overlay lays its own scrim over the background. */ const SCRIM = '.folder-overlay, .folder-overlay-mobile'; diff --git a/ui/js/listbox.js b/ui/js/listbox.js index ebc21c4d..022255c2 100644 --- a/ui/js/listbox.js +++ b/ui/js/listbox.js @@ -10,7 +10,7 @@ import { nextActiveIndex, singleChoice } from '/js/admin-logic.js?v=28471483'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=eb6cc81c'; import { formatList } from '/js/format-number.js?v=abcd4ae4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa } from '/js/utils.js?v=017012d7'; +import { qa } from '/js/utils.js?v=ab3cac1d'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev'); diff --git a/ui/js/photo-credit.js b/ui/js/photo-credit.js new file mode 100644 index 00000000..783d8e54 --- /dev/null +++ b/ui/js/photo-credit.js @@ -0,0 +1,41 @@ +// @ts-check + +/** The narrowest bottom slot that still fits a short name. */ +const MIN_SLOT = 120; + +/** Where the desktop credit goes: beside the Dock while it fits, else the top + corner. Keep the credit off the Dock. It is fixed at the same bottom edge. + + @param {number} vw the viewport width + @param {{left:number, right:number}|null} dock the Dock's box, or null with no Dock + @param {boolean} rtl + @param {number} edge the gap kept from the screen edge and from the Dock + @returns {{ place: 'bottom'|'top', maxWidth: number }} */ +export function creditPlacement(vw, dock, rtl, edge) { + const room = dock ? (rtl ? vw - dock.right : dock.left) - 2 * edge : vw / 2 - edge; + return room >= MIN_SLOT + ? { place: 'bottom', maxWidth: Math.floor(room) } + : { place: 'top', maxWidth: Math.floor(vw / 2 - edge) }; +} + +/** @param {string} text @param {string} href @returns {HTMLAnchorElement} */ +function creditLink(text, href) { + const a = document.createElement('a'); + a.href = href; + a.textContent = text; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + return a; +} + +/** Shows the photographer and Unsplash links, or hides the element. + + @param {HTMLElement|null} el + @param {{ name: string, profile: string, site: string }|null} credit + @returns {void} */ +export function showPhotoCredit(el, credit) { + if (!el) return; + el.replaceChildren(); + el.hidden = !credit; + if (credit) el.append(creditLink(credit.name, credit.profile), ' / ', creditLink('Unsplash', credit.site)); +} diff --git a/ui/js/spotlight.js b/ui/js/spotlight.js index c4a124e2..edfc8919 100644 --- a/ui/js/spotlight.js +++ b/ui/js/spotlight.js @@ -1,5 +1,5 @@ import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=eb6cc81c'; -import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=017012d7'; +import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=ab3cac1d'; import { t } from '/js/i18n.js?v=ed7c5565'; /* Attached to the window so a re-open can undo the previous one. */ diff --git a/ui/js/ui.js b/ui/js/ui.js index 49b54e9b..a4a31164 100644 --- a/ui/js/ui.js +++ b/ui/js/ui.js @@ -17,9 +17,9 @@ import { qa, setCentredText, setUserText, -} from '/js/utils.js?v=017012d7'; +} from '/js/utils.js?v=ab3cac1d'; import { t, currentLang } from '/js/i18n.js?v=ed7c5565'; -import { toneForColor } from '/js/label-contrast.js?v=4e97555e'; +import { toneForColor } from '/js/label-contrast.js?v=46ab91e5'; import { mobileMetrics, gridColumnWidth, gridCellCount, dockFit } from '/js/mobile-metrics.js?v=938a32a2'; import { smoothRectPath } from '/js/smooth-corner.js?v=b7dda7e1'; import { mkGlassRim, observeGlass } from '/js/glass-rim.js?v=3faec233'; diff --git a/ui/js/utils.js b/ui/js/utils.js index e71c5bd7..605ed783 100644 --- a/ui/js/utils.js +++ b/ui/js/utils.js @@ -1,5 +1,5 @@ import { iconChain } from '/js/icons.js?v=5ac01d72'; -import { toneForColor } from '/js/label-contrast.js?v=4e97555e'; +import { toneForColor } from '/js/label-contrast.js?v=46ab91e5'; import { SETTINGS_ICON, SETTINGS_ICON_LIGHT } from '/js/settings-icon.js?v=4079b66a'; import { mkGlassRim } from '/js/glass-rim.js?v=3faec233'; import { smoothRectPath } from '/js/smooth-corner.js?v=b7dda7e1'; diff --git a/ui/js/wallpaper-cache.js b/ui/js/wallpaper-cache.js index 87d1b355..0585eaca 100644 --- a/ui/js/wallpaper-cache.js +++ b/ui/js/wallpaper-cache.js @@ -12,12 +12,29 @@ function wallpaperKey(bg) { return `${bg?.type || ''}|${bg?.collection || ''}`; } -/** The still-valid wallpaper URL held for these background settings, if any. +/** @typedef {{ name: string, profile: string, site: string }} PhotoCredit */ +/** @typedef {{ url: string, credit: PhotoCredit|null }} CachedWallpaper */ + +const UNSPLASH = 'https://unsplash.com/'; + +/** A credit the page may render as links, or null. + + @param {any} c @returns {PhotoCredit|null} */ +export function validCredit(c) { + if (!c || typeof c !== 'object') return null; + const { name, profile, site } = c; + if (typeof name !== 'string' || !name.trim()) return null; + if (typeof profile !== 'string' || !profile.startsWith(UNSPLASH)) return null; + if (typeof site !== 'string' || !site.startsWith(UNSPLASH)) return null; + return { name, profile, site }; +} + +/** The still-valid wallpaper held for these background settings, if any. @param {string|null|undefined} stored the raw stored entry @param {any} bg the background settings the page was built from @param {number} now - @returns {string|null} */ + @returns {CachedWallpaper|null} */ export function readWallpaperCache(stored, bg, now) { if (typeof stored !== 'string' || !stored) return null; let entry; @@ -31,15 +48,17 @@ export function readWallpaperCache(stored, bg, now) { if (typeof entry.at !== 'number' || !Number.isFinite(entry.at)) return null; /* A clock moved backwards would otherwise hold one photo forever. */ if (now < entry.at || now - entry.at >= WALLPAPER_TTL_MS) return null; - return entry.url; + /* Keep. An entry stored before credits would show the photo uncredited. */ + if (!('credit' in entry)) return null; + return { url: entry.url, credit: validCredit(entry.credit) }; } -/** @param {string} url @param {any} bg @param {number} now @returns {string} */ -export function writeWallpaperCache(url, bg, now) { - return JSON.stringify({ url, key: wallpaperKey(bg), at: now }); +/** @param {CachedWallpaper} wallpaper @param {any} bg @param {number} now @returns {string} */ +export function writeWallpaperCache(wallpaper, bg, now) { + return JSON.stringify({ url: wallpaper.url, credit: wallpaper.credit, key: wallpaperKey(bg), at: now }); } -/** @param {any} bg @param {number} [now] @returns {string|null} */ +/** @param {any} bg @param {number} [now] @returns {CachedWallpaper|null} */ export function loadWallpaper(bg, now = Date.now()) { let stored = null; try { @@ -50,9 +69,9 @@ export function loadWallpaper(bg, now = Date.now()) { return readWallpaperCache(stored, bg, now); } -/** @param {string} url @param {any} bg @param {number} [now] @returns {void} */ -export function saveWallpaper(url, bg, now = Date.now()) { +/** @param {CachedWallpaper} wallpaper @param {any} bg @param {number} [now] @returns {void} */ +export function saveWallpaper(wallpaper, bg, now = Date.now()) { try { - localStorage.setItem(WALLPAPER_STORE, writeWallpaperCache(url, bg, now)); + localStorage.setItem(WALLPAPER_STORE, writeWallpaperCache(wallpaper, bg, now)); } catch {} } diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index b20aadbc..f7aef780 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=c509d11e'; -import { createListbox } from '/js/listbox.js?v=42aec241'; -import { renderColorControl } from '/js/admin-color-control.js?v=b47faa12'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=f4752f33'; +import { createListbox } from '/js/listbox.js?v=b9f71853'; +import { renderColorControl } from '/js/admin-color-control.js?v=7d6a7623'; import { seedCarried, applyOptionSet, @@ -16,7 +16,7 @@ import { isPlainField, } from '/js/admin-logic.js?v=28471483'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=1c258dc1'; -import { qi } from '/js/utils.js?v=017012d7'; +import { qi } from '/js/utils.js?v=ab3cac1d'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { SAVED_ROW } from '/js/saved-row.js?v=44039df6'; diff --git a/ui/test/photo-credit.test.mjs b/ui/test/photo-credit.test.mjs new file mode 100644 index 00000000..1b0ea529 --- /dev/null +++ b/ui/test/photo-credit.test.mjs @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { register } from 'node:module'; + +register('./js-root-hooks.mjs', import.meta.url); + +const { creditPlacement } = await import('../js/photo-credit.js'); + +test('the credit sits beside the Dock, limited to the room left of it', () => { + assert.deepEqual(creditPlacement(1440, { left: 520, right: 920 }, false, 20), { place: 'bottom', maxWidth: 480 }); +}); + +test('in a right-to-left layout the room is measured right of the Dock', () => { + assert.deepEqual(creditPlacement(1440, { left: 400, right: 1000 }, true, 20), { place: 'bottom', maxWidth: 400 }); +}); + +/* Both are fixed to the bottom edge, so a credit wider than the room overlaps the Dock. */ +test('a Dock that leaves too little room moves the credit to the top', () => { + assert.equal(creditPlacement(800, { left: 100, right: 700 }, false, 20).place, 'top'); +}); + +test('with no Dock the credit keeps the bottom corner', () => { + assert.deepEqual(creditPlacement(1000, null, false, 20), { place: 'bottom', maxWidth: 480 }); +}); diff --git a/ui/test/wallpaper-cache.test.mjs b/ui/test/wallpaper-cache.test.mjs index d7f2ef6d..0ab6df7b 100644 --- a/ui/test/wallpaper-cache.test.mjs +++ b/ui/test/wallpaper-cache.test.mjs @@ -4,7 +4,13 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { loadWallpaper, readWallpaperCache, saveWallpaper, writeWallpaperCache } from '../js/wallpaper-cache.js'; +import { + loadWallpaper, + readWallpaperCache, + saveWallpaper, + validCredit, + writeWallpaperCache, +} from '../js/wallpaper-cache.js'; /* ── the wallpaper across a reload ────────────────────────────────────────── */ @@ -15,27 +21,33 @@ import { loadWallpaper, readWallpaperCache, saveWallpaper, writeWallpaperCache } const BG = { type: 'unsplash', collection: '1234' }; const T0 = 1_760_000_000_000; const DAY = 24 * 60 * 60 * 1000; +const CREDIT = { + name: 'Annie Spratt', + profile: 'https://unsplash.com/@anniespratt?utm_source=stackyard&utm_medium=referral', + site: 'https://unsplash.com/?utm_source=stackyard&utm_medium=referral', +}; +const PHOTO = { url: 'https://images/a.jpg', credit: CREDIT }; test('a reload inside the cache window keeps the same photo', () => { - const stored = writeWallpaperCache('https://images/a.jpg', BG, T0); - assert.equal(readWallpaperCache(stored, BG, T0), 'https://images/a.jpg'); - assert.equal(readWallpaperCache(stored, BG, T0 + DAY - 1), 'https://images/a.jpg'); + const stored = writeWallpaperCache(PHOTO, BG, T0); + assert.deepEqual(readWallpaperCache(stored, BG, T0), PHOTO); + assert.deepEqual(readWallpaperCache(stored, BG, T0 + DAY - 1), PHOTO); }); test('the photo is replaced once the window has passed', () => { - const stored = writeWallpaperCache('https://images/a.jpg', BG, T0); + const stored = writeWallpaperCache(PHOTO, BG, T0); assert.equal(readWallpaperCache(stored, BG, T0 + DAY), null); }); test('changing the background settings picks a new photo', () => { - const stored = writeWallpaperCache('https://images/a.jpg', BG, T0); + const stored = writeWallpaperCache(PHOTO, BG, T0); assert.equal(readWallpaperCache(stored, { ...BG, collection: '9999' }, T0), null); assert.equal(readWallpaperCache(stored, { type: 'color' }, T0), null); }); /* A clock that moved backwards would otherwise hold one photo indefinitely. */ test('an entry stamped in the future is discarded', () => { - const stored = writeWallpaperCache('https://images/a.jpg', BG, T0); + const stored = writeWallpaperCache(PHOTO, BG, T0); assert.equal(readWallpaperCache(stored, BG, T0 - 1000), null); }); @@ -50,5 +62,36 @@ test('a missing or unusable entry asks for a new photo', () => { context that loads the module. */ test('a page with no usable storage falls back to a fresh photo', () => { assert.equal(loadWallpaper(BG, T0), null); - assert.doesNotThrow(() => saveWallpaper('https://images/a.jpg', BG, T0)); + assert.doesNotThrow(() => saveWallpaper(PHOTO, BG, T0)); +}); + +/* An entry stored before credits existed would show an Unsplash photo with no + credit for up to a day. */ +test('an entry without a credit field asks for a new photo', () => { + const old = JSON.stringify({ url: 'https://images/a.jpg', key: 'unsplash|1234', at: T0 }); + assert.equal(readWallpaperCache(old, BG, T0), null); +}); + +test('a stored credit that is not a pair of Unsplash links is dropped', () => { + const stored = JSON.stringify({ + ...PHOTO, + credit: { ...CREDIT, profile: 'javascript:alert(1)' }, + key: 'unsplash|1234', + at: T0, + }); + assert.deepEqual(readWallpaperCache(stored, BG, T0), { url: PHOTO.url, credit: null }); +}); + +test('only a named credit with two unsplash.com links is valid', () => { + assert.deepEqual(validCredit(CREDIT), CREDIT); + for (const bad of [ + null, + 'x', + { ...CREDIT, name: ' ' }, + { ...CREDIT, profile: 'https://evil.example/@a' }, + { ...CREDIT, site: 'http://unsplash.com/' }, + { ...CREDIT, profile: 'https://unsplash.com.evil.example/' }, + ]) { + assert.equal(validCredit(bad), null, JSON.stringify(bad)); + } });