Last active
May 20, 2025 16:23
-
-
Save sebilasse/eb23601dd34e9e2b6916300c78a1a664 to your computer and use it in GitHub Desktop.
Preview kv to as, currently bugs
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
declare global { | |
var Deno: any; | |
} | |
import { createFederation, Activity, exportJwk, generateCryptoKeyPair, importJwk, MemoryKvStore } from "@fedify/fedify"; | |
import { Patch, AsFnDef, PromiseAs, VersionId, ScopeStatus, Status, HREF } from './kvInterfaces.ts'; | |
import { AsObjectNormalized, normalize, shrinkToId, asId, hasAnyType, hasAllTypes, isActivity, findKeyAndType } from "@AS"; | |
import { SimilarityKV } from '@/API/base/String/similarity.ts'; | |
import { hrefToAs } from '@/API/Link/index.ts'; | |
import { encode } from '@/API/Place/geohash.ts'; | |
import { ulid: setUlid, monotonicUlid, decodeTime } from "jsr:@std/ulid"; | |
import { | |
PUBLIC, ADR, KV, PROPERTY, JOINKEY, SCOPETYPE, SCOPESTATUS, STATUS, ACTIVITY, MILLISECONDS, SCOPE, keySet, joinTexts, | |
HTTPSHOST, protocolAllowed, charToPath, pathToChar | |
} from './kvConst.ts'; | |
import { jsondiff, createPatch, updatePatch, kvToJsonpatch, applyPatches, applyDiffs, reduceToChunks } from './kvPatch.ts'; | |
type A = `${keyof typeof ACTIVITY}`; | |
type M = (typeof kv.atomic); | |
type O = AsObjectNormalized; | |
const TAB = String.fromCharCode(9); | |
// TODO USE REDAKTOR SINGLETON ! | |
const kv = await Deno.openKv(); | |
// <-- REDAKTOR SINGLETON ! | |
// TODO Transaction Limits, see https://docs.deno.com/deploy/kv/manual/transactions/ | |
const fedifies = { | |
nodeInfoDispatcher: async (ctx, identifier) => { | |
// TODO | |
}, | |
actorDispatcher: async (ctx, identifier) => ({ | |
...(await getItem(identifier)), | |
inbox: ctx.getInboxUri(identifier), | |
publicKeys: (await ctx.getActorKeyPairs(identifier)).map(keyPair => keyPair.cryptographicKey) | |
}), | |
outboxDispatcher: async (ctx, identifier) => { | |
/* | |
const posts = await getPosts(identifier); // Get posts from the database | |
const keyOwner = await ctx.getSignedKeyOwner(); // Get the actor who signed the request | |
if (keyOwner == null) return { items: [] }; // Return an empty array if the actor is not found | |
const items = posts | |
.filter(post => post.isVisibleTo(keyOwner)) | |
.map(toCreate); // Convert model objects to ActivityStreams objects | |
return { items }; | |
*/ | |
}, | |
inboxDispatcher: async (ctx, identifier) => { | |
// TODO | |
}, | |
inboxListener: async (ctx, activity) => { | |
// TODO | |
}, | |
mapHandle: async (ctx, username) => { | |
const url = await getUrlByHandle(username); | |
// Work with the database to find the user's ULID by the WebFinger username. | |
if (!url) { return null; } | |
return await getId(url); | |
}, | |
authorize: async (ctx, identifier) => { | |
const signedKeyOwner = await ctx.getSignedKeyOwner(); | |
if (signedKeyOwner == null) return false; | |
const actorStatus = (await getUlidStatus(signedKeyOwner))||[]; | |
let [actorId, ownerHost, scope] = [actorStatus[STATUS.Ulid], actorStatus[STATUS.OwnerHost], actorStatus[STATUS.Scope]]; | |
// Is Actor blocked … | |
const isBlocked = (!actorId || scopeBlocked(scope) || !ownerHost); | |
return !isBlocked; | |
} | |
} | |
const fedipath = { | |
nodeInfo: '/.well-known/nodeinfo', | |
actor: '/users/{identifier}', | |
outbox:'/users/{identifier}/outbox', | |
inbox: '/users/{identifier}/inbox', | |
sharedInbox: '/inbox' | |
} | |
// Fedify Example: | |
/* | |
const federation = createFederation<void>({ kv }); | |
federation.setActorDispatcher(fedipath.actor, fedifies.actorDispatcher) | |
.mapHandle(fedifies.mapHandle) | |
.authorize(fedifies.authorize) | |
.setNodeInfoDispatcher(fedipath.nodeInfo, fedifies.nodeInfoDispatcher) | |
.setOutboxDispatcher(fedipath.outbox, fedifies.outboxDispatcher) | |
.setInboxListeners(fedipath.inbox, fedipath.sharedInbox) | |
.on(Activity, fedifies.inboxListener) | |
// setObjectDispatcher | |
// setFeaturedDispatcher, setFeaturedTagsDispatcher ... | |
*/ | |
/* AS TO KV */ | |
const baseUrl = location.href.endsWith('/') ? location.href : `${location.href}/`; | |
const urlDelimiter = { actor: '_', outbox: 'out'}; | |
export const createId = (ownerUlid?: string) => { | |
const base = `${baseUrl}${urlDelimiter.actor}`; | |
if (!ownerUlid) { | |
ownerUlid = monotonicUlid(); | |
return {id: `${base}/${ownerUlid}`, ownerUlid } | |
} | |
const ulid = monotonicUlid(); | |
return {id: `${base}/${ownerUlid}/${urlDelimiter.outbox}/${ulid}`, ownerUlid, ulid } | |
} | |
export const kvUrl = ({id}) => (id.startsWith(baseUrl) ? id.replace(`${baseUrl}/`, '/') : id.replace('https://', '')); | |
export const isValidUlid = (ulid: string) => typeof ulid !== 'string' || ulid.length < 25 || ulid.length > 27; | |
/* */ | |
function hrefToHrefKey(u: string|URL) { | |
const url = typeof u === 'object' && u?.protocol && u?.hostname ? u : new URL(u); | |
const urlStr = typeof u === 'object' && u?.protocol && u?.hostname ? u.toString() : u; | |
if (!url?.protocol || !url?.host) { return null; } | |
const { protocol, host, username, password, pathname, search, hash } = url; | |
const isHttps = protocol === 'https:'; | |
const isHost = pathname === '/' && !search && !hash; | |
const isLocal = (urlStr as string).startsWith(baseUrl); | |
const primaryKey = String.fromCharCode(isHost ? KV.HOST : (isLocal ? KV.OUT : KV.IN)); | |
if (!isHttps && !protocolAllowed[protocol]) { return null; } | |
if (isLocal) { | |
const [ownerUlid, ulid = false] = pathname.split('/').filter((s) => !!s && s !== urlDelimiter.actor && s !== urlDelimiter.outbox); | |
return ulid ? [primaryKey, ownerUlid, ulid] : [primaryKey, ownerUlid]; | |
} | |
const prefix = username ? `${!password ? `${username}` : `${username}:${password}@`}` : ''; | |
const hostKey = isHttps && HTTPSHOST[`${prefix}${host}`] | |
? (HTTPSHOST[`${prefix}${host}`] < 126 ? String.fromCharCode(HTTPSHOST[`${prefix}${host}`]) : HTTPSHOST[`${prefix}${host}`]) | |
: `${prefix}${host}`; | |
const key = isHttps ? [primaryKey, hostKey] : [primaryKey, protocol, hostKey]; | |
if (pathname && pathname !== '/') { | |
const parts = pathname.split('/'); | |
for (const p of parts) { key.push(charToPath[p] ? charToPath[p] : p); } | |
} | |
if (search||hash) { key.push(`${search||''}${hash||''}`); } | |
return key | |
} | |
function hrefKeyToHref(a: null|string[]) { | |
if (!Array.isArray(a)) { return null; } | |
const isLocal = a[0].length === 1 && a[0].charCodeAt(0) === KV.OUT; | |
if (isLocal) { return `https://${baseUrl}${urlDelimiter.actor}/${a[1]}${a.length > 2 ? `/${urlDelimiter.outbox}/${a[2]}` : ''}`} | |
const isHttps = typeof a[0] === 'number' || !a[0].endsWith(':'); | |
const protocol = isHttps ? 'https:' : a[0]; | |
if (!isHttps) { a = a.slice(1); } | |
let [host, ...pathParts] = a; | |
if (typeof host === 'number') { | |
host = HTTPSHOST[host]; | |
} else if (host.length === 1) { | |
host = HTTPSHOST[host.charCodeAt(0)]; | |
} | |
pathParts = pathParts.map((p, i) => (p.startsWith('?') || p.startsWith('#') ? p : `${i ? '/' : ''}${pathToChar[p]||p}`)); | |
const noDash = { mailto:1, geo:1, tel:1 }; | |
return `${protocol}${noDash[protocol] ? '' : '//'}${host}${pathParts.join('')}`; | |
} | |
// host [KVKEY.HOST, (OwnerHostChar or OwnerHost)] = Scope | |
// localActor [KVKEY.OUT, OwnerUlid] = Scope+(ProfileVersionId or TAB) | |
// localActivity [KVKEY.OUT, OwnerUlid, Ulid] = Scope+ActivityChars | |
// localObject [KVKEY.OUT, OwnerUlid, Ulid] = Scope+...OtherActorUlids+(VersionId or TAB) | |
// remoteActor [KVKEY.IN, (OwnerHostChar or OwnerHost), path] = Scope+OwnerUlid | |
// remoteActivity [KVKEY.IN, (OwnerHostChar or OwnerHost), path] = Scope+OwnerUlid+Ulid+ActivityChars | |
// remoteObject [KVKEY.IN, (OwnerHostChar or OwnerHost), path] = Scope+OwnerUlid+Ulid+...OtherActorUlids | |
// ulid (reverse) [KVKEY.ULID, (OwnerHostChar or Ulid)] = above key | |
// const isActiveOrTombstone = !(i%2); const isActive = Number.isInteger(i/16); const isWait = Number.isInteger((i-27)/16); | |
// const isPrivate = (i>31 && i<44); const isShared = (i>47 && i<60); const isPublic = i>63; const isHost = (i>95 && i<108); | |
// ActorUlids?, ActorHosts?, LikeCount?, ShareCount? | |
// User | |
export const setUser = async (user, status, confirmUrl?): PromiseAs => {} | |
export const updateUser = async (user, status, confirmUrl?): PromiseAs => {} | |
export const deleteUser = async (user, status, confirmUrl?): PromiseAs => {} | |
export const serverBlockUser = async (UsertId: string): PromiseAs => {} | |
export const serverBlock = async (actorOrObjectId: string): PromiseAs => {} | |
// Actor | |
export const setActor = async (actor, userId): PromiseAs => { | |
} | |
type TYPENR = SCOPETYPE & number; type STATUSNR = SCOPESTATUS & number; | |
export const setScope = (type: TYPENR, status: STATUSNR) => String.fromCharCode(type+status); | |
const statusSetter = { | |
local: { | |
setActor: setScope, | |
setActivity: (type: TYPENR, status: STATUSNR, activityChars = '') => `${setScope(type, status)}${activityChars}`, | |
setObject: (type: TYPENR, status: STATUSNR, otherActorUlids: string[] = [], versionId = TAB) => | |
`${setScope(type, status)}${otherActorUlids.join('')}${versionId}` | |
}, | |
remote: { | |
setActor: (type: TYPENR, status: STATUSNR, ownerUlid: string) => `${setScope(type, status)}${ownerUlid}`, | |
setActivity: (type: TYPENR, status: STATUSNR, ownerUlid: string, activityChars = '') => | |
`${setScope(type, status)}${ownerUlid}${activityChars}`, | |
setObject: (type: TYPENR, status: STATUSNR, ulid: string, ownerUlid: string, otherActorUlids: string[] = []) => | |
`${setScope(type, status)}${ownerUlid}${ulid}${otherActorUlids.join('')}` | |
} | |
} | |
async function setStatus( | |
status = SCOPESTATUS.ACTIVE, asOrHost: O|string, type: Status["type"], | |
ownerUlid: string, ulid = '', versionUlid = TAB, activityChars = '' | |
): Promise<{key: any[], value: Status}> { | |
const id = asId(asOrHost); | |
const key = hrefToHrefKey(id); | |
if (!key) { throw new Deno.errors.NotFound(`${id}: Object key not found.`); } | |
if (typeof asOrHost === 'string') { | |
const u = new URL(asOrHost); | |
if (u?.pathname === '/' && !u?.search && !u.hash && !!u?.hostname) { | |
return { key, value: parseStatus(asOrHost, setScope(SCOPETYPE.HOST, status), key, false, true) }; | |
} else { | |
// TODO ? | |
asOrHost = await getItem(asOrHost); | |
} | |
} | |
let isPublic = false; | |
const otherActorUlids: string[] = []; | |
const { attributedTo } = asOrHost; | |
for await (const aUrl of attributedTo) { | |
if (aUrl === 'https://www.w3.org/ns/activitystreams#Public' || aUrl === 'as:Public' || aUrl === 'Public') { isPublic = true; } | |
const { ulid } = kv.get(hrefToHrefKey(aUrl))?.value; | |
if (!ulid) { | |
// TODO : New Actor url detected | |
} | |
if (ulid !== ownerUlid) { otherActorUlids.push(ulid); } | |
} | |
const isLocal = id.startsWith(baseUrl); | |
const isActor = (!ulid) && !activityChars; | |
const isActivity = !!ulid && !!activityChars; | |
let scopeType = isPublic === true ? SCOPETYPE.PUBLIC : (!otherActorUlids.length ? SCOPETYPE.PRIVATE : SCOPETYPE.SHARED); | |
let statusDef = ''; | |
if (isLocal) { | |
const { setActor, setActivity, setObject } = statusSetter.local; | |
if (isActor) { | |
statusDef = setActor(scopeType, status); | |
} else if (isActivity) { | |
statusDef = setActivity(scopeType, status, activityChars); | |
} else { | |
if (!ulid) { throw new Deno.errors.NotFound(`${id}: Object ulid not found.`); } | |
statusDef = setObject(scopeType, status, otherActorUlids, versionUlid); | |
} | |
return { key, value: parseStatus(id, statusDef, key, true, false) }; | |
} | |
const ownerHost = key[1]; | |
const { setActor, setActivity, setObject } = statusSetter.remote; | |
if (isActor) { | |
statusDef = setActor(scopeType, status, ownerUlid); | |
} else if (isActivity) { | |
statusDef = setActivity(scopeType, status, ownerUlid, activityChars); | |
} else { | |
if (!ulid) { throw new Deno.errors.NotFound(`${id}: Object ulid not found.`); } | |
statusDef = setObject(scopeType, status, ulid, ownerUlid, otherActorUlids); | |
} | |
return { key, value: parseStatus(id, statusDef, key, true, false) }; | |
} | |
/*export enum SCOPETYPE { | |
PRIVATE = 32, | |
SHARED = 48, | |
PUBLIC = 64, | |
HOST = 96 | |
}*/ | |
/* */ | |
function initialStatus(oId: string, ownerUlid: string, aStatus: Partial<DEF> = []): DEF { | |
const [aId = false, hasText = false, aOwnerHost = '/'] = aStatus.slice(3); | |
// Ulid, OwnerUlid, Scope, VersionId, hasText, OwnerHost?, ActorUlids?, ActorHosts?, LikeCount?, ShareCount? | |
return [oId, ownerUlid, SCOPE.ACTIVE, aId, hasText, aOwnerHost, [], [], 0, 0]; | |
} | |
/* get ULID and STATUS from URL internally */ | |
function parseScope(scope): ScopeStatus { | |
const i = scope.charCodeAt(0); | |
const isActiveOrTombstone = !(i%2); | |
const isActive = Number.isInteger(i/16); | |
const isWait = Number.isInteger((i-11)/16); | |
const isBlocked = !isActiveOrTombstone && !isWait; | |
const [isPrivate, isShared, isPublic, isHost] = [(i>31 && i<44), (i>47 && i<60), (i>63), (i>95 && i<108)]; | |
return { isActiveOrTombstone, isActive, isWait, isBlocked, isPrivate, isShared, isPublic, isHost } | |
} | |
function parseStatus(id, value, hrefKey, isLocal, isHost = false) { | |
const scope = value.charAt(0); | |
const ulids = value.slice(1); | |
const scopeStatus = parseScope(scope); | |
const isActor = isHost ? false : (isLocal && hrefKey.length > 3 ? true : (ulids.length === 26 ? true : false)); | |
const isActivity = isHost||isActor ? false : ((!isLocal && ulids.length > 52) || (isLocal && ulids.length < 26)); | |
const type: Status["type"] = isHost ? 'Host' : (isActor ? 'Actor' : (isActivity ? 'Activity' : 'Object')); | |
const ulidChunks = Array.from({ length: Math.ceil(ulids.length / 26) }, (v, i) => ulids.slice(i * 26, i * 26 + 26)); | |
const ownerHostKey = hrefKey[1]; | |
const ownerHost = isLocal ? '/' : (typeof hrefKey[1] === 'string' && hrefKey[1].length > 2 | |
? hrefKey[1] | |
: HTTPSHOST[typeof hrefKey[1] === 'number' ? hrefKey[1] : hrefKey[1].charCodeAt(0)]); | |
let [actorUlids, activities, ownerUlid, ulid] = [[], [], hrefKey[1], hrefKey[2]] as [string[], string[], string, string]; | |
if (!isLocal) { | |
ownerUlid = ulidChunks.shift(); | |
ulid = ulidChunks.shift(); | |
} | |
if (type === 'Activity') { activities = ulidChunks[ulidChunks.length-1].split('').map((ch) => ACTIVITY[ch.charCodeAt(0)]); } | |
let versionUlid = ''; | |
if (type === 'Object') { | |
versionUlid = ulidChunks.pop(); | |
actorUlids = ulidChunks; | |
} | |
const status: Status = { id, type, value, ownerHostKey, ownerHost, ownerUlid, ulid: (ulid||ownerUlid), ...scopeStatus }; | |
if (versionUlid && versionUlid.length === 26) { status.versionUlid = versionUlid; } | |
if (actorUlids.length) { status.actorUlids = actorUlids; } | |
if (activities.length) { status.activities = activities; } | |
return status | |
} | |
async function getUlidStatus(idOrAS: string|O, inclTombstone = true, inclAll = false): Promise<Status|null> { | |
if (!idOrAS) { return null; } | |
const id = asId(idOrAS); | |
if (typeof id !== 'string') { return null; } | |
const key = hrefToHrefKey(id); | |
if (!Array.isArray(key) || !key.length) { return null; } | |
const [isHost, isLocal] = [key[0] === KV.HOST, key[0] === KV.OUT]; | |
const { value } = await kv.get(key); | |
const scopeStatus = parseScope(value.charAt(0)); | |
if (!inclAll && !inclTombstone && !scopeStatus.isActive) { return null; } | |
if (!inclAll && !scopeStatus.isActiveOrTombstone) { return null; } | |
return parseStatus(id, value, key, isLocal, isHost); | |
} | |
/* get URL from ULID */ | |
async function getUrl(ulid: string) { | |
return isValidUlid(ulid) ? null : (await kv.get([KV.ULID, ulid]))?.value ?? null; | |
} | |
/* get URL from HANDLE */ | |
export async function getUrlByHandle(handle): Promise<string|null> { | |
if (handle.indexOf('@') < 0) { handle = `@${handle}@${baseUrl.replace(/[/]$/,'')}`; } | |
if (!handle.startsWith('@') || handle.indexOf('@') === handle.lastIndexOf('@')) { return null; } | |
return await kv.get([KV.WEBFINGER, handle])?.value; | |
} | |
/* is STATUS owned by ULID ? */ | |
export function isObjectOwner(status: Status, ownerUlid: string) { | |
return status.ownerUlid === ownerUlid || (status?.actorUlids && status.actorUlids.indexOf(ownerUlid) > -1); | |
} | |
/* did ULID block STATUS ? */ | |
export async function didBlock(gettingUlid: string, status: Status): Promise<boolean> { | |
if (status.isBlocked) { return true; } | |
if (!gettingUlid) { return false; } | |
//const [id, ownerUlid = '', s,v,j, ownerHost = '', actorUlids = [], actorHosts = []] = status; | |
const { ulid, ownerUlid = '', ownerHostKey = '', actorUlids = [] } = status; | |
const A = KV.ACTOR_ACTION; | |
const B = ACTIVITY.Block; | |
// getting actor not interested in this object | |
if (ulid && (await kv.get([A, gettingUlid, B, ulid]))?.value) { return true; } | |
const blockKeys = [ | |
[A, gettingUlid, B, ownerHostKey], // getting actor not interested in object domain. | |
[A, gettingUlid, B, ownerUlid], // getting actor not interested in object actor. | |
[A, ownerUlid, B, gettingUlid] // object actor not interested in getting actor or domain. | |
]; | |
if (actorUlids.length) { | |
// getting actor not interested in other actor domain. | |
for await (const ulid of actorUlids) { | |
const key = await kv.get([KV.ULID, ulid]); | |
if (key[0] === KV.IN) { blockKeys.push([A, gettingUlid, B, key[1]]); } | |
} | |
// actor not interested in other actor. | |
for (const aUlid of actorUlids) { | |
blockKeys.push([A, gettingUlid, B, aUlid]); | |
blockKeys.push([A, aUlid, B, gettingUlid]); | |
} | |
} | |
// other object blocks | |
if ((await kv.getMany(blockKeys)).filter((o) => !!o?.value).length) { return true; } | |
return false; | |
} | |
/* get ULID and STATUS from URL for Actor */ | |
export async function getWithStatus(objectUrl: string, forActorUrl: string|false = false, inclTombstone = true, inclAll = false): Promise<Status|null> { | |
const status = await getUlidStatus(objectUrl, inclTombstone, inclAll); | |
const forActorStatus = await getUlidStatus(forActorUrl); | |
if (!status || !forActorStatus) { return null; } | |
const { ulid = '', ownerUlid = '', isPublic = false, isActive = false } = status; | |
const { ulid: actorUlid = '' } = forActorStatus; | |
// not found or blocked by Instance | |
if (!ulid || (forActorUrl && !actorUlid)) { return null } | |
// me or own item | |
if (ulid === actorUlid || ownerUlid === actorUlid) { return status; } | |
// actors block : | |
if (await didBlock(actorUlid, status)) { return null; } | |
// public or addressed to forActorUrl : | |
if ((isPublic && isActive) || (await kv.get([KV.TO, actorUlid, ulid]))?.value) { return status; } | |
// TODO - NOW COVERED BY KV.TO … | |
// visible forActorUrl through Collection : | |
const adrCollections = kv.list({prefix: [KV.COLLECTION_FOR_ACTIVITY, ulid]}); | |
for await (const c of adrCollections) { | |
const [k, u, cId = ''] = c.key; | |
if (cId && (await kv.get([KV.OBJECT_FOR_COLLECTION, actorUlid, cId]))?.value) { | |
return status; /* is member of addressed Collection */ | |
} | |
} | |
return null; | |
} | |
/* get ULID and STATUS from HANDLE for Actor */ | |
export const getWithStatusByHandle = async (handle): Promise<Status|null> => (await getWithStatus((await getUrlByHandle(handle))||'')); | |
export async function getId(objectUrl, forActorUrl?, inclTombstone = true, inclAll = false): PromiseAs { | |
const status = await getWithStatus(objectUrl, forActorUrl, inclTombstone, inclAll); | |
return status ? status[STATUS.Ulid] : null; | |
} | |
/* get all VERSION IDS [Create/Update/Delete/Undo Action Ids] from ULID */ | |
async function getVersionIds(oId: string, onlyLastActionId = false) { | |
let lastId = ''; | |
const versions: string[] = []; | |
// reverse = newest first ... | |
const previousActions = kv.list({prefix: [KV.OBJECT_ACTION, oId]}, {reverse: true}); | |
for await (const u of previousActions) { | |
const allowed = new Set([ACTIVITY.Create, ACTIVITY.Update, ACTIVITY.Delete, ACTIVITY.Undo]); | |
//[KVKEY.OBJECT_ACTION, oId, activityType, aId, ownerUlid]; | |
const [type = false, aId = ''] = u.key; | |
if (!allowed.has(type) || !aId) { continue } | |
lastId = aId; | |
if (onlyLastActionId) { return lastId; } | |
versions.unshift(lastId); | |
} | |
return versions | |
} | |
/* get last VERSION ID [Create/Update/Delete/Undo Action Id] from ULID */ | |
async function getLastVersionId(oId): Promise<string> { | |
return (await getVersionIds(oId, true) as string); | |
} | |
/* */ | |
async function definitionCheck( | |
mutation: M, as_id: string|O, forActorUrl, ownerUlid, actors, mode: 'Update'|'Delete' = 'Update') { | |
const {key, versionstamp} = await kv.get(hrefToHrefKey(as_id)); | |
if (!key) { | |
throw new Deno.errors.NotFound(`${mode} Object key not found.`); | |
} | |
mutation.check({ key, versionstamp }); | |
// Is this actor an object "owner" ? | |
const status = await getWithStatus(asId(as_id), forActorUrl); | |
if (!status) { throw new Deno.errors.NotSupported(`${mode} Object not found.`); } | |
if (!isObjectOwner(status, ownerUlid)) { | |
if (!actors.map(asId).filter((u) => u === forActorUrl).length) { | |
throw new Deno.errors.NotSupported(`${mode} not allowed for Object ${asId(as_id)}`); | |
} | |
} | |
return status; | |
} | |
/* */ | |
function getOwningActors(owningActorUrls: string[], actors: {[url:string]: string; /* ulid */}): Partial<Status> & {actorHosts: string[]} { | |
const actorUlids = owningActorUrls.map((acctUrl) => actors[acctUrl]); | |
const actorHosts: string[] = Array.from(new Set(owningActorUrls.reduce((a: string[], acctUrl) => { | |
const {hostname} = new URL(acctUrl, baseUrl); | |
if (!hostname || typeof hostname !== 'string') { return a } | |
const hostparts = hostname.split('.'); | |
for (let i = 0; i < hostparts.length; i++) { | |
a.push(hostparts.join('.')); | |
hostparts.shift(); | |
} | |
return a; | |
}, []))); | |
return {actorUlids, actorHosts} | |
} | |
/* */ | |
export async function getNodeinfoByHost(domain): Promise<any> { | |
return (await kv.get([KV.NODEINFO, domain])?.value); | |
} | |
/* */ | |
async function getPatch(status: Status, lastVersionId?: VersionId) { | |
const { ulid } = status; | |
const patches: any = []; | |
const list = !lastVersionId ? kv.list({prefix: [ulid]}) : kv.list({prefix: [ulid], end: [ulid, `${lastVersionId}~`]}); | |
for await (const o of list) { patches.push(kvToJsonpatch(o.key)) } | |
return patches; | |
} | |
export async function getItem(objectUrl, forActorUrl?, inclTombstone = true, inclAll = false, versionUlid?: VersionId): PromiseAs { | |
const status = await getWithStatus(objectUrl, forActorUrl, inclTombstone, inclAll); | |
if (!status) { return null; } // is e.g. an Undo of Create but that can receive another Undo ... | |
if (!versionUlid) { versionUlid = status[STATUS.VersionId]; } | |
const patches = await getPatch(status, versionUlid); | |
const asBase: O = { id: objectUrl, published: new Date(decodeTime(status[STATUS.Ulid])).toISOString() }; | |
if (versionUlid) { | |
asBase[status.isActive ? 'updated' : 'deleted'] = new Date(decodeTime(versionUlid)).toISOString(); | |
} | |
const as: O = {...asBase, ...applyPatches({}, patches).as}; | |
await getText(status, as); | |
// console.log(as); | |
if (hasAnyType(as, 'Link') && typeof as?.preview === 'string') { | |
try { as.preview = await getItem(as.preview); } catch(e) {} | |
} | |
for (const k in as) { | |
if (Array.isArray(as[k])) { | |
as[k] = as[k].map((v) => (v.startsWith('/') ? `${baseUrl}${v}` : v)); | |
} else if (typeof as[k] === 'string' && as[k].startsWith('/')) { | |
as[k] = (as[k].startsWith('/') ? `${baseUrl}${as[k]}` : as[k]); | |
} | |
} | |
return as; | |
} | |
/* */ | |
async function getDiff(diffs) { | |
if (typeof diffs[0] !== 'string') { return diffs; } | |
// TODO - only allow Diffs up to X levels in kvText() | |
let i = 0; | |
while (i < 50 && Array.isArray(diffs[0]) && diffs[0].length && diffs[0][0] === KV.TEXT) { | |
const nTextKey = diffs.shift(); | |
diffs = [...(await kv.get(nTextKey)?.value), ...diffs]; | |
i = i+1; | |
} | |
return diffs; | |
} | |
export async function getText(status: Status, as: O = {}) { | |
//if (!status?.length) { return as; } | |
const { ulid, versionUlid } = status; | |
// Ulid, OwnerUlid, Scope, VersionId, hasText, OwnerHost?, ActorUlids?, ActorHosts?, LikeCount?, ShareCount? | |
const texts: any = {}; | |
const list = kv.list({prefix: [KV.TEXT, ulid, versionUlid]}); | |
for await (const diffs of list) { | |
const [k, lang, index] = diffs.key.slice(3); | |
if (!texts[k]) { texts[k] = {}; } | |
if (!texts[k][lang]) { texts[k][lang] = []; } | |
const d = await getDiff(diffs?.value); | |
texts[k][lang].push(applyDiffs(diffs)) | |
} | |
return texts; | |
} | |
async function getTextByKey(texKey) { | |
const diffs = await getDiff(kv.get(texKey)?.value); | |
return applyDiffs(diffs); | |
} | |
async function kvText(as: O, status: Status, mutation: M, versionUlid: string|false) { | |
const { ulid } = status; | |
const [diffMax, MIN_CHAR_DIFF, CHAR_DIFF_PROB_SMALLER_TEXT_FACTOR] = [20, 64, 5000000] | |
const oKeys = Object.keys(as).filter((k) => !!joinTexts[k]); | |
// find similar text and according diffs | |
for await (const k of oKeys) { | |
for await (const lang of as[k]) { | |
let index = 0; | |
for await (const text of as[k][lang]) { | |
// no text | |
if (typeof text !== 'string') { continue } | |
// TODO status.hasText = true; | |
const {key, value} = await SimilarityKV(text); | |
const textKey = [KV.TEXT, ulid, versionUlid, k, lang, index]; | |
const simKey = [KV.SIMILARITY, key]; | |
// equal text | |
const equal = kv.get(key)?.value; | |
if (!!equal && equal?.key) { | |
mutation.set(textKey, [equal.key]); | |
index = index+1; | |
continue; | |
} | |
// too short text | |
if (text.length < MIN_CHAR_DIFF) { | |
mutation.set(textKey, [text]); | |
mutation.set(simKey, {value, key: textKey}); | |
index = index+1; | |
continue; | |
} | |
// most similar text? | |
const [tSim, tLength, uuid] = key; | |
const rangeFactor = tLength * CHAR_DIFF_PROB_SMALLER_TEXT_FACTOR; | |
const diffMap = new Map(); | |
const larger = kv.list({ | |
start: [KV.SIMILARITY, tSim], | |
end: [KV.SIMILARITY, tSim + rangeFactor] | |
}); | |
const smaller = kv.list({ | |
start: [KV.SIMILARITY, tSim - rangeFactor], | |
end: [KV.SIMILARITY, tSim] | |
}, {reverse: true}); | |
const diffCount = Math.min(Math.min(diffMax, smaller.length), larger.length); | |
for (let n = 0; n <= diffCount; n++) { | |
const smallerText = await getTextByKey(smaller[n]?.value?.key); | |
const smallerDiff = jsondiff.diff(smallerText, text); | |
const largerText = await getTextByKey(larger[n]?.value?.key); | |
const largerDiff = jsondiff.diff(largerText, text); | |
try { | |
if (smallerDiff) { diffMap.set(JSON.stringify(smallerDiff).length, [smaller[n]?.value?.key, smallerDiff]); } | |
if (largerDiff) { diffMap.set(JSON.stringify(largerDiff).length, [larger[n]?.value?.key, largerDiff]); } | |
} catch(e) {} | |
} | |
const sorted = [...diffMap.entries()].sort((a,b) => (a[0]-b[0])); | |
const [bestScore = -1, [bestKey, bestDiff]] = sorted.length ? sorted[0] : []; | |
// no suitable similar | |
if (bestScore < 0 || bestScore > text.length) { | |
mutation.set(textKey, [text]); | |
mutation.set(simKey, {value, key: textKey}); | |
} else { | |
mutation.set(textKey, [bestKey, bestDiff]); | |
mutation.set(simKey, {value, key: textKey}); | |
} | |
index = index+1; | |
} | |
} | |
} | |
} | |
/* */ | |
/* activityType = false means preview for href */ | |
function setWithStatus(id: string|O, oId: string, status: string, mutation: M, ownerUlid: string, activityType: A) { | |
id = typeof id === 'object' ? asId(shrinkToId({id}, baseUrl)) : id; | |
const [idKey, oKey] = [hrefToHrefKey(id), [KV.ULID, oId]]; | |
const activityChar = String.fromCharCode(ACTIVITY[activityType]); | |
const actorKey = [KV.ACTOR_ACTION, ownerUlid, activityChar, oId]; | |
mutation.set(idKey, status).set(oKey, idKey).set(actorKey, idKey); | |
return mutation; | |
} | |
/* */ | |
async function setObjectActivity( | |
as: O, activityStatus: Status, mutation: M, forActorUrl: string, activityType: A = 'Create', | |
waitForAcceptExpireIn?: number | |
) { | |
const { ulid: aId, ownerUlid } = activityStatus; | |
const def = await getWithStatus(asId(as), forActorUrl) | |
if (!def) { | |
const u = new URL(as); | |
if (u?.pathname === '/' && !u?.search && !u.hash && !!u?.hostname) { | |
as = u.hostname; | |
} | |
} | |
const {isWait, ulid: oId = as, o = ({id: as})} = def||{}; | |
if (!oId || typeof oId !== 'string' || !o.id) { | |
throw new Error(`Failed to request ${activityType}: ${o.id}`); | |
} | |
const activityChar = String.fromCharCode(ACTIVITY[activityType]); | |
const actionKey = [KV.OBJECT_ACTION, oId, activityChar, aId, ownerUlid]; | |
const actorKey = [KV.ACTOR_ACTION, ownerUlid, activityChar, oId]; | |
if (isWait) { | |
const waitKey = [KV.WAIT_FOR_ACCEPT, aId, oId]; | |
const setOptions = waitForAcceptExpireIn ? { expireIn: waitForAcceptExpireIn } : {}; | |
mutation.set(waitKey, [[actionKey, aId], [actorKey, o.id]], setOptions); | |
} else { | |
mutation.set(actionKey, true).set(actorKey, o.id); | |
} | |
} | |
function patchMutation(patches: Patch[], mutation = kv.atomic(), isUpdate = false) { | |
for (const p of patches) { mutation.set(p, !!p[4]); } | |
return mutation; | |
} | |
/* */ | |
function createPatchMutation(oId: string, original: O, versionUlid: string|false = false): M { | |
const mutation = patchMutation(createPatch(oId, original, versionUlid)); | |
return mutation.check({ key: hrefToHrefKey(original.id), versionstamp: null }); | |
} | |
/* */ | |
function updatePatchMutation(oId: string, original: O, updated: O, status: DEF, versionUlid: string|false = false, mutation?): M { | |
status[STATUS.VersionId] = versionUlid; | |
return patchMutation(updatePatch(oId, original, updated, versionUlid), mutation, true); | |
} | |
/* */ | |
async function patch( | |
as: O, status: Status, forActorUrl: string, activityType: A = 'Create', asUpdate?: O, | |
reactionObject: AsObjectNormalized[]|string|null = [], waitForAcceptExpireIn?: number | |
) { | |
const { ulid, ownerUlid, value } = status; | |
const isUpdate = activityType === 'Update' && as && asUpdate; | |
if (!ulid) { return } | |
/* | |
const [isCreate, isUpdate] = [(activityType === ACTIVITY.Create && as), (activityType === ACTIVITY.Update && as && asUpdate)]; | |
const timeKey = isCreate ? 'published' : (isUpdate ? 'updated' : null); | |
if (timeKey) { as[timeKey] = new Date(decodeTime(aId)).toISOString(); } | |
*/ | |
const mutation = isUpdate | |
? updatePatchMutation(ulid, as, asUpdate, status) | |
: createPatchMutation(ulid, as); | |
await kvText(as, status, mutation, ulid); | |
if (Array.isArray(reactionObject) && !reactionObject?.length) { | |
return setWithStatus(as.id, ulid, value, mutation, ownerUlid, activityType); | |
} else if (!Array.isArray(reactionObject)) { | |
reactionObject = [reactionObject]; | |
} | |
for await (let _o of reactionObject) { | |
await setObjectActivity(_o, status, mutation, forActorUrl, activityType, waitForAcceptExpireIn); | |
} | |
return setWithStatus(as.id, ulid, value, mutation, ownerUlid, activityType); | |
} | |
/* */ | |
async function patchObject(o: O, oStatus: Status, ownerUrl: string, activity: O, aStatus?: Status, fromServer = false, original?: O) { | |
const oMutation = fromServer ? kv.atomic() : (original | |
? (await patch(shrinkToId(original, baseUrl), oStatus, ownerUrl, shrinkToId(o, baseUrl))) | |
: (await patch(shrinkToId(o, baseUrl), oStatus, ownerUrl))); | |
if (Object.keys(o).filter((k) => ADR[k]).length) { | |
await kvTo(o, oStatus, oMutation, ownerUrl, activity, original); | |
} | |
if (Object.keys(o).filter((k) => JOINKEY[k]).length) { | |
await kvJoinAS(o, oStatus, oMutation, ownerUrl, activity, original, fromServer); | |
} | |
// Secondary Indexes for objects : what, when, where | |
if (fromServer === false) { await whatWhereWhen(o, oStatus, oMutation, ownerUrl, original); } | |
// Set main index | |
if (aStatus){ await setObjectActivity(o, aStatus, oMutation, ownerUrl); } | |
return oMutation; | |
} | |
/* */ | |
async function kvTo( | |
as: O, status: Status, mutation: M, forActorUrl: string, activity?: O|false, currentO?: O, setOptions = {} | |
) { | |
const { ulid } = status; | |
const oKeys = Object.keys(as).filter((k) => !!ADR[k]); | |
const deletes = {}; | |
const asIsActivity = !activity; | |
const kvToOp = async (arr: string[], k: string, op: 'set'|'delete' = 'set') => { | |
for await (const adrUrl of arr) { | |
const toActorUlid = await getId(adrUrl, forActorUrl); | |
const isCollection = typeof (await kv.get([KV.ITEMCOUNT, toActorUlid]))?.value === 'number'; | |
if (isCollection) { | |
const collection = kv.list({prefix: [KV.PROPERTY, toActorUlid, PROPERTY.items]}); | |
for await (const toUlid of collection) { | |
if (toUlid.charAt(0) === '_') { mutation[op]([KV.TO, toUlid, asIsActivity, ulid, ADR[k]], k, setOptions); } | |
} | |
continue; | |
} | |
mutation[op]([KV.TO, toActorUlid, asIsActivity, ulid, ADR[k]], k, setOptions); | |
} | |
return mutation | |
} | |
for await (const k of oKeys) { | |
if (typeof activity === 'object') { | |
let union = Array.from(new Set([...(activity[k]||[]).map(asId), ...(as[k]||[]).map(asId)])); | |
deletes[k] = []; | |
if (currentO && currentO[k]) { | |
const [oSet, curSet] = [new Set(as[k]), new Set(currentO[k])]; | |
union = union.filter((u) => !curSet.has(u)); | |
deletes[k] = as[k].filter((u) => oSet.has(u)); | |
} | |
as[k] = union; | |
activity[k] = union; | |
} else if (currentO && currentO[k]) { | |
let union = Array.from(new Set((as[k]||[]).map(asId))); | |
const [oSet, curSet] = [new Set(as[k]), new Set(currentO[k])]; | |
union = union.filter((u) => !curSet.has(u)); | |
deletes[k] = as[k].filter((u) => oSet.has(u)); | |
as[k] = union; | |
} | |
await kvToOp(as[k], k); | |
await kvToOp(deletes[k], k, 'delete'); | |
} | |
} | |
/* */ | |
async function kvJoinAS( | |
as: O, status: Status, mutation: M, forActorUrl: string, currentO?: O, fromServer = false, setOptions = {} | |
) { | |
const { ulid } = status; | |
const o = {...as}; | |
const oKeys = Object.keys(o).filter((k) => !!JOINKEY[k]); | |
const deletes = {}; | |
const functional = { describes: PROPERTY.describes, partOf: PROPERTY.partOf, href: PROPERTY.href }; | |
const kvJoinASOp = async (arr: string|O[], k: string, op: 'set'|'delete' = 'set') => { | |
if (typeof arr === 'string' && functional[k]) { | |
return mutation[op]([KV.PROPERTY, ulid, PROPERTY[k], arr], true, setOptions); | |
} | |
if (!Array.isArray(arr) || !arr?.length) { return mutation } | |
if (k === 'image') { | |
k = 'url'; | |
const a: string[] = []; | |
for await (const img of arr) { | |
const o = typeof img === 'object' ? img : (await getItem(img, forActorUrl)); | |
if (o?.url) { | |
for (const u of o.url) { if (typeof u === 'string' || u?.href) { a.push(typeof u === 'string' ? u : u.href) } } | |
} | |
} | |
arr = a; | |
} | |
for await (const url of arr) { | |
// .image > url and .url : BY HREF | |
if (k === 'url') { | |
const v = (typeof url === 'object' && url?.href) ? url.href : url; | |
mutation[op]([KV.PROPERTY, ulid, PROPERTY.href, v], true, setOptions); | |
continue; | |
} | |
// .tag BY NORMALIZED VALUE | |
if (k === 'tag') { | |
const o = typeof url === 'object' ? url : (await getItem(url, forActorUrl)); | |
if (!o?.name && !o?.nameMap) { continue; } | |
if (o?.nameMap) { | |
for (const l in o.nameMap) { mutation[op]([KV.PROPERTY, ulid, PROPERTY.href, o.nameMap[l]], true, setOptions); } | |
} else { | |
mutation[op]([KV.PROPERTY, ulid, PROPERTY.href, o.name], true, setOptions); | |
} | |
continue; | |
} | |
const oUlid = await getId(asId(url), forActorUrl); | |
mutation[op]([KV.PROPERTY, ulid, PROPERTY[k], oUlid], true, setOptions); | |
} | |
return mutation | |
} | |
for await (const k of oKeys) { | |
deletes[k] = []; | |
let arr = (as[k]||[]).map(asId); | |
if (currentO && currentO[k]) { | |
const [oSet, curSet] = [new Set(as[k]), new Set(currentO[k])]; | |
arr = arr.filter((u) => !curSet.has(u)); | |
deletes[k] = as[k].filter((u) => oSet.has(u)); | |
} | |
as[k] = arr; | |
if (!fromServer) { | |
await kvJoinASOp(as[k], k); | |
await kvJoinASOp(deletes[k], k, 'delete'); | |
} | |
if (PROPERTY[k] === PROPERTY.inReplyTo) { | |
// TODO set replies | |
for (const r of as[k]) { | |
const inReplyToUlid = await getId(asId(r)); | |
mutation.set([KV.REPLIES, inReplyToUlid, oId]); | |
} | |
for (const r of deletes[k]) { | |
const inReplyToUlid = await getId(asId(r)); | |
mutation.delete([KV.REPLIES, inReplyToUlid, oId]); | |
} | |
} | |
} | |
} | |
/* */ | |
async function whatWhereWhenStatus(o, ownerUrl) { | |
let [start, end, what] = [false as any, false as any, []]; | |
const where: string[] = []; | |
try { | |
if (o?.location) { | |
for await (const loc of o.location) { | |
const location = (typeof loc === 'string' | |
? ((await getItem(loc, ownerUrl))?.value||{}) | |
: (typeof loc === 'object' ? loc : {})); | |
const { latitude, longitude } = location||{}; | |
if (!latitude || !longitude) { continue; } | |
where.push(encode(latitude, longitude)); | |
} | |
} | |
} catch(e) {} | |
try { | |
if (o?.type) { | |
what = findKeyAndType(o).filter((o) => !!o?.key).map((o) => o.key); | |
} | |
} catch(e) {} | |
try { | |
if (o?.startTime) { start = Date.parse(o.startTime) } | |
if (o?.endTime) { | |
end = Date.parse(o.endTime); | |
if (!start && end) { start = end; } | |
} else if (start) { | |
end = start; | |
} | |
} catch(e) {} | |
return { what, where, start, end } | |
} | |
async function whatWhereWhen(o: O, status: Status, mutation: M, ownerUrl: string, currentO?: O, setOptions = {isServer: false}) { | |
const { ulid } = status; | |
const wwwStatus = await whatWhereWhenStatus(o, ownerUrl); | |
const curStatus = !currentO ? null : await whatWhereWhenStatus(currentO, ownerUrl); | |
const res = curStatus ? ({...wwwStatus, what: [], where: []} as any) : wwwStatus; | |
const deletes: any = {what: [], where: []}; | |
// const { what: curWhat, where: curWhere, start: curStart = false, end: curEnd = false} | |
// WHERE = 30, // where ordered by object-time // search: where | |
// WHERE_WHEN_WHAT = 33, // where ordered by when // search: where,when / when,where / where,when | |
// WHAT_WHERE_WHEN = 32, // what ordered by where // search: what / what,where / what,where,when | |
if (curStatus) { | |
const [whatSet, whatCurSet, whereSet, whereCurSet] = [ | |
new Set(wwwStatus.what||[]), new Set(curStatus?.what||[]), new Set(wwwStatus.where||[]), new Set(curStatus?.where||[]) | |
]; | |
const whats: string[] = wwwStatus.what; | |
res.what = whats.filter((u) => !(whatCurSet as any).has(u)); | |
deletes.what = curStatus.what.filter((u) => whatSet.has(u)); | |
const wheres = wwwStatus.where; | |
res.where = wheres.filter((u) => !(whereCurSet as any).has(u)); | |
deletes.where = curStatus.where.filter((u) => whereSet.has(u)); | |
} | |
const whereOp = (arr: string|O[], op: 'set'|'delete' = 'set') => { | |
for (const geohash of arr) { | |
mutation[op]([KV.WHERE, geohash, ulid], true, setOptions); | |
if (!what.length) { | |
mutation[op]([KV.WHERE_WHEN_WHAT, geohash, start, end, false, ulid], true, setOptions); | |
continue; | |
} | |
for (const shortcutType of res.what) { | |
mutation[op]([KV.WHERE_WHEN_WHAT, geohash, start, end, shortcutType, ulid], true, setOptions); | |
mutation[op]([KV.WHAT_WHERE_WHEN, shortcutType, geohash, start, end, ulid], true, setOptions); | |
} | |
} | |
} | |
const whatOp = (arr: string|O[], op: 'set'|'delete' = 'set') => { | |
for (const shortcutType of arr) { | |
mutation.set([KV.WHAT_WHERE_WHEN, shortcutType, false, start, end, ulid], true, setOptions); | |
} | |
} | |
const { what = [], where = [], start = false, end = false } = res; | |
if (!!where?.length) { | |
whereOp(where); | |
whereOp(deletes.where, 'delete'); | |
} else if (!!what?.length) { | |
whatOp(what); | |
whatOp(deletes.where, 'delete'); | |
} | |
// WHEN_WHAT = 31, // when ordered by object-time // search: when | |
if (start) { | |
if (!what?.length) { | |
mutation.set([KV.WHEN_WHAT, start, end, false, ulid], true, setOptions); | |
return; | |
} | |
for (const shortcutType of what) { | |
mutation.set([KV.WHEN_WHAT, start, end, shortcutType, ulid], true, setOptions); | |
} | |
} | |
} | |
/* */ | |
async function setClientReaction(activityType: A, activity: O, status: Status, scope?: string) { | |
if (!activity?.object || !activity.object.length) { return } | |
const { ownerUlid, isWait } = status; | |
const ownerUrl = await getUrl(ownerUlid); | |
if (typeof scope === 'string') { status[STATUS.Scope] = scope; } | |
const expire = isWait ? MILLISECONDS.YEAR : undefined; | |
const mutation = await patch( | |
shrinkToId(activity, baseUrl), status, (await getUrl(ownerUlid)), activityType, false, activity.object, expire | |
); | |
return await commitAction(activity, status, mutation, ownerUrl); | |
} | |
function setClientReactionFactory(activityType: A, scope?: string) { | |
return (async (activity, status) => | |
(await setClientReaction(activityType, activity, status, scope))) | |
} | |
/* | |
TODO | |
This is for demo only and can be use to create previews for any href automatically. | |
via import { hrefToAs } from '@/API/Link/index.ts'; | |
example: | |
if (hasAnyType(o,'Link') && o?.href && !o?.preview) { await getOrSetDefaultPreviewUlid(o); } | |
> and o has now .preview which is the id of the kv persisted preview | |
*/ | |
async function getOrSetDefaultPreviewUlid(o) { | |
const HREFKEY = (hrefToHrefKey(o.href)||[]) | |
const cached = (await kv.get([KV.HREF, ...HREFKEY]))?.value; | |
if (cached?.length > 1) { | |
o.preview = cached[1]; | |
return o; | |
} | |
const {preview, ...linkBase} = await hrefToAs(o); | |
preview.attributedTo = ['Public']; | |
if (typeof preview === 'object' && Object.keys(preview).length) { | |
const oId = (o?.published ? setUlid(Date.parse(o.published)) : monotonicUlid()); | |
const status = await setStatus(SCOPESTATUS.ACTIVE, o, 'Object', '', oId); | |
preview.id = `${baseUrl}_/o/${oId}`; | |
const mutation = patchMutation(createPatch(oId, preview, false)); | |
mutation.check({ key: [KV.HREF, ...HREFKEY], versionstamp: null }); | |
await kvText(preview, status.value, mutation, false); | |
if (Object.keys(o).filter((k) => JOINKEY[k]).length) { | |
await kvJoinAS(o, status.value, mutation, '/'); | |
} | |
// setWithStatus(o.href, oId, status ... | |
const [idKey, oKey] = [[KV.HREF, ...HREFKEY], [KV.ULID, oId]]; | |
mutation.set(idKey, status.value.value).set(oKey, o.href); | |
const res = await mutation.commit(); | |
if (!res.ok) { throw new Error(`Failed to create preview for href ${o?.href}`); } | |
o.preview = preview.id; | |
} | |
return o; | |
} | |
async function updateStatusActors(oStatus: Status, o: O, actors: any = {}): Promise<Status> { | |
const uStatus: Status = {...oStatus}; | |
const [_owner, ...owningActorUrls] = o.attributedTo.map(asId); | |
if (owningActorUrls?.length) { | |
for await (const acctUrl of owningActorUrls) { | |
if (actors[acctUrl]) { continue; } | |
actors[acctUrl] = await getId(acctUrl, acctUrl); | |
} | |
} | |
if (Array.isArray(uStatus.actorUlids)) { | |
uStatus.actorUlids = {...uStatus.actorUlids, ...getOwningActors(owningActorUrls, actors)}; | |
} | |
return uStatus; | |
} | |
async function addRemove(as, status, actors, type: 'add'|'remove' = 'add', doStoreActivity = true, fromServer = false) { | |
const { object = [], origin = [], target = [], ...activity } = as; | |
const [upperType, opArray] = [type === 'add' ? 'Add' : 'Remove', type === 'add' ? target : origin]; | |
if (!Array.isArray(opArray) || !opArray.length) { | |
throw new Error(`Failed to ${type}: No ${type === 'add' ? 'target' : 'origin'} found for ${activity?.id}`); | |
} | |
const [ aId, ownerUlid ] = status; | |
const ownerUrl = await getUrl(ownerUlid); | |
const mutation = doStoreActivity | |
? (fromServer ? kv.atomic() : await patch(shrinkToId(as, baseUrl), status, ownerUrl)) | |
: null; | |
for await (const o of object) { | |
// check that all objects exist and are allowed | |
const oStatus = await definitionCheck(mutation, o, ownerUrl, ownerUlid, actors); | |
if (!isValidUlid(oStatus[STATUS.Ulid])) { | |
throw new Deno.errors.NotSupported(`${upperType} object not found for url ${asId(o)}`); | |
} | |
await setObjectActivity(o, status, mutation, ownerUrl, (type === 'add' ? ACTIVITY.Add : ACTIVITY.Remove)) | |
} | |
for await (const t of opArray) { | |
const tStatus = await definitionCheck(mutation, t, ownerUrl, ownerUlid, actors); | |
const [ tId, tOwnerUlid, scope, versionUlid ] = status; | |
if (!isValidUlid(tStatus[STATUS.Ulid])) { | |
throw new Deno.errors.NotSupported(`${upperType} target not found for url ${asId(t)}`); | |
} | |
const keys = [[tId, KVKEY.items, versionUlid, KVKEY[type], object]].reduce(reduceToChunks, []); | |
for (const kvKeys of keys) { mutation.set(kvKeys, !!kvKeys[4]) } | |
} | |
if (doStoreActivity) { | |
const res = await mutation.commit(); | |
if (!res.ok) { throw new Error(`Failed to ${type}: ${activity?.id}`); } | |
} | |
return await commitAction(as, status, mutation, ownerUrl); | |
} | |
async function commitAction(as: O, status: DEF, mutation: M, ownerUrl: string, activityName = 'Create') { | |
if (Object.keys(as).filter((k) => ADR[k]).length) { await kvTo(as, status, mutation, ownerUrl); } | |
const res = await mutation.commit(); | |
if (!res.ok) throw new Error(`Failed to set ${activityName} activity in outbox`); | |
return as; | |
} | |
// not needed usually, is on top either in Fedify Dispatcher or Outbox/Inbox | |
async function isHandled(as, status, fromServer = false) { | |
if (fromServer) { | |
if (as?.id.startsWith(baseUrl) || (status[STATUS.OwnerHost] && status[STATUS.OwnerHost].startsWith(baseUrl))) { | |
return true; | |
} | |
} | |
return !!(await kv.get(hrefToHrefKey(as.id)))?.value | |
} | |
/* | |
"type": "Person", | |
"id": "https://kenzoishii.example.com/", | |
"following": "https://kenzoishii.example.com/following.json", | |
"followers": "https://kenzoishii.example.com/followers.json", | |
"liked": "https://kenzoishii.example.com/liked.json", | |
"inbox": "https://kenzoishii.example.com/inbox.json", | |
"outbox": "https://kenzoishii.example.com/feed.json", | |
"preferredUsername": "kenzoishii", | |
"name": "石井健蔵", | |
"summary": "この方はただの例です", | |
"icon": [ | |
"https://kenzoishii.example.com/image/165987aklre4" | |
] | |
streams | |
A list of supplementary Collections which may be of interest. | |
preferredUsername | |
A short username which may be used to refer to the actor, with no uniqueness guarantees. | |
endpoints | |
A json object which maps additional (typically server/domain-wide) endpoints which may be useful either | |
for this actor or someone referencing this actor. | |
This mapping may be nested inside the actor document as the value or may be a link to a JSON-LD document | |
with these properties. | |
The endpoints mapping MAY include the following properties: | |
sharedInbox | |
proxyUrl | |
oauthAuthorizationEndpoint | |
oauthTokenEndpoint | |
provideClientKey | |
signClientKey | |
alsoKnownAs | |
*/ | |
const ActivityFn: AsFnDef = { | |
fromClient: { // for Outbox | |
/* Create | |
When a Create activity is posted, the actor of the activity SHOULD be copied onto the object's attributedTo field. | |
A mismatch between addressing of the Create activity and its object is likely to lead to confusion. | |
As such, a server SHOULD copy any recipients of the Create activity to its object upon initial distribution, | |
and likewise with copying recipients from the object to the wrapping Create activity. | |
Note that it is acceptable for the object's addressing to be changed later without changing the Create's addressing | |
(for example via an Update activity). | |
Any to, bto, cc, bcc, and audience properties specified on the object MUST be copied over | |
to the new Create activity by the server. | |
*/ | |
Create: async (as, status, actors) => { | |
const { object = [], ...activity } = as; | |
const [ aId, ownerUlid ] = status; | |
const activityActors = {...actors}; | |
const ownerUrl = await getUrl(ownerUlid); | |
const mutation = await patch(shrinkToId(as, baseUrl), status, ownerUrl); | |
for await (const o of object) { | |
o.id = null; | |
o.published = new Date(decodeTime(aId)).toISOString(); | |
// Object id // TODO /${actorHandle} -+ | |
// {id: `${base}/${ownerUlid}/${urlDelimiter.outbox}/${ulid}`, ownerUlid, ulid } | |
const { id, ulid: oId } = createId(ownerUlid); | |
const oStatus = await setStatus(SCOPESTATUS.ACTIVE, o, 'Object', ownerUlid, oId); | |
// Adressing as spec. above | |
// addressing // ADR = { to:2, cc:2, audience:1, bto:3, bcc:3 }; | |
// When a Create activity is posted, the actor of the activity SHOULD be copied onto the object's attributedTo field: | |
if (o && !o?.attributedTo) { o.attributedTo = []; } | |
if (o.attributedTo?.length) { | |
for await (const acctUrl of o.attributedTo) { | |
if (actors[acctUrl]) { continue; } | |
actors[acctUrl] = await getId(acctUrl, acctUrl); | |
} | |
} | |
o.attributedTo = Array.from(new Set([...Object.keys(activityActors), ...o.attributedTo.map(asId)])); | |
const [_owner, ...owningActorUrls] = o.attributedTo||[]; | |
oStatus.push(Array.from(new Set(owningActorUrls))); | |
const oMutation = await patchObject(o, oStatus, ownerUrl, as, status); | |
const res = await oMutation.commit(); | |
if (!res.ok) throw new Error(`Failed to create object in outbox ${o.id}`); | |
// TODO filter out blocked recipients by sender ? | |
// spec. says : | |
/* **Clients** are responsible for addressing new Activities appropriately. | |
Clients SHOULD look at any objects attached to the new Activity via the object, target, inReplyTo | |
and/or tag fields, retrieve their actor or attributedTo properties, and MAY also retrieve their | |
addressing properties, and add these to the to or cc fields of the new Activity being created. | |
Clients MAY recurse through attached objects, but if doing so, SHOULD set a limit for this recursion. | |
In redaktor we intend to Create the reversed array of objects (inner to outer) where the length limit is 400. | |
*/ | |
} | |
return await commitAction(as, status, mutation, ownerUrl); | |
/* TODO : url: Link-cache like TextJoin */ | |
}, | |
/* Update | |
For client to server interactions, updates are partial; rather than updating the document all at once, | |
any key value pair supplied is used to replace the existing value with the new value. | |
This only applies to the top-level fields of the updated object. | |
A special exception is for when the value is the json null type; | |
this means that this field should be removed from the server's representation of the object. | |
Note that this behavior is for client to server interaction only | |
*/ | |
Update: async (as, status, actors) => { | |
const { object = [], ...activity } = as; | |
const [ aId, ownerUlid ] = status; | |
const ownerUrl = await getUrl(ownerUlid); | |
const mutation = await patch(shrinkToId(as, baseUrl), status, ownerUrl, ACTIVITY.Update); | |
for await (const o of object) { | |
// check that all objects exist and are allowed | |
const oStatus = await definitionCheck(mutation, o, ownerUrl, ownerUlid, actors); | |
const original = await getItem(asId(o), ownerUrl); | |
if (!original || !isValidUlid(oStatus[STATUS.Ulid])) { | |
throw new Deno.errors.NotSupported(`Update Object not found for url ${asId(o)}`); | |
} | |
const uStatus: DEF = await updateStatusActors(oStatus, o, actors); | |
o.updated = new Date(decodeTime(aId)).toISOString(); | |
const oMutation = await patchObject(o, uStatus, ownerUrl, as, status, false, original); | |
const res = await oMutation.commit(); | |
if (!res.ok) throw new Error(`Failed to update object in outbox ${o.id}`); | |
} | |
return await commitAction(as, status, mutation, ownerUrl); | |
}, | |
/* Delete | |
The Delete activity is used to delete an already existing object. The side effect of this is that the server MAY | |
replace the object with a Tombstone of the object that will be displayed in activities which reference the | |
deleted object. | |
If the deleted object is requested the server SHOULD respond with [either the HTTP 410 Gone status code] | |
[here] a Tombstone object presented as the response body | |
*/ | |
Delete: async (as, status, actors) => { | |
const { object = [], ...activity } = as; | |
const [ aId, ownerUlid ] = status; | |
const ownerUrl = await getUrl(ownerUlid); | |
const mutation = await patch(shrinkToId(as, baseUrl), status, ownerUrl, ACTIVITY.Delete); | |
for await (const o of object) { | |
// check that all objects exist and are allowed | |
const oStatus = await definitionCheck(mutation, o, ownerUrl, ownerUlid, actors); | |
if (!isValidUlid(oStatus[STATUS.Ulid])) { | |
throw new Deno.errors.NotSupported(`Delete Object not found for url ${asId(o)}`); | |
} | |
const {published = '', updated = ''} = o; | |
const tombstone: O = { id: o.id, type: ['Tombstone'] }; | |
if (published) { tombstone.published = published }; | |
if (updated) { tombstone.updated = updated }; | |
tombstone.deleted = new Date(decodeTime(aId)); | |
const oId = oStatus[STATUS.Ulid]; | |
const original = await getItem(asId(o), ownerUrl); | |
if (!original || !oId) { continue; } | |
const oMutation = await patchObject(tombstone, oStatus, ownerUrl, as, status, false, original); | |
const res = await oMutation.commit(); | |
if (!res.ok) throw new Error(`Failed to delete object in outbox ${o.id}`); | |
} | |
return await commitAction(as, status, mutation, ownerUrl); | |
}, | |
/* Undo | |
The Undo activity is used to undo a previous activity. See the Activity Vocabulary documentation on Inverse Activities | |
and "Undo". | |
For example, Undo may be used to undo a previous Like, Follow, or Block. | |
The undo activity and the activity being undone MUST both have the same actor. | |
Side effects should be undone, to the extent possible. For example, if undoing a Like, | |
any counter that had been incremented previously should be decremented appropriately. | |
There are some exceptions where there is an existing and explicit "inverse activity" which should be used instead. | |
Create based activities should instead use Delete, and Add activities should use Remove. | |
*/ | |
Undo: async (as, status, actors) => { | |
const { object = [], ...activity } = as; | |
const [ aId, ownerUlid ] = status; | |
const ownerUrl = await getUrl(ownerUlid); | |
const mutation = kv.atomic(); | |
for await (const undoActivityId of object) { | |
// check that all objects exist and are allowed | |
const undoStatus = await definitionCheck(mutation, undoActivityId, ownerUrl, ownerUlid, actors); | |
if (!isValidUlid(undoStatus[STATUS.Ulid])) { | |
throw new Deno.errors.NotSupported(`Undo Activity not found for url ${asId(undoActivityId)}`); | |
} | |
const undoId = undoStatus[STATUS.Ulid]; | |
const undoActivity = await getItem(asId(undoActivityId), ownerUrl); | |
const versions = await getVersionIds(undoId); | |
let [hasVersion, versionUlid] = [false, '']; | |
for (const vId of versions) { | |
if (hasVersion) { | |
versionUlid = vId; | |
break; | |
} | |
hasVersion = (vId === undoId); | |
} | |
if (!hasVersion || !isValidUlid(versionUlid)) { | |
throw new Deno.errors.NotSupported(`Undo Activity not found for ${undoActivity.id}`); | |
} | |
// TODO : intransitive undoActivity has no .object | |
if (undoActivity?.object) { | |
for await (const o of undoActivity.object) { | |
const oStatus = await definitionCheck(mutation, o, ownerUrl, ownerUlid, actors); | |
oStatus[STATUS.VersionId] = versionUlid; | |
const original = await getItem(asId(o), ownerUrl); | |
const update = await getItem(asId(o), ownerUrl, true, false, versionUlid); | |
const uStatus: DEF = await updateStatusActors(oStatus, o, actors); | |
const oMutation = await patchObject(update, uStatus, ownerUrl, as, status, false, original); | |
const res = await oMutation.commit(); | |
if (!res.ok) throw new Error(`Failed to do undo activity in outbox ${o.id}`); | |
} | |
} | |
} | |
setWithStatus(aId, aId, status, mutation, ownerUlid, ACTIVITY.Undo); | |
return await commitAction(as, status, mutation, ownerUrl); | |
}, | |
/* Add | |
Upon receipt of an Add activity into the outbox, the server SHOULD add the object to the collection specified | |
in the target property, unless: | |
- the target is not owned by the receiving server, and thus they are not authorized to update it. | |
- the object is not allowed to be added to the target collection for some other reason, | |
at the receiving server's discretion. | |
*/ | |
Add: async (as, status, actors) => (await addRemove(as, status, actors)), | |
/* Remove | |
Upon receipt of a Remove activity into the outbox, the server SHOULD remove the object from the collection specified | |
in the target property, unless [same constraints like Add] | |
*/ | |
Remove: async (as, status, actors) => (await addRemove(as, status, actors, 'remove')), | |
/* Move | |
Indicates that the actor has moved object from origin to target. | |
If the origin or target are not specified, either can be determined by context. | |
we do not use move for JSON Patch <-> kv ... | |
*/ | |
Move: async (as, status, actors) => { | |
const { object = [], ...activity } = as; | |
const [ aId, ownerUlid ] = status; | |
const ownerUrl = await getUrl(ownerUlid); | |
const mutation = await patch(shrinkToId(as, baseUrl), status, ownerUrl); | |
await addRemove(as, status, actors, 'add', false); | |
await addRemove(as, status, actors, 'remove', false); | |
return await commitAction(as, status, mutation, ownerUrl); | |
}, | |
/* Block | |
The Block activity is used to indicate that the posting actor does not want another actor (defined in object property) | |
to be able to interact with objects posted by the actor posting the Block activity. | |
The server SHOULD prevent the blocked user from interacting with any object posted by the actor. | |
Servers SHOULD NOT deliver Block Activities to their object. | |
*/ | |
Block: async (asOrHost: O|string|string[], status, actors) => { | |
if (!Array.isArray(asOrHost) && typeof asOrHost !== 'string') { | |
return (await setClientReaction(ACTIVITY.Block, asOrHost, status)) | |
} | |
const isIdObject = !Array.isArray(asOrHost) && typeof asOrHost === 'object' && (asOrHost as O)?.id; | |
// server wide domain block | |
const object = Array.isArray(asOrHost) ? asOrHost : [asOrHost]; | |
const [ aId, ownerUlid ] = status; | |
const ownerUrl = await getUrl(ownerUlid); | |
const mutation = isIdObject | |
? (await patch(shrinkToId(asOrHost, baseUrl), status, ownerUrl, ACTIVITY.Update)) | |
: kv.atomic(); | |
for await (const s of object) { await setObjectActivity(s, status, mutation, ownerUrl, ACTIVITY.Block); } | |
setWithStatus(aId, aId, status, mutation, ownerUlid, ACTIVITY.Block); | |
const res = await mutation.commit(); | |
if (!res.ok) throw new Error(`Failed to set Block activity in outbox. Activity: ${aId}`); | |
return {object}; | |
}, | |
/* Follow | |
The Follow activity is used to subscribe to the activities of another actor. | |
The side effect of receiving this in an outbox is that the server SHOULD add the object to the actor's | |
following Collection when and only if an Accept activity is subsequently received with | |
this Follow activity as its object. | |
*/ | |
Follow: setClientReactionFactory(ACTIVITY.Follow, SCOPE.WAIT_FOR_ACCEPT), | |
/* Like | |
The side effect of receiving this in an outbox is that the server SHOULD add the object to the actor's liked Collection. | |
*/ | |
Like: setClientReactionFactory(ACTIVITY.Like), | |
// similar ... | |
Dislike: setClientReactionFactory(ACTIVITY.Dislike), | |
Accept: setClientReactionFactory(ACTIVITY.Accept), | |
TentativeAccept: setClientReactionFactory(ACTIVITY.TentativeAccept), | |
Reject: setClientReactionFactory(ACTIVITY.Reject), | |
TentativeReject: setClientReactionFactory(ACTIVITY.TentativeReject), | |
Listen: setClientReactionFactory(ACTIVITY.Listen), | |
View: setClientReactionFactory(ACTIVITY.View), | |
Read: setClientReactionFactory(ACTIVITY.Read), | |
/* Flag | |
Indicates that the actor is "flagging" the object. | |
Flagging is defined in the sense common to many social platforms as | |
reporting content as being inappropriate for any number of reasons. | |
*/ | |
Flag: setClientReactionFactory(ACTIVITY.Flag), | |
/*Ignore | |
Indicates that the actor is ignoring the object. | |
*/ | |
Ignore: setClientReactionFactory(ACTIVITY.Ignore), | |
/* Offer | |
Indicates that the actor is offering the object. | |
If specified, the target indicates the entity to which the object is being offered.` | |
*/ | |
Offer: setClientReactionFactory(ACTIVITY.Offer), | |
/* Invite | |
A specialization of Offer in which the actor is extending an | |
invitation for the object to the target. | |
*/ | |
Invite: setClientReactionFactory(ACTIVITY.Invite), | |
/* Join | |
Indicates that the actor has joined the object. | |
The target and origin typically have no defined meaning. | |
*/ | |
Join: setClientReactionFactory(ACTIVITY.Join), | |
/* Leave | |
Indicates that the actor has left the object. | |
The target and origin typically have no meaning. | |
*/ | |
Leave: setClientReactionFactory(ACTIVITY.Leave), | |
/* Announce (aka 'share') | |
Indicates that the actor is calling the target's attention the object. | |
The origin typically has no defined meaning. | |
*/ | |
Announce: setClientReactionFactory(ACTIVITY.Announce), | |
/* | |
TODO ! | |
$_: 'IntransitiveActivity', | |
$_q: 'Question', | |
$_t: 'Travel', | |
$_a: 'Arrive', | |
*/ | |
}, | |
fromServer: { | |
/* | |
Note: Activities being distributed between actors on the same origin may use any internal mechanism, | |
and are not required to use HTTP. | |
*/ | |
/* Create | |
Receiving a Create activity in an inbox has surprisingly few side effects; | |
the activity should appear in the actor's inbox and it is likely that the server | |
will want to locally store a representation of this activity and its accompanying object. | |
However, this mostly happens in general with processing activities delivered to an inbox anyway. | |
*/ | |
Create: async (as, status, actors) => { | |
/* patchObject(o: O, oStatus: DEF, ownerUrl: string, activity: O, aStatus?: DEF, fromServer = false, original?: O) */ | |
const { object = [] } = as; | |
const [ aId, ownerUlid ] = status; | |
const ownerUrl = await getUrl(ownerUlid); | |
for await (const o of object) { | |
const objectExists = !o.id ? true : getWithStatus(o.id, false, true); | |
if (objectExists) { continue; } /* TODO what todo */ | |
if (!o?.published) { o.published = new Date(decodeTime(aId)).toISOString(); } | |
const { id, ulid: oId } = createId(ownerUlid); | |
const oStatus = initialStatus(oId, ownerUlid, status); | |
const oMutation = await patchObject(o, oStatus, ownerUrl, as, status, true); | |
setWithStatus(o.id, monotonicUlid(), status, oMutation, ownerUlid, ACTIVITY.Create); | |
} | |
return await commitAction(as, status, kv.atomic(), ownerUrl); | |
}, | |
/* Update | |
For server to server interactions, an Update activity means that the receiving server SHOULD update | |
its copy of the object of the same id to the copy supplied in the Update activity. | |
Unlike the client to server handling of the Update activity, this is not a partial update but | |
a complete replacement of the object. | |
The receiving server MUST take care to be sure that the Update is authorized to modify its object. | |
At minimum, this may be done by ensuring that the Update and its object are of same origin. | |
*/ | |
Update: async (as, status, actors) => { | |
const { object = [], ...activity } = as; | |
const [ aId, ownerUlid ] = status; | |
const ownerUrl = await getUrl(ownerUlid); | |
const mutation = kv.atomic(); | |
for await (const o of object) { | |
// check that all objects exist and are allowed | |
const oStatus = await definitionCheck(mutation, o, ownerUrl, ownerUlid, actors); | |
const original = await getItem(asId(o), ownerUrl); | |
if (!original || !isValidUlid(oStatus[STATUS.Ulid])) { | |
throw new Deno.errors.NotSupported(`Update Object not found for url ${asId(o)}`); | |
} | |
const uStatus: DEF = await updateStatusActors(oStatus, o, actors); | |
o.updated = new Date(decodeTime(aId)).toISOString(); | |
const uMutation = await patchObject(o, uStatus, ownerUrl, as, status, true, original); | |
setWithStatus(o.id, monotonicUlid(), status, uMutation, ownerUlid, ACTIVITY.Update); | |
const res = await uMutation.commit(); | |
if (!res.ok) throw new Error(`Failed to update object in outbox ${o.id}`); | |
} | |
return await commitAction(as, status, kv.atomic(), ownerUrl); | |
}, | |
/* Delete | |
The side effect of receiving this is that (assuming the object is owned by the sending actor / server) | |
the server receiving the delete activity SHOULD remove its representation of the object with the same id, | |
and MAY replace that representation with a Tombstone object. | |
(Note that after an activity has been transmitted from an origin server to a remote server, | |
there is nothing in the ActivityPub protocol that can enforce remote deletion of an object's representation). | |
*/ | |
Delete: async (as, status, actors) => { | |
/* if (as?.id && !as.id.startsWith(baseUrl)) {} */ | |
const { object = [], ...activity } = as; | |
const [ aId, ownerUlid ] = status; | |
const ownerUrl = await getUrl(ownerUlid); | |
const mutation = kv.atomic(); | |
for await (const o of object) { | |
// check that all objects exist and are allowed | |
const oStatus = await definitionCheck(mutation, o, ownerUrl, ownerUlid, actors); | |
if (!isValidUlid(oStatus[STATUS.Ulid])) { | |
throw new Deno.errors.NotSupported(`Delete Object not found for url ${asId(o)}`); | |
} | |
const {published = '', updated = ''} = o; | |
const tombstone: O = { id: o.id, type: ['Tombstone'] }; | |
if (published) { tombstone.published = published }; | |
if (updated) { tombstone.updated = updated }; | |
tombstone.deleted = new Date(decodeTime(aId)); | |
const oId = oStatus[STATUS.Ulid]; | |
const original = await getItem(asId(o), ownerUrl); | |
if (!original || !oId) { continue; } | |
const oMutation = await patchObject(tombstone, oStatus, ownerUrl, as, status, false, original); | |
setWithStatus(o.id, monotonicUlid(), status, oMutation, ownerUlid, ACTIVITY.Delete); | |
const res = await oMutation.commit(); | |
if (!res.ok) throw new Error(`Failed to delete object in outbox ${o.id}`); | |
} | |
return await commitAction(as, status, mutation, ownerUrl); | |
}, | |
/* Undo | |
The Undo activity is used to undo the side effects of previous activities. | |
See the ActivityStreams documentation on Inverse Activities and "Undo". | |
The scope and restrictions of the Undo activity are the same as for the Undo activity in the context | |
of client to server interactions, but applied to a federated context. | |
*/ | |
Undo: async (as, status, actors) => { | |
const { object = [], ...activity } = as; | |
const [ aId, ownerUlid ] = status; | |
const ownerUrl = await getUrl(ownerUlid); | |
const mutation = kv.atomic(); | |
for await (const undoActivityId of object) { | |
// check that all objects exist and are allowed | |
const undoStatus = await definitionCheck(mutation, undoActivityId, ownerUrl, ownerUlid, actors); | |
if (!isValidUlid(undoStatus[STATUS.Ulid])) { | |
throw new Deno.errors.NotSupported(`Undo Activity not found for url ${asId(undoActivityId)}`); | |
} | |
const undoId = undoStatus[STATUS.Ulid]; | |
const undoActivity = await getItem(asId(undoActivityId), ownerUrl); | |
const versions = await getVersionIds(undoId); | |
let [hasVersion, versionUlid] = [false, '']; | |
for (const vId of versions) { | |
if (hasVersion) { | |
versionUlid = vId; | |
break; | |
} | |
hasVersion = (vId === undoId); | |
} | |
if (!hasVersion || !isValidUlid(versionUlid)) { | |
throw new Deno.errors.NotSupported(`Undo Activity not found for ${undoActivity.id}`); | |
} | |
// TODO : intransitive undoActivity has no .object | |
if (undoActivity?.object) { | |
for await (const o of undoActivity.object) { | |
const oStatus = await definitionCheck(mutation, o, ownerUrl, ownerUlid, actors); | |
oStatus[STATUS.VersionId] = versionUlid; | |
const original = await getItem(asId(o), ownerUrl); | |
const update = await getItem(asId(o), ownerUrl, true, false, versionUlid); | |
const uStatus: DEF = await updateStatusActors(oStatus, o, actors); | |
const oMutation = await patchObject(update, uStatus, ownerUrl, as, status, false, original); | |
const res = await oMutation.commit(); | |
if (!res.ok) throw new Error(`Failed to do undo activity in inbox ${o.id}`); | |
} | |
} | |
} | |
setWithStatus(aId, aId, status, mutation, ownerUlid, ACTIVITY.Undo); | |
return await commitAction(as, status, mutation, ownerUrl); | |
}, | |
/* Add | |
Upon receipt of an Add activity into the inbox, the server SHOULD add the object to the collection specified | |
in the target property, unless: | |
the target is not owned by the receiving server, and thus they can't update it. | |
the object is not allowed to be added to the target collection for some other reason, at the receiver's discretion. | |
*/ | |
Add: async (as, status, actors) => (await addRemove(as, status, actors, 'add', true, true)), | |
/* Remove | |
Upon receipt of a Remove activity into the inbox, the server SHOULD remove the object from the collection specified | |
in the target property, unless: | |
the target is not owned by the receiving server, and thus they can't update it. | |
the object is not allowed to be removed to the target collection for some other reason, at the receiver's discretion. | |
*/ | |
Remove: async (as, status, actors) => (await addRemove(as, status, actors, 'remove', true, true)), | |
/* Move | |
Remove / Add | |
*/ | |
Move: async (as, status, actors) => { | |
const { object = [], ...activity } = as; | |
const [ aId, ownerUlid ] = status; | |
const ownerUrl = await getUrl(ownerUlid); | |
const mutation = kv.atomic(); | |
await addRemove(as, status, actors, 'add', false, true); | |
await addRemove(as, status, actors, 'remove', false, true); | |
return await commitAction(as, status, mutation, ownerUrl); | |
}, | |
/* Block | |
The Block activity is used to indicate that the posting actor does not want another actor (defined in object property) | |
to be able to interact with objects posted by the actor posting the Block activity. | |
The server SHOULD prevent the blocked user from interacting with any object posted by the actor. | |
Servers SHOULD NOT deliver Block Activities to their object. | |
*/ | |
Block: async (asOrHost: O|string|string[], status, actors) => { | |
if (!Array.isArray(asOrHost) && typeof asOrHost !== 'string') { | |
return (await setClientReaction(ACTIVITY.Block, asOrHost, status)) | |
} | |
// server wide domain block | |
const object = Array.isArray(asOrHost) ? asOrHost : [asOrHost]; | |
const [ aId, ownerUlid ] = status; | |
const ownerUrl = await getUrl(ownerUlid); | |
const mutation = kv.atomic(); | |
for await (const s of object) { await setObjectActivity(s, status, mutation, ownerUrl, ACTIVITY.Block); } | |
setWithStatus(aId, aId, status, mutation, ownerUlid, ACTIVITY.Block); | |
const res = await mutation.commit(); | |
if (!res.ok) throw new Error(`Failed to set Block activity in outbox. Activity: ${aId}`); | |
return {object}; | |
}, | |
/* Follow | |
The Follow activity is used to subscribe to the activities of another actor. | |
The side effect of receiving this in an inbox is that the server SHOULD generate either an | |
Accept or Reject activity with the Follow as the object and deliver it to the actor of the Follow. | |
The Accept or Reject MAY be generated automatically, or MAY be the result of user input | |
(possibly after some delay in which the user reviews). | |
Servers MAY choose to not explicitly send a Reject in response to a Follow, | |
though implementors ought to be aware that the server sending the request could be left | |
in an intermediate state. For example, a server might not send a Reject to protect a user's privacy. | |
In the case of receiving an Accept referencing this Follow as the object, | |
the server SHOULD add the actor to the object actor's Followers Collection. | |
In the case of a Reject, | |
the server MUST NOT add the actor to the object actor's Followers Collection. | |
*/ | |
Follow: setClientReactionFactory(ACTIVITY.Follow, SCOPE.WAIT_FOR_ACCEPT), | |
/* Like | |
The side effect of receiving this in an inbox is that the server SHOULD increment the object's | |
count of likes by adding the received activity to the likes collection if this collection is present. | |
*/ | |
Like: setClientReactionFactory(ACTIVITY.Like), | |
// similar ... | |
Dislike: setClientReactionFactory(ACTIVITY.Dislike), | |
Accept: setClientReactionFactory(ACTIVITY.Accept), | |
TentativeAccept: setClientReactionFactory(ACTIVITY.TentativeAccept), | |
Reject: setClientReactionFactory(ACTIVITY.Reject), | |
TentativeReject: setClientReactionFactory(ACTIVITY.TentativeReject), | |
Listen: setClientReactionFactory(ACTIVITY.Listen), | |
View: setClientReactionFactory(ACTIVITY.View), | |
Read: setClientReactionFactory(ACTIVITY.Read), | |
/* Flag | |
Indicates that the actor is "flagging" the object. | |
Flagging is defined in the sense common to many social platforms as | |
reporting content as being inappropriate for any number of reasons. | |
*/ | |
Flag: setClientReactionFactory(ACTIVITY.Flag), | |
/*Ignore | |
Indicates that the actor is ignoring the object. | |
*/ | |
Ignore: setClientReactionFactory(ACTIVITY.Ignore), | |
/* Offer | |
Indicates that the actor is offering the object. | |
If specified, the target indicates the entity to which the object is being offered.` | |
*/ | |
Offer: setClientReactionFactory(ACTIVITY.Offer), | |
/* Invite | |
A specialization of Offer in which the actor is extending an | |
invitation for the object to the target. | |
*/ | |
Invite: setClientReactionFactory(ACTIVITY.Invite), | |
/* Join | |
Indicates that the actor has joined the object. | |
The target and origin typically have no defined meaning. | |
*/ | |
Join: setClientReactionFactory(ACTIVITY.Join), | |
/* Leave | |
Indicates that the actor has left the object. | |
The target and origin typically have no meaning. | |
*/ | |
Leave: setClientReactionFactory(ACTIVITY.Leave), | |
/* Announce (aka 'share') | |
Indicates that the actor is calling the target's attention the object. | |
The origin typically has no defined meaning. | |
Upon receipt of an Announce activity in an inbox, a server SHOULD | |
increment the object's count of shares by adding the received activity | |
to the shares collection if this collection is present. | |
*/ | |
Announce: setClientReactionFactory(ACTIVITY.Announce), | |
/* | |
TODO ! | |
$_: 'IntransitiveActivity', | |
$_q: 'Question', | |
$_t: 'Travel', | |
$_a: 'Arrive', | |
*/ | |
} | |
} | |
// TODO see auth | |
export const getUserByConfirmation = async (): PromiseAs => {} | |
export const getUserByActor = async (actorUrlOrHandle: string): PromiseAs => { /*evt. getActorByHandle*/ } | |
export const listUser = async (): PromiseAs => {} | |
export const listUserByLanguage = async (): PromiseAs => {} | |
// Collection utilities | |
export const setCollection = async (item): PromiseAs => {} | |
export const updateCollection = async (item): PromiseAs => {} | |
export const getOrSetCollection = async (item): PromiseAs => {} | |
export const getCollection = async (item): PromiseAs => {} | |
// TODO sharedInbox | |
export const getOutbox = async (forActorUrl, inclTombstone = true): PromiseAs => {} | |
export const getInbox = async (forActorUrl, inclTombstone = true): PromiseAs => {} | |
// Activity | |
export const listActivitiesForObject = async (): PromiseAs => {} | |
// Object | |
export const what = async (shortType): PromiseAs => {} | |
export const when = async (geohash): PromiseAs => {} | |
export const where = async (startTime, endTime): PromiseAs => {} | |
export const listObjectByDetails = async (shortType, geohash, startTime, endTime): PromiseAs => {} | |
const activityOrder = { | |
noMeaning: [ | |
['Create','Delete'], ['Like','Dislike'], ['Join','Leave'], ['Add','Remove'], | |
['Accept','Reject'], ['Accept','TentativeReject'], ['Reject','TentativeAccept'] | |
], | |
asExclusiveActivity: [ 'Undo', 'Block' ], | |
asActivityOrder: [ | |
'Remove', 'Delete', 'Create', 'Update', 'Add', 'Move', 'Invite', 'Offer', 'Dislike', 'Like', | |
'Accept', 'TentativeAccept', 'Reject', 'TentativeReject', 'Listen', 'View', 'Read', | |
'Leave', 'Join', 'redaktor:Bookmark', 'Announce' | |
] | |
}; | |
function getActivityOrder(as:O) { | |
const exclusive = (as.type||[]).filter((t) => activityOrder.asExclusiveActivity.indexOf(t) > -1); | |
return (!!exclusive.length && hasAnyType(as, ...activityOrder.asActivityOrder)) | |
? [exclusive[0]] | |
: activityOrder.asActivityOrder; | |
} | |
async function setActors(as: O, actorUrl: string, actorId: string) { | |
if (!as.actor && as?.object?.actor) { | |
as.actor = (Array.isArray(as.object.actor) ? as.object.actor : [as.object.actor]).map(asId); | |
} | |
if (!as.actor) { as.actor = [actorUrl]; } | |
const { actor } = as; | |
const allowedActor = [actorUrl]; | |
const actors = {[actorUrl]: actorId}; | |
for await (const acct of actor) { | |
const acctUrl = asId(acct); | |
if (!acctUrl) { throw new Error("Sending Actor of Activity not found"); } | |
if (acctUrl === actorUrl) { continue; } | |
const acctId = await getId(acctUrl, actorUrl); | |
if (!acctId) { | |
// TODO NEW ACTOR detected | |
} | |
actors[acctUrl] = acctId; | |
allowedActor.push(acctUrl) | |
} | |
as.actor = allowedActor; | |
return actors; | |
} | |
// MUST run with --location - see https://docs.deno.com/runtime/reference/web_platform_apis/ | |
// TODO actor must come explicitly after checking signature and comparing | |
export async function OutboxPost(asActivity: AsObjectNormalized|AsObjectNormalized[], actorUrl: string) { | |
// at this point, the signature was checked and actorUrl is a valid url for the posting actor | |
const actorStatus = await getUlidStatus(actorUrl); | |
let { ulid: actorId = false, ownerHost, isBlocked } = actorStatus||{}; | |
// Is Actor blocked or not local | |
if (!actorId || isBlocked || !ownerHost || ownerHost !== '/') { return null } | |
const a = Array.isArray(asActivity) ? asActivity : [asActivity]; | |
/* Problem: | |
`as:type` is NOT marked as functional which is correct, it can have multiple cause it is an alias for JSON-LD `@type` | |
It is neither specified that there can be only one type of as: in the type array. | |
We want to be somehow conformant with the spec. | |
It sould not be solved in the past 7 years Social CG :( | |
workaround ... | |
if hasAllTypes(...noMenaing) filter them out, otherwise: | |
any Action returns a part of the atomic transaction exclusive or in the following order ... | |
any better solution is strictly welcome. | |
TODO | |
*/ | |
const activities: any[] = []; | |
for await (let as of a) { | |
as = normalize(as, { includeBcc: true }); | |
// is it handled already ? | |
if (!!(await kv.get(hrefToHrefKey(as.id)))?.value) { continue; } | |
/* | |
For client to server posting, it is possible to submit an object for creation without a surrounding activity. | |
The server MUST accept a valid [ActivityStreams] object that isn't a subtype of Activity in the POST request | |
to the outbox. | |
The server then MUST attach this object as the object of a Create Activity. | |
For non-transient objects, the server MUST attach an id to both the wrapping Create and its wrapped Object. | |
*/ | |
if (!isActivity(as)) { as = { type: ['Create'], object: as }; } | |
const actorMap = await setActors(as, actorUrl, actorId); | |
if (!as?.actor?.length) { throw new Deno.errors.NotSupported("Activity has no actor"); } | |
if (activityOrder.noMeaning.filter((notAll) => hasAllTypes(as, ...notAll)).length) { continue } | |
const order = getActivityOrder(as); | |
const {to = [], cc = [], bcc = [], bto = [], audience = [], object: o = {}, ...meta} = as; | |
const hasPublics = Array.from(new Set([...to,...cc,...bcc,...bto,...audience])).filter((s) => PUBLIC[s] === 1); | |
const hasActors = as.actor.filter((ao) => asId(ao) === actorId); | |
if (!hasActors.length) { as.actor.unshift(actorId) } | |
const { id, ulid: aId } = createId(actorId); | |
const status = await setStatus(SCOPESTATUS.ACTIVE, as, 'Activity', actorId, aId); | |
for await (const activityKey of order) { | |
try { | |
activities.push(await ActivityFn.fromClient[activityKey](as, status, actorMap)); | |
} catch (e) { | |
// TODO !!! last commit failed unhandled ... | |
continue; | |
} | |
} | |
} | |
return activities; | |
} | |
export async function InboxPost(asActivity: AsObjectNormalized|AsObjectNormalized[], actorUrl: string, hasFedify = true) { | |
// is it handled already cause local post ? | |
if (actorUrl.startsWith(baseUrl)) { return; } | |
// at this point, the signature was checked and actorUrl is a valid url for the posting actor | |
const actorStatus = (await getUlidStatus(actorUrl)); | |
let { ulid: actorId = false, ownerHost, isBlocked } = actorStatus||{}; | |
// Is Actor blocked or not local | |
if (!actorId || isBlocked || !ownerHost || ownerHost !== '/') { return null } | |
const a = Array.isArray(asActivity) ? asActivity : [asActivity]; | |
const activities: any[] = []; | |
for await (let as of a) { | |
as = normalize(as, { includeBcc: true }); | |
// is it handled already ? | |
if (as?.id.startsWith(baseUrl) || (!hasFedify && !!(await kv.get(hrefToHrefKey(as.id)))?.value)) { continue; } | |
const actorMap = await setActors(as, actorUrl, actorId); | |
if (!as?.actor?.length) { throw new Deno.errors.NotSupported("Activity has no actor"); } | |
if (activityOrder.noMeaning.filter((notAll) => hasAllTypes(as, ...notAll)).length) { continue } | |
const order = getActivityOrder(as); | |
const {to = [], cc = [], bcc = [], bto = [], audience = [], object: o = {}, ...meta} = as; | |
const hasPublics = Array.from(new Set([...to,...cc,...bcc,...bto,...audience])).filter((s) => PUBLIC[s] === 1); | |
const hasActors = as.actor.filter((ao) => asId(ao) === actorId); | |
if (!hasActors.length) { as.actor.unshift(actorId) } | |
const { id, ulid: aId } = createId(actorId); | |
const status = await setStatus(SCOPESTATUS.ACTIVE, as, 'Activity', actorId, aId); | |
for await (const activityKey of order) { | |
try { | |
activities.push(await ActivityFn.fromServer[activityKey](as, status, actorMap)); | |
} catch (e) { | |
// TODO !!! last commit failed unhandled ... | |
continue; | |
} | |
} | |
} | |
return activities; | |
} | |
/* TODO FOR INBOX - | |
// WAIT_FOR_ACCEPT | |
*/ | |
// e.g. ActivityFn.fromClient.Create(as, status ...) | |
/* | |
function hrefInt(s) { | |
} | |
function hrefToKey(u) { | |
const url = typeof u === 'object' && u?.protocol && u?.hostname | |
? u | |
: new URL(u); | |
if (!u?.protocol || !u?.hostname) { return null; } | |
const key = []; | |
const wellKnownProtocols = [ | |
'http:', 'https:', 'mailto:', 'tel:', 'geo:', 'imap:', 'irc:', 'wss:' | |
]; | |
const protocolI = wellKnownProtocols.indexOf(url.protocol); | |
key.push(protocolI > -1 ? protocolI : hrefInt(url.protocol)); | |
return key | |
} | |
console.log(hrefToKey(u)) | |
https://digitalcourage.social/users/sl007/statuses/114499352152664711 | |
https://digitalcourage.social/@sl007/114499352152664711 | |
https://digitalcourage.social/@[email protected]/114524220800538711 | |
type Char = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | |
| 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | |
| 'y' | 'z' | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | |
| 'L' | 'M' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | |
*/ | |
/* | |
const test = hrefToHrefKey(`https://digitalcourage.social`); | |
console.log(test); console.log( hrefKeyToHref(test)); | |
*/ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment