From 651d0def2ff7be77e036540d42fc9c78eb3e996f Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:15:19 -0400 Subject: [PATCH] GIFs from Wikimedia Commons (MediaWiki search) with no key; shared places link to Apple Maps Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- chat.js | 4 ++-- docker-compose.yml | 4 ++-- docs/v2-roadmap.md | 4 ++-- gifs.js | 19 ++++++++++++++++--- public/chat-extras.js | 15 ++++----------- public/chat-view.js | 4 ++-- public/sw.js | 2 +- tools/chatextras-test.js | 17 +++++++++++++---- 9 files changed, 43 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index f92f0cc..816f5b6 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ playing an episode or pressing the button still works. Settings -> Health shows - **Podcast discovery**: *Add podcast* searches Apple's free directory and, when `PODCASTINDEX_KEY` and `PODCASTINDEX_SECRET` are set (a free key from podcastindex.org), Podcast Index too, with topic buttons (News, History, Science...) that list trending shows. A feed in both appears once. - **Search what's said** (Podcast screen): finds a word or phrase in every transcript on the server and plays that episode from that moment. Only podcasts on your own list (and audiobooks) are searched; older transcripts are matched to their episode the first time. -- **Chat extras**: a **GIF** button (set `TENOR_API_KEY` or `GIPHY_API_KEY`; the hub does all the talking to the service, asks only for G-rated results, refuses searches and drops results containing words that could bring up adult material, keeps a sent GIF in the chat like a photo, and a sender or admin can delete one that slips through), **Place or location** (your phone's location after it asks, or a search through OpenStreetMap; shared as a map card that opens the phone's maps app), and the Chat list also shows **Signal** conversations, labelled, on screens where Signal is available. +- **Chat extras**: a **GIF** button (with no key it uses Wikimedia Commons through MediaWiki's search API: free animated pictures of animals, nature, space and science; set `TENOR_API_KEY` or `GIPHY_API_KEY` for reaction GIFs instead, or `GIF_PROVIDER=wikimedia|tenor|giphy|off` to choose; the hub does all the talking to the service, asks only for G-rated results, refuses searches and drops results containing words that could bring up adult material, keeps a sent GIF in the chat like a photo, and a sender or admin can delete one that slips through), **Place or location** (your phone's location after it asks, or a place search through OpenStreetMap; shared as a card whose link is an Apple Maps link, `maps.apple.com`, which opens the Maps app on an iPhone or iPad), and the Chat list also shows **Signal** conversations, labelled, on screens where Signal is available. - **Recipes** (needs `MEALIE_TOKEN`; `MEALIE_URL` defaults to `https://mealie.necloud.us`): browse and search your Mealie recipes, read one (ingredients, steps, times), import a recipe from a web link, and add its ingredients to the Mealie shopping list (Mealie adds up repeats). The shopping list is also kept in **Tasks** as a "Shopping" list: ticking, adding or deleting an item in either place is carried over when either screen opens or an item changes. In a recipe, tick ingredients (and tap steps) as you go; this is remembered on the device, with *Uncheck all* to start over. On wide screens the ingredients sit beside the steps. Turn the tab on per person in the admin screen. - **Contacts**: an address book per person (name, company, emails, phones, address, birthday, notes). Mail suggests contacts while you type a recipient, a message you are reading can be saved as a contact (or opens the one you have), birthdays appear in the Calendar, and .vcf files from iCloud, Google or a phone can be imported and exported. diff --git a/chat.js b/chat.js index 44dce21..a7a71dd 100644 --- a/chat.js +++ b/chat.js @@ -25,10 +25,10 @@ const ART_OK = /^\/(plex|api)\/[^\s"'<>\\]*$/, LINK_OK = /^#\/[\w-]+(\/[\w.~%:@+ /** What a client sent as a share, made safe: whitelisted kind, short words, a picture from this hub only and a link that is a hash route (never another site). */ function cleanShare(s) { if (!s || typeof s !== 'object' || !SHARE[s.kind]) throw fail(400, 'That cannot be shared'); - if (s.kind === 'place') { // a place on the map: the coordinates are checked and the link is made here (OpenStreetMap), never taken from the sender + if (s.kind === 'place') { // a place on the map: the coordinates are checked and the link is made here (Apple Maps), never taken from the sender const lat = Math.round(+s.lat * 1e5) / 1e5, lng = Math.round(+s.lng * 1e5) / 1e5, title = str(s.title, 120).trim() || 'Shared location'; if (!(Math.abs(lat) <= 90) || !(Math.abs(lng) <= 180) || s.lat === '' || s.lng === '' || s.lat == null || s.lng == null) throw fail(400, 'That place has no location'); - return { kind: 'place', title, sub: str(s.sub, 160).trim(), art: '', link: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=17/${lat}/${lng}`, lat, lng }; + return { kind: 'place', title, sub: str(s.sub, 160).trim(), art: '', link: `https://maps.apple.com/?ll=${lat},${lng}&q=${encodeURIComponent(title)}`, lat, lng }; } const title = str(s.title, 120).trim(), link = String(s.link || ''), art = String(s.art || ''); if (!title) throw fail(400, 'Nothing to share'); diff --git a/docker-compose.yml b/docker-compose.yml index b3c8ad5..00241ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,8 +32,8 @@ services: PODCASTINDEX_SECRET: ${PODCASTINDEX_SECRET:-} MEALIE_URL: ${MEALIE_URL:-https://mealie.necloud.us} # Recipes tab: your Mealie server, and an API token (Mealie: your profile -> API tokens); the token stays on the server MEALIE_TOKEN: ${MEALIE_TOKEN:-} - TENOR_API_KEY: ${TENOR_API_KEY:-} # GIFs in Family Chat: a Tenor key (Google Cloud, Tenor API) ... - GIPHY_API_KEY: ${GIPHY_API_KEY:-} # ... or a GIPHY key. Either one turns the GIF button on; leave both empty to keep it off. Only G-rated GIFs are ever offered. + TENOR_API_KEY: ${TENOR_API_KEY:-} # GIFs in Family Chat work with no key (Wikimedia Commons). For reaction GIFs, a Tenor key (Google Cloud, Tenor API) ... + GIPHY_API_KEY: ${GIPHY_API_KEY:-} # ... or a GIPHY key; either replaces Commons. GIF_PROVIDER=off turns the button off. Tenor and GIPHY results are G-rated only; Commons has no ratings, so its results are checked by words and categories. ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret PUBLIC_URL: ${PUBLIC_URL:-} # e.g. https://hub.necloud.us (OnlyOffice posts saved edits here) HUB_SETUP_CODE: ${HUB_SETUP_CODE:-} # first passkey; if empty, a code is printed in the log diff --git a/docs/v2-roadmap.md b/docs/v2-roadmap.md index 70e78ce..4bde985 100644 --- a/docs/v2-roadmap.md +++ b/docs/v2-roadmap.md @@ -26,8 +26,8 @@ A messenger inside the hub for the family, so family chats do not need Signal. E - [x] Voice notes (record and play, with a waveform) - [x] Link previews (fetched safely by the server) - [x] Polls -- [x] GIF picker: **Done (gifs.js, chat-extras.js): needs `TENOR_API_KEY` or `GIPHY_API_KEY`; G-rated only, a blocklist on searches and results, previews and the sent GIF go through the hub.** -- [x] Share a place as a link: **Done: your location (the phone asks) or a place search through OpenStreetMap, shown as a map card.** +- [x] GIF picker: **Done (gifs.js, chat-extras.js): works with no key through Wikimedia Commons (MediaWiki search), or with a Tenor or GIPHY key; a blocklist on searches and results (and categories for Commons), previews and the sent GIF go through the hub.** +- [x] Share a place as a link: **Done: your location (the phone asks) or a place search through OpenStreetMap, shown as a card with an Apple Maps (maps.apple.com) link.** **Family extras (M): mostly done** - [x] Turn a message into a task, an event or a shopping item in one tap diff --git a/gifs.js b/gifs.js index 89c04bc..5e8968f 100644 --- a/gifs.js +++ b/gifs.js @@ -1,14 +1,18 @@ -// GIFs for Family Chat, from Tenor or GIPHY (whichever key the admin gives: TENOR_API_KEY or GIPHY_API_KEY). The hub does all the talking to the service, so phones never contact it: +// GIFs for Family Chat, from Tenor or GIPHY (whichever key the admin gives: TENOR_API_KEY or GIPHY_API_KEY), or with no key at all from Wikimedia Commons (MediaWiki's search API): free animated +// pictures of animals, space, science and nature rather than reaction memes. GIF_PROVIDER=tenor|giphy|wikimedia|off picks one; the default is the first of Tenor, GIPHY, Wikimedia that works. The hub does all the talking to the service, so phones never contact it: // searches come back through here, previews are fetched here, and a GIF that is sent is downloaded and kept in the chat like any other picture. // Everything is kept family-friendly, for everybody: only the services' G-rated results are asked for, words that could bring up adult material are refused in a search and in what comes // back (title and tags), and only GIFs this server itself listed can be sent. A filter like this is a strong guard, not a promise: a sender or an admin can delete anything that slips through. const TENOR = () => (process.env.TENOR_URL || 'https://tenor.googleapis.com').replace(/\/+$/, ''), GIPHY = () => (process.env.GIPHY_URL || 'https://api.giphy.com').replace(/\/+$/, ''); -const provider = () => process.env.TENOR_API_KEY ? 'tenor' : process.env.GIPHY_API_KEY ? 'giphy' : ''; +const WIKI = () => (process.env.WIKIMEDIA_URL || 'https://commons.wikimedia.org').replace(/\/+$/, ''); +const provider = () => { const w = String(process.env.GIF_PROVIDER || '').toLowerCase(); if (w === 'off') return ''; if (w === 'wikimedia' || w === 'tenor' && process.env.TENOR_API_KEY || w === 'giphy' && process.env.GIPHY_API_KEY) return w; return process.env.TENOR_API_KEY ? 'tenor' : process.env.GIPHY_API_KEY ? 'giphy' : 'wikimedia'; }; const enabled = () => !!provider(); // whole words (and stems) that are never searched for and never shown const BLOCK = ['sex', 'sexy', 'sexual', 'sexually', 'sexier', 'sexiest', 'porn', 'porno', 'pornography', 'pornographic', 'xxx', 'nsfw', 'nude', 'nudes', 'nudity', 'naked', 'topless', 'bottomless', 'boob', 'boobs', 'boobies', 'breast', 'breasts', 'tit', 'tits', 'titty', 'titties', 'nipple', 'nipples', 'cleavage', 'butt', 'butts', 'booty', 'twerk', 'twerking', 'thong', 'thongs', 'lingerie', 'bikini', 'bikinis', 'underwear', 'panties', 'bra', 'bras', 'strip', 'stripper', 'strippers', 'striptease', 'erotic', 'erotica', 'fetish', 'kink', 'kinky', 'bdsm', 'hentai', 'orgasm', 'orgasms', 'penis', 'vagina', 'dick', 'dicks', 'cock', 'cocks', 'pussy', 'pussies', 'cum', 'cumming', 'horny', 'slut', 'sluts', 'whore', 'hooker', 'hookers', 'masturbate', 'masturbation', 'blowjob', 'handjob', 'anal', 'milf', 'onlyfans', 'playboy', 'adult', 'adults', 'lust', 'seduce', 'seductive', 'foreplay', 'grope', 'groping', 'fuck', 'fucking', 'fucker', 'fucked', 'shit', 'bitch', 'bitches', 'asshole', 'ass', 'drugs', 'cocaine', 'marijuana', 'gore', 'suicide']; const BLOCKED = new Set(BLOCK); +// Commons files carry categories, so those are checked too, against a few more phrases (a category is a phrase, not a word) +const RISKY = /nudity|nude|naked|sexual|sexuality|erotic|pornograph|genital|fetish|bdsm|topless|underwear|swimwear|bikini|lingerie|breast|buttock|nipple|prostitut|obscene|sex education|human sexuality|anatomy of the|violence|corpse|dead bod|execution|torture|massacre|gore|suicide|drug use|cannabis|cocaine|heroin/i; /** Does this text (a search, a title, tags) contain a word that is not allowed? Whole words only, so "class" and "assistant" are fine. */ const unsafe = text => (String(text || '').toLowerCase().match(/[a-z0-9]+/g) || []).some(w => BLOCKED.has(w) || BLOCKED.has(w.replace(/s$/, ''))); @@ -16,7 +20,7 @@ const unsafe = text => (String(text || '').toLowerCase().match(/[a-z0-9]+/g) || const known = new Map(); // url -> expiry const remember = urls => { const t = Date.now() + 3600e3; for (const u of urls) known.set(u, t); if (known.size > 4000) for (const [k, v] of known) if (v < Date.now()) known.delete(k); }; const isKnown = u => (known.get(u) || 0) > Date.now(); -const MEDIA = /^https:\/\/(media[0-9]*\.tenor\.com|media[0-9]*\.giphy\.com|i\.giphy\.com|c\.tenor\.com)\//i; +const MEDIA = /^https:\/\/(media[0-9]*\.tenor\.com|media[0-9]*\.giphy\.com|i\.giphy\.com|c\.tenor\.com|upload\.wikimedia\.org)\//i; const mediaOk = u => { try { const x = new URL(u); return MEDIA.test(u) || String(process.env.GIF_MEDIA_HOSTS || '').split(',').filter(Boolean).includes(x.host); } catch { return false; } }; async function getJson(url) { @@ -39,6 +43,15 @@ async function search(q, pos) { const off = Math.max(0, parseInt(pos, 10) || 0), key = encodeURIComponent(process.env.GIPHY_API_KEY), base = `${GIPHY()}/v1/gifs/${q ? 'search?q=' + encodeURIComponent(q) + '&' : 'trending?'}api_key=${key}&limit=${n}&offset=${off}&rating=g&bundle=messaging_non_clips`; const j = await getJson(base); next = (j.data || []).length >= n ? String(off + n) : ''; items = (j.data || []).filter(x => !x.rating || x.rating === 'g').map(x => { const t = (x.images || {}).fixed_width_small || (x.images || {}).fixed_width, g = (x.images || {}).fixed_width || (x.images || {}).original; return t && g && t.url && g.url ? { title: x.title || '', tags: '', w: +t.width || 0, h: +t.height || 0, preview: t.url, url: g.url } : null; }); + } else if (provider() === 'wikimedia') { // Wikimedia Commons: animated GIFs only, chosen by the search itself; no rating exists, so the word and category checks do the work + const off = Math.max(0, parseInt(pos, 10) || 0), term = (q || 'animal') + ' filemime:image/gif'; + const url = `${WIKI()}/w/api.php?action=query&format=json&generator=search&gsrnamespace=6&gsrlimit=${n}&gsroffset=${off}&gsrsearch=${encodeURIComponent(term)}&prop=imageinfo&iiprop=url|size|mime|extmetadata&iiurlwidth=320&iiextmetadatafilter=ImageDescription|Categories|ObjectName`; + const j = await getJson(url); next = j.continue && j.continue.gsroffset != null ? String(j.continue.gsroffset) : ''; + items = Object.values((j.query || {}).pages || {}).sort((a, b) => (a.index || 0) - (b.index || 0)).map(pg => { + const ii = (pg.imageinfo || [])[0]; if (!ii || ii.mime !== 'image/gif' || !ii.thumburl) return null; const em = ii.extmetadata || {}, plain = v => String(v && v.value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + const title = String(pg.title || '').replace(/^File:/, '').replace(/\.gif$/i, '').replace(/[_]+/g, ' '), cats = plain(em.Categories).replace(/\|/g, ' ยท '), desc = plain(em.ImageDescription).slice(0, 300); + if (RISKY.test(cats) || RISKY.test(title) || RISKY.test(desc)) return null; return { title: plain(em.ObjectName) || title, tags: cats + ' ' + desc, w: ii.thumbwidth || 0, h: ii.thumbheight || 0, preview: ii.thumburl, url: ii.thumburl }; + }); } else throw Object.assign(new Error('GIFs are not set up on this server'), { code: 503 }); items = items.filter(x => x && mediaOk(x.preview) && mediaOk(x.url) && !unsafe(x.title) && !unsafe(x.tags)).map(({ tags, ...x }) => x); remember(items.flatMap(x => [x.preview, x.url])); return { items, next }; diff --git a/public/chat-extras.js b/public/chat-extras.js index ee46819..3ed77bc 100644 --- a/public/chat-extras.js +++ b/public/chat-extras.js @@ -1,10 +1,10 @@ 'use strict'; // ---- Family Chat extras: a GIF picker (kept family-friendly by the hub; phones never contact the GIF service) and sharing a place (your location, or a place you search for). // The Chat list also shows Signal conversations, labelled, when Signal is available on this screen. -async function chGifOn() { if (ch.gifOn === undefined) { try { ch.gifOn = !!(await chApi('/gif/status')).enabled; ch.gifBy = (await chApi('/gif/status')).provider; } catch { ch.gifOn = false; } } return ch.gifOn; } +async function chGifOn() { if (ch.gifOn === undefined) { try { const st = await chApi('/gif/status'); ch.gifOn = !!st.enabled; ch.gifBy = st.provider; } catch { ch.gifOn = false; } } return ch.gifOn; } function chGifPicker() { - const to = ch.cur, by = ch.gifBy === 'giphy' ? 'GIPHY' : 'Tenor'; - const el = openSheet('GIF', 'Family-friendly only', `

Powered by ${by}

`); + const to = ch.cur, by = ch.gifBy === 'giphy' ? 'GIPHY' : ch.gifBy === 'wikimedia' ? 'Wikimedia Commons (free pictures; mostly animals, nature, space and science)' : 'Tenor'; + const el = openSheet('GIF', 'Family-friendly only', `
${ch.gifBy === 'wikimedia' ? `
${['animals', 'space', 'weather', 'ocean', 'science', 'dance', 'cartoon', 'sports'].map(w => `${w}`).join('')}
` : ''}

Powered by ${by}

`); el.querySelector('.sheet').classList.add('wide'); let next = '', q = '', busy = false; const load = async more => { if (busy) return; busy = true; el.querySelector('#gpe').textContent = ''; @@ -14,6 +14,7 @@ function chGifPicker() { }; el.querySelector('#gpf').onsubmit = e => { e.preventDefault(); q = el.querySelector('#gpq').value.trim(); next = ''; load(false); }; el.querySelector('#gpm').onclick = () => load(true); + el.querySelectorAll('#gpc [data-w]').forEach(a => a.onclick = e => { e.preventDefault(); el.querySelector('#gpq').value = a.dataset.w; el.querySelector('#gpf').requestSubmit(); }); el.querySelector('#gpg').onclick = async e => { const b = e.target.closest('.gifc'); if (!b || b.disabled) return; b.disabled = true; b.style.opacity = '.5'; try { await chPost('/gif/send', { conv: to, url: b.dataset.u, title: b.dataset.t }); closeSheet(); } catch (er) { b.disabled = false; b.style.opacity = ''; el.querySelector('#gpe').textContent = er.message; } }; load(false); @@ -32,14 +33,6 @@ function chPlacePicker() { try { const r = await chApi('/places?q=' + encodeURIComponent(q)); box.innerHTML = r.items.map((x, i) => `${esc(x.title)}${esc(x.sub)}`).join('') || '
No places found.
'; box.querySelectorAll('[data-i]').forEach(a => a.onclick = ev => { ev.preventDefault(); const x = r.items[+a.dataset.i]; send(x.title, x.sub, x.lat, x.lng); }); } catch (er) { box.innerHTML = ''; el.querySelector('#ple').textContent = er.message; } }; } -/** Where a shared place opens: the phone's own maps app when there is one, otherwise OpenStreetMap. */ -function chPlaceHref(s) { - const lat = +s.lat, lng = +s.lng; if (!isFinite(lat) || !isFinite(lng)) return s.link; - const ua = navigator.userAgent || '', t = encodeURIComponent(s.title || 'Place'); - if (/iPhone|iPad|iPod|Macintosh/.test(ua) && 'ontouchend' in document) return `https://maps.apple.com/?ll=${lat},${lng}&q=${t}`; - if (/Android/.test(ua)) return `geo:${lat},${lng}?q=${lat},${lng}(${t})`; - return s.link; -} // ---- one list for Chat and Signal async function chLoadSignal() { ch.sig = []; if (typeof allowed !== 'function' || !allowed('signal') || typeof sgApi !== 'function') return; diff --git a/public/chat-view.js b/public/chat-view.js index b6985d4..90ea23d 100644 --- a/public/chat-view.js +++ b/public/chat-view.js @@ -82,12 +82,12 @@ function chPoll(m) { // ---- share cards: a song, film, book... sent from its own screen. Tapping opens it, unless that part of the hub is not turned on for this person. const CH_SHARE = { song: ['๐ŸŽต', 'Song'], album: ['๐Ÿ’ฟ', 'Album'], artist: ['๐ŸŽค', 'Artist'], playlist: ['๐Ÿ“ƒ', 'Playlist'], movie: ['๐ŸŽฌ', 'Movie'], show: ['๐Ÿ“บ', 'TV show'], episode: ['๐Ÿ“บ', 'Episode'], book: ['๐Ÿ“–', 'Book'], audiobook: ['๐ŸŽง', 'Audiobook'], podcast: ['๐ŸŽ™๏ธ', 'Podcast'], podcastep: ['๐ŸŽ™๏ธ', 'Podcast episode'], radio: ['๐Ÿ“ป', 'Radio'], place: ['๐Ÿ“', 'Place'] }; /** The tab a deep link lives in decides whether this person can open it (a child account only gets the tabs a parent allowed). */ -const chShareOk = link => { if (/^https:\/\/www\.openstreetmap\.org\/\?mlat=/.test(String(link || ''))) return true; const tab = String(link || '').split('/')[1]; return !!tab && !!routesOf()[tab] && allowed(tab); }; +const chShareOk = link => { if (/^https:\/\/maps\.apple\.com\/\?ll=/.test(String(link || ''))) return true; const tab = String(link || '').split('/')[1]; return !!tab && !!routesOf()[tab] && allowed(tab); }; function chShareCard(m) { const s = m.share, k = CH_SHARE[s.kind] || ['๐Ÿ”—', 'Shared'], ok = chShareOk(s.link), art = /^\/(plex|api)\//.test(s.art || '') ? s.art : ''; const body = `${art ? `` : `${k[0]}`} ${k[0]} ${esc(k[1])}${esc(s.title)}${s.sub ? `${esc(s.sub)}` : ''}${ok ? '' : 'Not available for you'}`; - if (s.kind === 'place') return `${body}โ€บ`; + if (s.kind === 'place') return `${body}โ€บ`; return ok ? `${body}โ€บ` : `
${body}
`; } function chBubble(m, c, prev) { diff --git a/public/sw.js b/public/sw.js index 719703c..7402b07 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v129'; +const CACHE = 'hub-v130'; const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/tools/chatextras-test.js b/tools/chatextras-test.js index 494938c..97500b9 100644 --- a/tools/chatextras-test.js +++ b/tools/chatextras-test.js @@ -1,7 +1,7 @@ // Family Chat extras: GIFs (kept family-friendly, fetched through the hub, kept in the chat) and sharing a place (a search, and coordinates that are checked). const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'); process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-chatx-')); -const GIF = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64'); let asked = [], tenorQ = [], giphyQ = []; +const GIF = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64'); let commonsPages = null, asked = [], tenorQ = [], giphyQ = [], wikiQ = []; const media = http.createServer((req, res) => { asked.push(req.url); if (req.url.startsWith('/big')) { res.writeHead(200, { 'content-type': 'image/gif' }); return res.end(Buffer.alloc(9 * 1024 * 1024)); } if (req.url.startsWith('/html')) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('x'); } res.writeHead(200, { 'content-type': 'image/gif' }); res.end(GIF); }).listen(0); const svc = http.createServer((req, res) => { const u = new URL(req.url, 'http://x'), M = 'http://127.0.0.1:' + media.address().port; res.setHeader('content-type', 'application/json'); @@ -11,9 +11,12 @@ const svc = http.createServer((req, res) => { { content_description: 'party', tags: ['topless', 'fun'], media_formats: { tinygif: { url: M + '/t3.gif', dims: [100, 80] }, gif: { url: M + '/g3.gif', dims: [300, 240] } } }, { content_description: 'elsewhere', tags: [], media_formats: { tinygif: { url: 'https://evil.example/t4.gif', dims: [1, 1] }, gif: { url: 'https://evil.example/g4.gif', dims: [1, 1] } } }] })); } if (u.pathname.startsWith('/v1/gifs/')) { giphyQ.push(u.pathname + u.search); return res.end(JSON.stringify({ data: [{ title: 'cat', rating: 'g', images: { fixed_width_small: { url: M + '/s1.gif', width: '100', height: '80' }, fixed_width: { url: M + '/f1.gif' } } }, { title: 'cat 2', rating: 'pg-13', images: { fixed_width_small: { url: M + '/s2.gif', width: '100', height: '80' }, fixed_width: { url: M + '/f2.gif' } } }] })); } + if (u.pathname === '/w/api.php') { wikiQ.push(u.search); const M2 = M, mk = (i, title, mime, desc, cats, thumb) => ({ pageid: i, index: i, title: 'File:' + title, imageinfo: [{ mime, thumburl: thumb === null ? '' : (thumb || M2 + '/commons-' + i + '.gif'), thumbwidth: 320, thumbheight: 240, extmetadata: { ImageDescription: { value: desc }, Categories: { value: cats } } }] }); + const pages = [mk(1, 'Cat walking.gif', 'image/gif', 'A cat walking', 'Cats|Animation'), mk(2, 'Nude study.gif', 'image/gif', 'Study', 'Nudity in art'), mk(3, 'Cat.png', 'image/png', 'still', 'Cats'), mk(4, 'Dancing robot.gif', 'image/gif', 'A robot', 'Robots'), mk(5, 'Clinic.gif', 'image/gif', 'Medical animation of the female genitalia', 'Medicine'), mk(6, 'No thumb.gif', 'image/gif', 'x', 'Cats', null)]; + commonsPages = pages; const cont = pages.length ? { continue: { gsroffset: 24 } } : {}; return res.end(JSON.stringify({ ...cont, query: { pages: Object.fromEntries(pages.map(p => [p.pageid, p])) } })); } if (u.pathname === '/search') { return res.end(JSON.stringify([{ name: 'Central Park', display_name: 'Central Park, Manhattan, New York, United States', lat: '40.78', lon: '-73.96' }, { name: 'Bad', display_name: 'Bad', lat: 'x', lon: 'y' }])); } res.statusCode = 404; res.end('{}'); }).listen(0, async () => { - process.env.GIF_MEDIA_HOSTS = '127.0.0.1:' + media.address().port; process.env.TENOR_URL = process.env.GIPHY_URL = process.env.NOMINATIM_URL = 'http://127.0.0.1:' + svc.address().port; + process.env.GIF_MEDIA_HOSTS = '127.0.0.1:' + media.address().port; process.env.WIKIMEDIA_URL = process.env.TENOR_URL = process.env.GIPHY_URL = process.env.NOMINATIM_URL = 'http://127.0.0.1:' + svc.address().port; const users = require('../users'), chat = require('../chat'), gifs = require('../gifs'); const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['chat'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['chat'], handle: 'Yw' }) }; const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => chat.handle(req, res))).listen(0, async () => { @@ -22,7 +25,7 @@ const svc = http.createServer((req, res) => { // the filter's words for (const bad of ['sexy dance', 'Boobs', 'naked man', 'p0rn'.replace('0', 'o'), 'bikini beach', 'F-U-C-K'.replace(/-/g, '')]) assert.equal(gifs.unsafe(bad), true, bad + ' is refused'); for (const ok of ['happy dance', 'class', 'assistant', 'Scunthorpe', 'cat', 'thanks', 'high five', 'book club', 'pass the butter']) assert.equal(gifs.unsafe(ok), false, ok + ' is fine'); // not set up - let r = await call('/gif/status'); assert.deepEqual(await j(r), { enabled: false, provider: '' }); assert.equal((await call('/gif?q=cat')).status, 503); + process.env.GIF_PROVIDER = 'off'; let r = await call('/gif/status'); assert.deepEqual(await j(r), { enabled: false, provider: '' }); assert.equal((await call('/gif?q=cat')).status, 503); delete process.env.GIF_PROVIDER; // Tenor: always the strictest setting, and what comes back is filtered by words and by where it is hosted process.env.TENOR_API_KEY = 'k1'; assert.deepEqual(await j(await call('/gif/status')), { enabled: true, provider: 'tenor' }); r = await j(await call('/gif?q=dance')); assert.match(tenorQ[0], /contentfilter=high/); assert.match(tenorQ[0], /q=dance/); assert.deepEqual(r.items.map(x => x.title), ['happy dance'], 'a title or a tag with a bad word, and a picture from another site, are dropped'); assert.equal(r.next, 'n2'); @@ -35,11 +38,17 @@ const svc = http.createServer((req, res) => { assert.equal((await post('/gif/send', { conv: dm.id, url: 'http://127.0.0.1:' + media.address().port + '/never-listed.gif' })).status, 400, 'only listed GIFs can be sent'); // size and type limits { const M = 'http://127.0.0.1:' + media.address().port; gifs.remember([M + '/big.gif', M + '/html.gif']); assert.equal((await post('/gif/send', { conv: dm.id, url: M + '/big.gif' })).status, 413, 'a huge GIF is refused'); assert.equal((await post('/gif/send', { conv: dm.id, url: M + '/html.gif' })).status, 502, 'something that is not a picture is refused'); } + // Wikimedia Commons (MediaWiki's search): no key needed, the default when there is no Tenor or GIPHY key; animated GIFs only, and files with risky titles, descriptions or categories are dropped + delete process.env.TENOR_API_KEY; delete process.env.GIPHY_API_KEY; assert.deepEqual(await j(await call('/gif/status')), { enabled: true, provider: 'wikimedia' }, 'with no key, Commons is used'); + r = await j(await call('/gif?q=cats')); assert.match(wikiQ[0], /filemime%3Aimage%2Fgif/); assert.match(wikiQ[0], /gsrsearch=cats/); assert.deepEqual(r.items.map(x => x.title), ['Cat walking', 'Dancing robot'], 'a risky category, a risky description, a still picture and one with no thumbnail are dropped, in search order'); assert.equal(r.next, '24'); assert.ok(!r.items.some(x => /Bikini|Nude/i.test(x.title))); + await call('/gif'); assert.match(wikiQ[1], /gsrsearch=animal/, 'with no words, something to look at'); r = await j(await call('/gif?q=cats&pos=24')); assert.match(wikiQ[2], /gsroffset=24/); assert.equal((await call('/gif?q=naked')).status, 400, 'the same word list applies'); + { const M = 'http://127.0.0.1:' + media.address().port; assert.equal((await post('/gif/send', { conv: (await j(await post('/new', { type: 'direct', with: P.b.id }))).conv.id, url: M + '/commons-1.gif', title: 'Cat walking' })).status, 200, 'a Commons GIF is kept in the chat too'); } + process.env.TENOR_API_KEY = 'k1'; process.env.GIF_PROVIDER = 'wikimedia'; assert.equal((await j(await call('/gif/status'))).provider, 'wikimedia', 'GIF_PROVIDER picks one even when keys exist'); delete process.env.GIF_PROVIDER; delete process.env.TENOR_API_KEY; // Giphy: G only, and nothing above it slips through delete process.env.TENOR_API_KEY; process.env.GIPHY_API_KEY = 'k2'; r = await j(await call('/gif?q=cat')); assert.match(giphyQ[0], /rating=g/); assert.match(giphyQ[0], /api_key=k2/); assert.deepEqual(r.items.map(x => x.title), ['cat'], 'a result rated above G is dropped even if the service sent it'); // a place: search through the hub, and a share with checked coordinates assert.equal((await call('/places?q=ab')).status, 400); r = await j(await call('/places?q=central park')); assert.deepEqual(r.items, [{ title: 'Central Park', sub: 'Manhattan, New York, United States', lat: 40.78, lng: -73.96 }], 'bad rows are dropped'); - r = await post('/send', { conv: dm.id, share: { kind: 'place', title: 'Central Park', sub: 'New York', lat: 40.78, lng: -73.96, link: 'https://evil.example/' } }); assert.equal(r.status, 200); const sh = (await j(r)).msg.share; assert.equal(sh.link, 'https://www.openstreetmap.org/?mlat=40.78&mlon=-73.96#map=17/40.78/-73.96', 'the link is made by the hub, not taken from the sender'); assert.equal(sh.kind, 'place'); + r = await post('/send', { conv: dm.id, share: { kind: 'place', title: 'Central Park', sub: 'New York', lat: 40.78, lng: -73.96, link: 'https://evil.example/' } }); assert.equal(r.status, 200); const sh = (await j(r)).msg.share; assert.equal(sh.link, 'https://maps.apple.com/?ll=40.78,-73.96&q=Central%20Park', 'the Apple Maps link is made by the hub, not taken from the sender'); assert.equal(sh.kind, 'place'); for (const bad of [{ lat: 91, lng: 0 }, { lat: 0, lng: 181 }, { lat: 'x', lng: 1 }, { lat: '', lng: '' }, {}]) assert.equal((await post('/send', { conv: dm.id, share: { kind: 'place', title: 'x', ...bad } })).status, 400, JSON.stringify(bad) + ' is not a place'); console.log('chat extras ok'); } catch (e) { console.error(e); process.exitCode = 1; }