Skip to content

Instantly share code, notes, and snippets.

@sebilasse
Last active January 5, 2025 11:05
Show Gist options
  • Save sebilasse/3c1566846146d4ed7be007076e572165 to your computer and use it in GitHub Desktop.
Save sebilasse/3c1566846146d4ed7be007076e572165 to your computer and use it in GitHub Desktop.
Normalize and inflate ActivityPub
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