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
52 changes: 45 additions & 7 deletions api/src/routes/wallpaper.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,45 @@ function mayWrite(req, res) {
return true;
}

/* 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} */
let _photo = null;
/** @type {{ key: string, promise: Promise<{ url: string|null, error?: string }> } | null} */
let _pending = null;

/** @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 });
if (_pending?.key === key) return _pending.promise;
const promise = (async () => {
const p = new URLSearchParams({ orientation: 'landscape', content_filter: 'high', client_id: bg.apiKey || '' });
if (bg.collection) p.set('collections', bg.collection);
const r = await fetchUnchecked(`https://api.unsplash.com/photos/random?${p}`);
const raw = r.data?.urls?.raw;
if (!raw) {
log.warn('Unsplash returned no image', { status: r.status });
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 };
})().finally(() => {
if (_pending?.promise === promise) _pending = null;
});
_pending = { key, promise };
return promise;
}

on('GET', '/api/wallpaper', async (_, res) => {
const cfg = loadConfig(),
bg = cfg.settings?.background || {};
if (bg.type !== 'unsplash') return json(res, 200, { url: null });
try {
const p = new URLSearchParams({ orientation: 'landscape', content_filter: 'high', client_id: bg.apiKey || '' });
if (bg.collection) p.set('collections', bg.collection);
const r = await fetchUnchecked(`https://api.unsplash.com/photos/random?${p}`);
const raw = r.data?.urls?.raw;
if (!raw) return json(res, 200, { url: null, error: r.data?.errors?.[0] || 'No image returned' });
json(res, 200, { url: `${raw}&w=2800&h=1800&q=85&fm=jpg&fit=crop&crop=entropy` });
json(res, 200, await randomPhoto(bg));
} catch (e) {
json(res, 200, Object.assign({ url: null }, errorBody(e)));
}
Expand Down Expand Up @@ -199,4 +227,14 @@ on('POST', '/api/wallpaper/fetch', async (req, res) => {
}
});

module.exports = { storeWallpaper, pruneWallpapers, wallpapersToDrop, WALLPAPER_URL_BASE, PENDING_MS };
module.exports = {
storeWallpaper,
pruneWallpapers,
wallpapersToDrop,
WALLPAPER_URL_BASE,
PENDING_MS,
_resetPhoto: () => {
_photo = null;
_pending = null;
},
};
98 changes: 98 additions & 0 deletions api/test/wallpaper-unsplash.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
const path = require('node:path');
const { tmpDir } = require('../test-support/tmp');
process.env.CONFIG_PATH = path.join(tmpDir('wallpaper-unsplash'), 'apps.json');

const { test, before, after, beforeEach, mock } = require('node:test');
const assert = require('node:assert/strict');
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` } },
});
proxy.fetchUnchecked = url => {
calls.push(url);
return reply(url);
};

require('../src/routes');
const { dispatch } = require('../src/router');
const { saveConfig } = require('../src/config');
const { _resetPhoto } = require('../src/routes/wallpaper');

let server, port;
let clock = Date.now();

function getWallpaper() {
return new Promise((resolve, reject) => {
http
.get({ host: '127.0.0.1', port, path: '/api/wallpaper', headers: { Host: '127.0.0.1' } }, res => {
let b = '';
res.on('data', c => (b += c));
res.on('end', () => resolve(JSON.parse(b)));
})
.on('error', reject);
});
}

const unsplash = (extra = {}) =>
saveConfig({ items: [], settings: { background: { type: 'unsplash', apiKey: 'key-1', ...extra } } });

before(async () => {
mock.method(Date, 'now', () => clock);
server = http.createServer(dispatch);
await new Promise(r => server.listen(0, '127.0.0.1', r));
port = server.address().port;
});
after(() => new Promise(r => server.close(r)));

beforeEach(() => {
calls = [];
_resetPhoto();
unsplash();
clock += 1000;
});

test('repeated requests share one Unsplash call for a day', async () => {
const first = await getWallpaper();
for (let i = 0; i < 5; i++) assert.deepEqual(await getWallpaper(), first);
assert.equal(calls.length, 1);
clock += 24 * 60 * 60 * 1000;
assert.notDeepEqual(await getWallpaper(), first);
assert.equal(calls.length, 2);
});

test('requests that arrive together share one call', async () => {
const results = await Promise.all(Array.from({ length: 6 }, getWallpaper));
assert.equal(calls.length, 1);
for (const r of results) assert.deepEqual(r, results[0]);
});

test('a new key or collection asks again', async () => {
await getWallpaper();
unsplash({ apiKey: 'key-2' });
await getWallpaper();
unsplash({ apiKey: 'key-2', collection: 'abc' });
await getWallpaper();
assert.equal(calls.length, 3);
assert.ok(calls[2].includes('collections=abc'));
});

test("Unsplash's own error text is not passed on, and a failure is not kept", async () => {
reply = async () => ({ status: 401, data: { errors: ['OAuth error: The access token is invalid'] } });
try {
const r = await getWallpaper();
assert.deepEqual(r, { url: null, error: 'No image returned' });
await getWallpaper();
assert.equal(calls.length, 2);
} finally {
reply = async () => ({
status: 200,
data: { urls: { raw: `https://images.unsplash.com/photo-${calls.length}?ixid=x` } },
});
}
});
Loading