GIFs from Wikimedia Commons (MediaWiki search) with no key; shared places link to Apple Maps
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
584a95753d
commit
651d0def2f
9 files changed
+43
-28
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+4
-11
@@ -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', `<form id="gpf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="search" id="gpq" placeholder="Search GIFs" autocomplete="off" maxlength="60" style="flex:1;min-width:0"><button class="btn ghost">Search</button></form><div class="gifgrid" id="gpg" role="list"></div><p class="err" id="gpe"></p><div class="bar" style="justify-content:center"><button class="btn ghost" id="gpm" hidden>More</button></div><p class="sub" style="text-align:center;margin:4px 0 0;font-size:12px">Powered by ${by}</p>`);
|
||||
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', `<form id="gpf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="search" id="gpq" placeholder="Search GIFs" autocomplete="off" maxlength="60" style="flex:1;min-width:0"><button class="btn ghost">Search</button></form>${ch.gifBy === 'wikimedia' ? `<div class="seg" id="gpc" style="flex-wrap:wrap">${['animals', 'space', 'weather', 'ocean', 'science', 'dance', 'cartoon', 'sports'].map(w => `<a href="#" data-w="${w}">${w}</a>`).join('')}</div>` : ''}<div class="gifgrid" id="gpg" role="list"></div><p class="err" id="gpe"></p><div class="bar" style="justify-content:center"><button class="btn ghost" id="gpm" hidden>More</button></div><p class="sub" style="text-align:center;margin:4px 0 0;font-size:12px">Powered by ${by}</p>`);
|
||||
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) => `<a href="#" class="row" data-i="${i}"><span class="grow" style="white-space:normal"><b>${esc(x.title)}</b><small>${esc(x.sub)}</small></span></a>`).join('') || '<div class="row"><span class="grow"><small>No places found.</small></span></div>';
|
||||
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;
|
||||
|
||||
+2
-2
@@ -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 ? `<img class="sgshare-art" alt="" loading="lazy" src="${esc(art)}" onerror="this.replaceWith(Object.assign(document.createElement('i'),{className:'sgshare-art',textContent:'${k[0]}'}))">` : `<i class="sgshare-art">${k[0]}</i>`}
|
||||
<span class="sgshare-t"><small>${k[0]} ${esc(k[1])}</small><b>${esc(s.title)}</b>${s.sub ? `<em>${esc(s.sub)}</em>` : ''}${ok ? '' : '<small class="sgshare-off">Not available for you</small>'}</span>`;
|
||||
if (s.kind === 'place') return `<a class="sgshare" href="${esc(chPlaceHref(s))}" target="_blank" rel="noopener noreferrer">${body}<span class="sgshare-go">›</span></a>`;
|
||||
if (s.kind === 'place') return `<a class="sgshare" href="${esc(s.link)}" target="_blank" rel="noopener noreferrer">${body}<span class="sgshare-go">›</span></a>`;
|
||||
return ok ? `<a class="sgshare" href="${esc(s.link)}">${body}<span class="sgshare-go">›</span></a>` : `<div class="sgshare off" role="note">${body}</div>`;
|
||||
}
|
||||
function chBubble(m, c, prev) {
|
||||
|
||||
+1
-1
@@ -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'];
|
||||
|
||||
@@ -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('<b>x</b>'); } 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 <b>cat</b> 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; }
|
||||
|
||||
Reference in new issue
Block a user