Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions api/src/routes/wallpaper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '' });
Expand All @@ -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;
});
Expand Down
78 changes: 70 additions & 8 deletions api/test/wallpaper-unsplash.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,21 @@ const http = require('node:http');
const proxy = require('../src/proxy');
/** @type {string[]} */
let calls = [];
/** @type {(url: string) => Promise<any>} */
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<any>} */
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);
};
Expand Down Expand Up @@ -52,6 +61,7 @@ after(() => new Promise(r => server.close(r)));

beforeEach(() => {
calls = [];
pings = [];
_resetPhoto();
unsplash();
clock += 1000;
Expand Down Expand Up @@ -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() });
}
});
6 changes: 6 additions & 0 deletions tsconfig.frontend.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
2 changes: 1 addition & 1 deletion ui/admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,6 @@ <h1 class="sec-title" id="ev-title">General</h1>
</div></div>
</div>

<script type="module" src="/js/admin.js?v=6ddbe5c0"></script>
<script type="module" src="/js/admin.js?v=aeca5dee"></script>
</body>
</html>
24 changes: 24 additions & 0 deletions ui/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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); }
Expand Down
1 change: 1 addition & 0 deletions ui/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions ui/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions ui/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions ui/i18n/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@
"searchInput": "جستجوی برنامه‌ها و پوشه‌ها",
"noResults": "نتیجه‌ای یافت نشد",
"pagination": "صفحه‌های داشبورد",
"photoCredit": "منبع عکس",
"pageAnnounce": "صفحه {page} از {total}",
"goToPage": "رفتن به صفحه {page} از {total}",
"appsAndFolders": "برنامه‌ها و پوشه‌ها",
Expand Down
1 change: 1 addition & 0 deletions ui/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions ui/i18n/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@
"searchInput": "搜索应用和文件夹",
"noResults": "没有结果",
"pagination": "仪表板页面",
"photoCredit": "照片来源",
"pageAnnounce": "第 {page} 页,共 {total} 页",
"goToPage": "转到第 {page} 页,共 {total} 页",
"appsAndFolders": "应用与文件夹",
Expand Down
5 changes: 3 additions & 2 deletions ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
<script src="/js/admin-theme.js?v=66565254"></script>
<title>Stackyard</title>
<link rel="stylesheet" href="/css/tokens.css?v=4333b906">
<link rel="stylesheet" href="/css/dashboard.css?v=99f1ff06">
<link rel="stylesheet" href="/css/dashboard.css?v=024156b1">
<link rel="stylesheet" href="/css/config-recovery.css?v=5fa2d249">
</head>
<body>
Expand All @@ -43,6 +43,7 @@
<div class="msp-dots"></div>
</div>
<nav id="dock" aria-label="Dock" data-i18n-al="home.dock"></nav>
<div id="photo-credit" role="group" aria-label="Photo credit" data-i18n-al="home.photoCredit" hidden></div>
<dialog id="spot" aria-label="Search" data-i18n-al="home.searchDialog">
<div id="sres-live" class="visually-hidden" aria-live="polite"></div>
<div id="sres"></div>
Expand All @@ -56,5 +57,5 @@
</div>
</div>
</dialog>
<script type="module" src="/js/dashboard.js?v=4f4a5a40"></script>
<script type="module" src="/js/dashboard.js?v=2a9d3585"></script>
</body></html>
8 changes: 4 additions & 4 deletions ui/js/admin-app-form.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions ui/js/admin-auth.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions ui/js/admin-color-control.js
Original file line number Diff line number Diff line change
@@ -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'];
Expand Down
2 changes: 1 addition & 1 deletion ui/js/admin-drag.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {};
Expand Down
8 changes: 4 additions & 4 deletions ui/js/admin-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
8 changes: 4 additions & 4 deletions ui/js/admin-settings.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion ui/js/admin-shared.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Loading
Loading