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' ? `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('') || '