Last active
January 5, 2025 11:05
-
-
Save sebilasse/3c1566846146d4ed7be007076e572165 to your computer and use it in GitHub Desktop.
Normalize and inflate ActivityPub
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { IS_BROWSER } from "$fresh/runtime.ts"; | |
import { | |
APall, RedaktorActor, AsActivity, AsObject, | |
AsObjectNormalized, AsLinkObject, AsCollection | |
} from './interfaces.d.ts'; | |
import { | |
AsActors, AsActivities, AsObjects, AsLinks | |
} from './ActivityPubInfo.ts'; | |
import { Object as asObject } from "jsr:@fedify/fedify"; | |
import DOMPurify from '$dompurify'; | |
import { optimize } from '$svgo'; | |
import dateTimeR from '@/API/base/String/regex/regexXSDdateTime.ts'; | |
import durationR from '@/API/base/String/regex/regexXSDduration.ts'; | |
export const omitSymbol = Symbol.for('rOmitProperties'); | |
export const isAP = (o:any, type?:string) => { | |
if (typeof o === 'object' && !o?.type) o.type = ['Create']; | |
const hasType = (typeof o === 'object' && o.type && (typeof o.type === 'string' || Array.isArray(o.type))); | |
if (!type) { | |
return hasType | |
} else { | |
return (Array.isArray(o?.type) && !!o.type.filter((t: string) => t === type)?.length || | |
o?.type === type) | |
} | |
} | |
export const isIn = (o: any, apTypes: any) => { | |
// console.log(o); | |
return ((Array.isArray(o.type) && !!o.type.filter((t: string) => apTypes.hasOwnProperty(t)).length) || | |
apTypes.hasOwnProperty(o.type)); | |
} | |
export const isActivity = (o: any): o is AsActivity => !!o && isIn(o, AsActivities); | |
export const isObject = (o: any): o is AsObject => !!o && isIn(o, AsObjects); | |
export const isActor = (o: any): o is RedaktorActor => !!o && isIn(o, AsActors); | |
export const isLink = (o: any): o is AsLinkObject => typeof o === 'string' || (!!o && isIn(o, AsLinks)); | |
export const isLinkOrImage = (o: any) => !!o && (isLink(o) || (isAP(o, 'Image') && o.url)); | |
export const isCollection = (o: any) => !!o && (isAP(o, 'Collection') || isAP(o, 'OrderedCollection')); | |
export const isCollectionPage = (o: any) => !!o && (isAP(o, 'CollectionPage') || isAP(o, 'OrderedCollectionPage')); | |
export const isCaption = (s:any, o:any) => (!!s && (typeof s === 'string'||Array.isArray(s))) || typeof o === 'object'; | |
export const isDatetime = (s: any) => (!!s && typeof s === 'string') && dateTimeR.test(s); | |
export const isDuration = (s: any) => (!!s && typeof s === 'string') && durationR.test(s); | |
export const isPosInteger = (n: any) => (typeof n === 'number' && Number.isInteger(n) && n >= 0); | |
export function getActorName({ petName: pet, preferredUsername: p, name: n, id }: RedaktorActor): string { | |
if (!!pet) { | |
if (Array.isArray(pet) && !!pet.length && typeof pet[0] === 'string') { return pet[0] } | |
if (typeof pet === 'string') { return pet } | |
} | |
if (!!p) { | |
if (Array.isArray(p) && !!p.length && typeof p[0] === 'string') { return p[0] } | |
if (typeof p === 'string') { return p } | |
} | |
if (!!n) { | |
if (Array.isArray(n) && !!n.length && typeof n[0] === 'string') { return n[0] } | |
if (typeof n === 'string') { return n } | |
} | |
if (!!id) { | |
if (Array.isArray(id) && !!id.length && typeof id[0] === 'string') { return id[0] } | |
if (typeof id === 'string') { return id } | |
} | |
return '' | |
} | |
function toArray(v: any) { | |
return Array.isArray(v) ? JSON.parse(JSON.stringify(v.filter((o) => typeof o !== 'undefined'))) : [v]; | |
} | |
export const mediaTypes = ['text/plain', 'text/html', 'text/markdown']; // TODO | |
interface LanguageDetect {[k: string]: any; detect: any;} | |
interface NormalizeOptions { | |
includeBcc?: boolean; | |
inlineSVG?: boolean; | |
allowMediaType?: string[]; // TODO | |
} | |
const defaultNormalizeOptions = { includeBcc: false, inlineSVG: false, allowMediaType: mediaTypes } | |
function _normalize(as: APall, language?: LanguageDetect, _options: NormalizeOptions = defaultNormalizeOptions): AsObjectNormalized { | |
/* TODO | |
default '@context' | |
'https://www.w3.org/ns/activitystreams' | |
hreflang Value must be a [BCP47] Language-Tag. | |
*/ | |
if (!as || Array.isArray(as) || typeof as !== 'object') { return {type: []} } | |
const options = {...defaultNormalizeOptions, ..._options}; | |
const { omitProperties: _omits, ...ap } = as; | |
const omits = new Set(Array.isArray(_omits) ? _omits.filter((s) => typeof s === 'string') : []); | |
const multi = { | |
type:1,name:1,summary:1,source:1,content:1,url:1,context:1,relationship:1,icon:1, | |
image:1,attributedTo:1,generator:1,location:1,inReplyTo:1,audience:1,bcc:1,bto:1,cc:1,to:1, | |
attachment:1,tag:1,preview:1,actor:1,object:1,instrument:1,origin:1,target:1,result:1, | |
oneOf:1,anyOf:1,closed:1,items:1,partOf:1,rel:1,streams:1,endpoints:1 | |
}; | |
for (const k in multi) { | |
if (!Array.isArray(ap[k]) && (typeof ap[k] === 'string' || typeof ap[k] === 'object')) { | |
ap[k] = toArray(ap[k]); | |
} | |
} | |
// location attachment | |
const { | |
'@language': atLanguage, | |
id, | |
type = ['Create'], | |
name = '', | |
summary, | |
source, | |
content, | |
nameMap, | |
summaryMap, | |
contentMap, | |
sourceMap, | |
url, | |
context, relationship, formerType, describes, deleted, subject, | |
// generic | |
icon, image, mediaType, | |
published, updated, attributedTo, generator, location, | |
inReplyTo, audience, bcc, bto, cc, to, | |
attachment, tag, preview, replies, | |
// time based | |
duration, startTime, endTime, height, width, | |
// Place | |
accuracy, altitude, latitude, longitude, radius, units, | |
// Activity | |
actor, object, instrument, origin, target, result, | |
// Question | |
oneOf, anyOf, closed, | |
// Collection + OrderedCollection + CollectionPage + OrderedCollectionPage | |
current, first, last, items, totalItems, startIndex, | |
// CollectionPage + OrderedCollectionPage | |
next, prev, partOf, | |
// Link + Mention | |
href, hreflang, rel, | |
// Actor | |
inbox, outbox, following, followers, liked, streams, preferredUsername, endpoints, | |
// ... other properties | |
...notAP | |
} = ap; | |
const toLangMap = (l: any, lMap: any, key: string, assumeKey: string = 'und') => { | |
if (!!lMap) { | |
for (const k in lMap) { lMap[k] = toArray(lMap[k]); } | |
if (l) o[key] = toArray(l); //langMap(lMap); | |
o[`${key}Map`] = lMap; | |
} else { | |
o[key] = toArray(l); | |
o[`${key}Map`] = { [assumeKey]: toArray(o[key]) } | |
} | |
} | |
const idOrO = (prop: string, v: any, o: AsObject, domainFn = (o: AsObject) => (isCollectionPage(o) || isLink(o))) => { | |
if (domainFn(v)) { | |
(o as AsObject)[prop] = v; | |
} else if (typeof v === 'string') { | |
(o as AsObject)[prop] = { | |
type: ["Link"], | |
href: v | |
} | |
} | |
return o; | |
} | |
type A = any[]; | |
const hasItem = (x: any): x is A => Array.isArray(x) && !!x.length; | |
// make omit Set, check has for all following // include only JSON datatypes | |
// Schema and prefix url … | |
/* TODO | |
id | |
HOW MANY multiple max. ? | |
WHAT IF e.g. [' ', ' ' (repeat 20 times), 'A real text'] ? | |
*/ | |
/* | |
inbox?: AsOrderedCollection; | |
outbox?: AsOrderedCollection; | |
href?: string; | |
hreflang?: string; | |
} | |
*/ | |
let defaultLanguage = 'und'; | |
try { defaultLanguage = Intl.getCanonicalLocales(atLanguage)[0]; } catch(e) {} | |
if (defaultLanguage === 'und' && !!language && typeof language.detect === 'function' && | |
((ap.name && !ap.nameMap) || (ap.summary && !ap.summaryMap) || (ap.content && !ap.contentMap))) { | |
const auto = language.detect(`${ap.name||''} ${ap.summary||''} ${ap.summary||''}`); | |
if (auto?.natural.length && (auto.natural[0]?.score||0) > 1) { | |
defaultLanguage = Intl.getCanonicalLocales(auto.natural[0].id)[0]; | |
} | |
} | |
let o: any = { | |
id, | |
type: hasItem(type) ? type : ['Create'], | |
[omitSymbol]: omits, | |
...notAP | |
}; | |
if (hasItem(formerType)) { | |
o.formerType = formerType.filter((s: any) => typeof s === 'string' && !!s) | |
} | |
if (hasItem(url)) { | |
o.url = url.map((u) => typeof u === 'string' | |
? { type: ["Link"], href: u } | |
: (isLink(u) ? _normalize(u, language) : false) | |
).filter((u) => !!u); | |
} | |
if (hasItem(icon)) { o.icon = icon.filter(isLinkOrImage).map((_o:any) => _normalize(_o, language)); } | |
if (hasItem(image)) { o.image = image.filter(isLinkOrImage).map((_o:any) => _normalize(_o, language)); } | |
if (typeof mediaType === 'string') { o.mediaType = mediaType } | |
if (isCaption(name, nameMap)) { toLangMap(name, nameMap, 'name', 'und') } | |
if (isCaption(summary, summaryMap)) { | |
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-summary "encoded as HTML" | |
toLangMap(summary, summaryMap, 'summary', defaultLanguage); | |
if (o?.summary) o.summary = o.summary.map((s) => DOMPurify.sanitize(s)); | |
if (o?.summaryMap) { | |
for (const k in o.summaryMap) { | |
o.summaryMap[k] = o.summaryMap[k].map((s) => DOMPurify.sanitize(s)); | |
} | |
} | |
} | |
if (isCaption(content, contentMap)) { | |
toLangMap(content, contentMap, 'content', defaultLanguage); | |
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-mediatype | |
if (!o?.mediaType || o?.mediaType === '') { | |
if (o?.content) o.content = o.content.map((s) => DOMPurify.sanitize(s)); | |
if (o?.contentMap) { | |
for (const k in o.contentMap) { | |
o.contentMap[k] = o.contentMap[k].map((s) => DOMPurify.sanitize(s)); | |
} | |
} | |
} | |
} | |
if (isCaption(source, sourceMap)) { toLangMap(source, sourceMap, 'source') } | |
if (isDatetime(published)) { o.published = published } | |
if (isDatetime(updated)) { o.updated = updated } | |
if (isDatetime(deleted)) { o.deleted = deleted } | |
if (isDuration(duration)) { o.duration = duration } | |
if (isDatetime(startTime)) { o.startTime = startTime } | |
if (isDatetime(endTime)) { o.endTime = endTime } | |
if (isPosInteger(height)) { o.height = height } | |
if (isPosInteger(width)) { o.width = width } | |
if (isCollection(replies)) { | |
(o as any).replies = _normalize((replies as AsCollection), language, options); | |
} | |
if (isObject(ap)) { | |
if (isAP(ap, 'Place')) { | |
if (typeof accuracy === 'number' && accuracy >= 0 && accuracy <= 100) { | |
(o as AsObject).accuracy = accuracy | |
} | |
if (typeof radius === 'number' && radius >= 0) { | |
(o as AsObject).radius = radius | |
} | |
if (typeof altitude === 'number') { (o as AsObject).altitude = altitude } | |
if (typeof latitude === 'number') { (o as AsObject).latitude = latitude } | |
if (typeof longitude === 'number') { (o as AsObject).longitude = longitude } | |
if (isLink(units) /* string too */ ) { (o as any).units = units } | |
} | |
if (isCollection(ap) || isCollectionPage(ap)) { | |
o = idOrO('current', current, o); | |
o = idOrO('first', current, o); | |
o = idOrO('last', current, o); | |
if (Array.isArray(items)) { | |
(o as AsObjectNormalized).items = items.map((item: AsObject) => _normalize(item, language, options)) | |
} | |
if (isPosInteger(totalItems)) { (o as AsObject).totalItems = totalItems } | |
if (isAP(ap, 'OrderedCollection') && isPosInteger(startIndex)) { | |
(o as AsObject).startIndex = startIndex | |
} | |
} | |
if (isCollectionPage(ap)) { | |
o = idOrO('next', next, o); | |
o = idOrO('prev', prev, o); | |
o = idOrO('partOf', partOf, o); | |
} | |
o = idOrO('describes', describes, o, (v) => isAP(v, 'Profile')); | |
o = idOrO('subject', subject, o, (v) => isAP(v, 'Relationship')); | |
} | |
if (isLink(ap)) { | |
if (!!href && typeof href === 'string') { (o as AsLinkObject).href = href; } | |
if (!!hreflang && typeof hreflang === 'string') { (o as AsLinkObject).hreflang = hreflang; } | |
if (hasItem(rel)) { o.rel = rel; } | |
} | |
if (isActor(ap)) { | |
if (isCollection(inbox)) { (o as any).inbox = inbox } | |
if (isCollection(outbox)) { (o as any).outbox = outbox } | |
if (isCollection(streams)) { (o as any).streams = streams } | |
if (isLink(following)) { (o as any).following = following } | |
if (isLink(followers)) { (o as any).followers = followers } | |
if (isLink(liked)) { (o as any).liked = liked } | |
if (typeof preferredUsername === 'string') { (o as any).preferredUsername = preferredUsername } | |
if (typeof endpoints === 'object') { | |
for (let key in endpoints) { | |
if (endpoints.hasOwnProperty(key) && isLink(endpoints[key])) { | |
if (!(o as any).endpoints) { (o as any).endpoints = {} } | |
(o as any).endpoints[key] = endpoints[key]; | |
} | |
} | |
} | |
} | |
if (isActivity(ap)) { | |
const _Aap: any = { | |
actor, object, instrument, origin, target, result, oneOf, anyOf, closed | |
} | |
/* TODO / NOTE origin and target are NOT marked Functional (can be multiple) [?] */ | |
const force: any = { | |
object: {Add:1,Remove:1,Create:1,Update:1,Delete:1,Follow:1,Like:1,Block:1,Undo:1}, | |
target: {Add:1,Remove:1} | |
} | |
const intransitive: any = {Question:1,Travel:1,Arrive:1}; | |
for (let key in _Aap) { | |
if (!_Aap.hasOwnProperty(key)) { continue } | |
if (key === 'oneOf' && hasItem(_Aap.oneOf) && !!_Aap.oneOf.length) { | |
_Aap.oneOf = _Aap.oneOf.map((oneO: AsObject) => ((typeof oneO === 'object') && !oneO.hasOwnProperty('type')) ? | |
{...oneO, type: 'Object'} : oneO) | |
} | |
if (key === 'anyOf' && hasItem(_Aap.anyOf) && !!_Aap.anyOf.length) { | |
_Aap.anyOf = _Aap.anyOf.map((oneO: AsObject) => ((typeof oneO === 'object') && !oneO.hasOwnProperty('type')) ? | |
{...oneO, type: 'Object'} : oneO) | |
} | |
if (key === 'object') { | |
const doForbid = typeof ap.type === 'string' ? !!intransitive[ap.type] : | |
(hasItem(ap.type) ? !!ap.type.filter((t) => !!intransitive[t]).length : false); | |
if (doForbid) { continue } | |
} | |
if (!!force[key]) { | |
const doForceForType = typeof ap.type === 'string' ? !!force[key][ap.type] : | |
(hasItem(ap.type) ? !!ap.type.filter((t) => !!force[key][t]).length : false); | |
if (doForceForType && typeof ap[key] !== 'object' && typeof ap[key] !== 'string') { | |
o[key] = []; | |
continue; | |
} | |
} | |
if (key === 'actor' && isLink(_Aap[key])) { | |
(o as AsActivity).actor = toArray(_Aap[key]); | |
} else if (key === 'closed' && (typeof _Aap[key] === 'boolean' || isDatetime(_Aap[key]))) { | |
(o as AsActivity).closed = _Aap[key]; | |
} else if (_Aap[key]) { | |
o[key] = _Aap[key].map((_o:any) => _normalize(_o, language)); | |
} | |
} | |
// console.log('ACTIVITY o', o); | |
} | |
if (hasItem(relationship)) { | |
o.relationship = relationship.map((_o:any) => _normalize(_o, language)); | |
} | |
const _ap: any = { | |
attributedTo, generator, location, | |
inReplyTo, audience, context, cc, to, | |
attachment, tag, preview | |
}; | |
if (options.includeBcc === true) { | |
_ap.bcc = bcc; | |
_ap.bto = bto; | |
} | |
for (let key in _ap) { | |
if (!_ap.hasOwnProperty(key)) { continue } | |
if (hasItem(_ap[key])) { o[key] = _ap[key].map((o:any) => _normalize(o, language)); } | |
} | |
const locales: string[] = Object.keys({...(o.nameMap||{}), ...(o.summaryMap||{}), ...(o.contentMap||{})}); | |
o.locales = locales.map((l) => Intl.getCanonicalLocales(l)).flat(); | |
return o | |
} | |
const getFactory = (as: AsObjectNormalized) => (property: string, min?: number, max?: number) => { | |
const hasMin = typeof min === 'number' && min >= 0 | |
const captions = {name:1, summary:1, content:1, nameMap:1, summaryMap:1, contentMap:1}; | |
if (captions[property]) { | |
const propM = property.endsWith('Map') ? property : `${property}Map`; | |
if (as[propM]) { | |
const v = as[propM]; | |
return hasMin && Array.isArray(v) ? v.slice(Math.round(min), Math.round(max||v.length)) : v; | |
} | |
} | |
if (!as[property]) return null; | |
const res = hasMin && Array.isArray(as[property]) | |
? as[property].slice(Math.round(min), Math.round(max||as[property].length)) | |
: as[property]; | |
if (typeof res === 'object') { | |
res.get = getFactory(res); | |
res.getOne = (property: string) => res.get(property, 0, 1)?.length ? (res.get(property, 0, 1)[0]||null) : null; | |
res.getRest = (property: string) => res.get(property, 1); | |
} | |
return res; | |
} | |
const svgT = 'image/svg+xml'; | |
export const isSVG = (o) => o?.mediaType === svgT || o?.url[0].mediaType === svgT || o?.url[0].href.endsWith('.svg'); | |
export const inlineSVG = async (o: any, basePath?: string) => { | |
if (typeof o !== 'object' || !o?.url.length || !isSVG(o)) return false; | |
o.url = o.url.map((u) => !u.href.startsWith('https:/') && !u.href.startsWith('/') ? {...u, href: `/${u.href}`} : u); | |
try { | |
const pathSVG = o.url[0].href.replace('/', (basePath||'./static/')); | |
const iconSVG = IS_BROWSER || !o?.url?.length || !o.url[0]?.href | |
? '' | |
: await Deno.readTextFile(pathSVG); | |
if (iconSVG) { | |
o.mediaType = svgT; | |
o.content = optimize(DOMPurify.sanitize(iconSVG), { | |
path: pathSVG, // recommended | |
multipass: true, | |
plugins: [ { name: 'preset-default', params: { overrides: {collapseGroups: false, removeViewBox: false} } } ], | |
js2svg: { indent: 2, pretty: true } | |
})?.data || ''; | |
} | |
} catch(e) { console.log(e) } | |
return o; | |
} | |
export default async function normalize( | |
as: APall, language?: LanguageDetect, options: NormalizeOptions = defaultNormalizeOptions | |
): Promise<AsObjectNormalized> { | |
// if it was not hydrated, we need to do this async operation first … | |
if (typeof as === 'string') { | |
try { | |
as = JSON.parse(as); | |
if (typeof as !== 'object') return as; | |
} catch(e) { | |
return (as as any); | |
} | |
} | |
if (as.hasOwnProperty('id') && !as?.id) delete as.id; | |
if (!as?.type) as.type = ["Create"]; | |
const o = await asObject.fromJsonLd(as); | |
const res = _normalize((await o.toJsonLd()), language, options); | |
if (options.inlineSVG) { | |
const basePath = typeof options.inlineSVG === 'string' ? options.inlineSVG : void 0; | |
if (res?.icon?.length) { | |
const icons: any = []; | |
for await (const icon of res.icon) { icons.push(await inlineSVG(icon, basePath)) } | |
res.icon = icons.filter((o) => !!o); | |
} | |
if (res?.image?.length) { | |
const images: any = []; | |
for await (const icon of res.image) { images.push(await inlineSVG(icon, basePath)) } | |
res.image = images.filter((o) => !!o); | |
} | |
if (res?.url?.length && res?.url[0].href && isSVG(res)) { | |
const { content = false } = await inlineSVG(res); | |
if (content) res.content = content; | |
} | |
} | |
res.get = getFactory(res); | |
res.getOne = (property: string) => res.get(property, 0, 1)?.length ? (res.get(property, 0, 1)[0]||null) : null; | |
res.getRest = (property: string) => res.get(property, 1); | |
return res; | |
//console.log('toJsonLd',await xo.toJsonLd()); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment