From 36d8ace51ffb9d05f552e712e6ae7aa097c667c1 Mon Sep 17 00:00:00 2001 From: Gordon Pedersen Date: Thu, 21 Sep 2023 17:04:27 +1000 Subject: [PATCH] Added dislikes, shares/boosts, replies and deleting posts All admin/outbox stuff for now. Nothing inbox, yet --- _content/_data/disliked.json | 1 + _content/_data/shared.json | 1 + src/admin.ts | 116 ++++++++++++++++++++++++++++++++--- src/db.ts | 68 ++++++++++++++++++-- src/outbox.ts | 42 ++++++++++++- src/request.ts | 1 + 6 files changed, 214 insertions(+), 15 deletions(-) create mode 100644 _content/_data/disliked.json create mode 100644 _content/_data/shared.json diff --git a/_content/_data/disliked.json b/_content/_data/disliked.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/_content/_data/disliked.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/_content/_data/shared.json b/_content/_data/shared.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/_content/_data/shared.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/src/admin.ts b/src/admin.ts index e79f195..638400d 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -1,5 +1,5 @@ import { idsFromValue } from "./activitypub" -import { getFollowing, getOutboxActivity, listLiked } from "./db" +import * as db from "./db" import { ACTOR, ADMIN_PASSWORD, ADMIN_USERNAME, BASE_URL } from "./env" import outbox from "./outbox" import { fetchObject } from "./request" @@ -19,6 +19,12 @@ export default (req: Request): Response | Promise | undefined => { else if(req.method == "DELETE" && (match = url.pathname.match(/^\/follow\/([^\/]+)\/?$/i))) return unfollow(req, match[1]) else if(req.method == "POST" && (match = url.pathname.match(/^\/like\/(.+)\/?$/i))) return like(req, match[1]) else if(req.method == "DELETE" && (match = url.pathname.match(/^\/like\/(.+)\/?$/i))) return unlike(req, match[1]) + else if(req.method == "POST" && (match = url.pathname.match(/^\/dislike\/(.+)\/?$/i))) return dislike(req, match[1]) + else if(req.method == "DELETE" && (match = url.pathname.match(/^\/dislike\/(.+)\/?$/i))) return undislike(req, match[1]) + else if(req.method == "POST" && (match = url.pathname.match(/^\/share\/(.+)\/?$/i))) return share(req, match[1]) + else if(req.method == "DELETE" && (match = url.pathname.match(/^\/share\/(.+)\/?$/i))) return unshare(req, match[1]) + else if(req.method == "POST" && (match = url.pathname.match(/^\/reply\/(.+)\/?$/i))) return create(req, match[1]) + else if(req.method == "DELETE" && (match = url.pathname.match(/^\/delete\/(.+)\/?$/i))) return deletePost(req, match[1]) console.log(`Couldn't match admin path ${req.method} "${url.pathname}"`) @@ -36,9 +42,13 @@ const checkAuth = (headers: Headers): Boolean => { } // create an activity -const create = async (req:Request):Promise => { +const create = async (req:Request, inReplyTo:string|null = null):Promise => { const body = await req.json() + if(!inReplyTo && body.object.inReplyTo) inReplyTo = body.object.inReplyTo + + const original = inReplyTo ? await (await fetchObject(ACTOR, inReplyTo)).json() : null + // create the object, merging in supplied data const date = new Date() const object = { @@ -48,14 +58,18 @@ const create = async (req:Request):Promise => { cc: [`${ACTOR}/followers`], ...body.object } + if(inReplyTo){ + object.inReplyTo = original || inReplyTo + object.cc.push(inReplyTo) + } const activity = { "@context": "https://www.w3.org/ns/activitystreams", type: "Create", published: date.toISOString(), actor: ACTOR, - to: ["https://www.w3.org/ns/activitystreams#Public"], - cc: [`${ACTOR}/followers`], + to: object.to, + cc: object.cc, ...body, object: { ...object } } @@ -76,9 +90,9 @@ const follow = async (req:Request, handle:string):Promise => { const unfollow = async (req:Request, handle:string):Promise => { // check to see if we are already following. If not, just return success - const existing = await getFollowing(handle) + const existing = await db.getFollowing(handle) if (!existing) return new Response("", { status: 204 }) - const activity = await getOutboxActivity(existing.id) + const activity = await db.getOutboxActivity(existing.id) // outbox will also take care of the deletion return await outbox({ "@context": "https://www.w3.org/ns/activitystreams", @@ -97,13 +111,14 @@ const like = async (req:Request, object_url:string):Promise => { type: "Like", actor: ACTOR, object: object, - to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"] + to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"], + cc: [ACTOR + 'followers'] }) } const unlike = async (req:Request, object_id:string):Promise => { // check to see if we are already following. If not, just return success - const liked = await listLiked() + const liked = await db.listLiked() let existing = liked.find(o => o.object_id === object_id) if (!existing){ const object = await (await fetchObject(ACTOR, object_id)).json() @@ -113,13 +128,94 @@ const unlike = async (req:Request, object_id:string):Promise => { }) } if (!existing) return new Response("No like found to delete", { status: 204 }) - const activity = await getOutboxActivity(existing.id) + const activity = await db.getOutboxActivity(existing.id) + return undo(activity) +} + +const dislike = async (req:Request, object_url:string):Promise => { + const object = await (await fetchObject(ACTOR, object_url)).json() + + return await outbox({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Dislike", + actor: ACTOR, + object: object, + to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"], + cc: [ACTOR + 'followers'] + }) +} + +const undislike = async (req:Request, object_id:string):Promise => { + // check to see if we are already following. If not, just return success + const disliked = await db.listDisliked() + let existing = disliked.find(o => o.object_id === object_id) + if (!existing){ + const object = await (await fetchObject(ACTOR, object_id)).json() + idsFromValue(object).forEach(id => { + const e = disliked.find(o => o.object_id === id) + if(e) existing = e + }) + } + if (!existing) return new Response("No dislike found to delete", { status: 204 }) + const activity = await db.getOutboxActivity(existing.id) + return undo(activity) +} + +const share = async (req:Request, object_url:string):Promise => { + const object = await (await fetchObject(ACTOR, object_url)).json() + + return await outbox({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Announce", + actor: ACTOR, + object: object, + to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"], + cc: [ACTOR + 'followers'] + }) +} + +const unshare = async (req:Request, object_id:string):Promise => { + // check to see if we are already following. If not, just return success + const shared = await db.listShared() + let existing = shared.find(o => o.object_id === object_id) + if (!existing){ + const object = await (await fetchObject(ACTOR, object_id)).json() + idsFromValue(object).forEach(id => { + const e = shared.find(o => o.object_id === id) + if(e) existing = e + }) + } + if (!existing) return new Response("No share found to delete", { status: 204 }) + const activity = await db.getOutboxActivity(existing.id) + return undo(activity) +} + +const undo = async(activity:any):Promise => { // outbox will also take care of the deletion return await outbox({ "@context": "https://www.w3.org/ns/activitystreams", type: "Undo", actor: ACTOR, object: activity, - to: activity.to + to: activity.to, + cc: activity.cc + }) +} + +const deletePost = async (req:Request, id:string):Promise => { + const post = await db.getPostByURL(id) + if(!post) return new Response("", { status: 404 }) + const activity = await db.getOutboxActivity(post.local_id) + return await outbox({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Delete", + actor: ACTOR, + to: activity.to, + cc: activity.cc, + audience: activity.audience, + object: { + id, + type: "Tombstone" + } }) } \ No newline at end of file diff --git a/src/db.ts b/src/db.ts index 2537965..da4f115 100644 --- a/src/db.ts +++ b/src/db.ts @@ -2,6 +2,8 @@ import { ACTIVITY_INBOX_PATH, ACTIVITY_OUTBOX_PATH, ACTIVITY_PATH, ACTOR, BASE_U import path from "path" import { readdir } from "fs/promises" import { unlinkSync } from "node:fs" +import { fetchObject } from "./request"; +import { idsFromValue } from "./activitypub"; const matter = require('gray-matter') export async function doActivity(activity:any, object_id:string|null|undefined) { @@ -52,7 +54,9 @@ export async function listOutboxActivities() { export async function createPost(post_object:any, object_id:string) { const file = Bun.file(path.join(POSTS_PATH, `${object_id}.md`)) - const {type, object} = post_object + let {type, object, inReplyTo} = post_object + if(inReplyTo && typeof inReplyTo === 'string') inReplyTo = await fetchObject(ACTOR, inReplyTo) + if(object){ let { content, published, id, attributedTo } = object if(content as string) content = '> ' + content.replace('\n', '\n> ') + '\n' @@ -60,11 +64,23 @@ export async function createPost(post_object:any, object_id:string) { content += post_object.content || "" //TODO: add appropriate content for different types (e.g. like, etc) - await Bun.write(file, matter.stringify(content, { id, published, attributedTo, type })) + const data:any = { id, published, attributedTo, type } + if(inReplyTo) data.inReplyTo = idsFromValue(inReplyTo).at(0) + await Bun.write(file, matter.stringify(content, data)) } else { const { content, published, id, attributedTo } = post_object - await Bun.write(file, matter.stringify(content || "", { id, published, attributedTo, type })) + + let reply_content = "" + if(!object && inReplyTo) { + reply_content = inReplyTo.content + if(reply_content as string) reply_content = '> ' + reply_content.replace('\n', '\n> ') + '\n' + else reply_content = "" + } + + const data:any = { id, published, attributedTo, type } + if(inReplyTo) data.inReplyTo = idsFromValue(inReplyTo).at(0) + await Bun.write(file, matter.stringify((reply_content || "") + (content || ""), data)) } } @@ -73,10 +89,18 @@ export async function getPost(id:string) { const { data, content } = matter(await file.text()) return { ...data, - content: content.trim() + content: content.trim(), + local_id: id } } +export async function getPostByURL(url_id:string) { + if(!url_id || !url_id.startsWith(ACTOR + '/post/')) return null + const match = url_id.match(/\/([0-9a-f]+)\/?$/) + const local_id = match ? match[1] : url_id + return await getPost(local_id) +} + export async function deletePost(id:string) { unlinkSync(path.join(POSTS_PATH, id + '.md')) } @@ -162,4 +186,40 @@ export async function deleteLiked(object_id:string) { export async function listLiked() { const file = Bun.file(path.join(DATA_PATH, `liked.json`)) return await file.json() as Array +} + +export async function createDisliked(object_id:string, id:string) { + const file = Bun.file(path.join(DATA_PATH, `disliked.json`)) + const disliked_list = await file.json() as Array + if(!disliked_list.find(v => v.object_id === object_id)) disliked_list.push({id, object_id, createdAt: new Date().toISOString()}) + await Bun.write(file, JSON.stringify(disliked_list)) +} + +export async function deleteDisliked(object_id:string) { + const file = Bun.file(path.join(DATA_PATH, `disliked.json`)) + const disliked_list = await file.json() as Array + await Bun.write(file, JSON.stringify(disliked_list.filter(v => v.object_id !== object_id))) +} + +export async function listDisliked() { + const file = Bun.file(path.join(DATA_PATH, `disliked.json`)) + return await file.json() as Array +} + +export async function createShared(object_id:string, id:string) { + const file = Bun.file(path.join(DATA_PATH, `shared.json`)) + const shared_list = await file.json() as Array + if(!shared_list.find(v => v.object_id === object_id)) shared_list.push({id, object_id, createdAt: new Date().toISOString()}) + await Bun.write(file, JSON.stringify(shared_list)) +} + +export async function deleteShared(object_id:string) { + const file = Bun.file(path.join(DATA_PATH, `shared.json`)) + const shared_list = await file.json() as Array + await Bun.write(file, JSON.stringify(shared_list.filter(v => v.object_id !== object_id))) +} + +export async function listShared() { + const file = Bun.file(path.join(DATA_PATH, `shared.json`)) + return await file.json() as Array } \ No newline at end of file diff --git a/src/outbox.ts b/src/outbox.ts index 95975c6..acd25b7 100644 --- a/src/outbox.ts +++ b/src/outbox.ts @@ -47,8 +47,11 @@ export default async function outbox(activity:any):Promise { case "Accept": await accept(activity, id); break; case "Follow": await follow(activity, id); break; case "Like": await like(activity, id); break; + case "Dislike": await dislike(activity, id); break; + case "Annouce": await announce(activity, id); break; case "Create": await create(activity, id); break; case "Undo": await undo(activity); break; + case "Delete": await deletePost(activity); break; // TODO: case "Anncounce": return await share(activity) } // save the activity data for the outbox @@ -92,6 +95,32 @@ async function like(activity:any, id:string) { return true } +async function dislike(activity:any, id:string) { + if(typeof activity.object === 'string'){ + await db.createDisliked(activity.object, id) + activity.object = await fetchObject(ACTOR, activity.object) + } + else { + const disliked = await idsFromValue(activity.object) + disliked.forEach(l => db.createDisliked(l, id)) + } + await db.createPost(activity, id) + return true +} + +async function announce(activity:any, id:string) { + if(typeof activity.object === 'string'){ + await db.createShared(activity.object, id) + activity.object = await fetchObject(ACTOR, activity.object) + } + else { + const shared = await idsFromValue(activity.object) + shared.forEach(l => db.createShared(l, id)) + } + await db.createPost(activity, id) + return true +} + // async function share(activity:any) { // let object = activity.object // if(typeof object === 'string') { @@ -116,12 +145,23 @@ async function undo(activity:any) { switch(activity.object.type) { case "Follow": await db.deleteFollowing(existing.object); break; case "Like": idsFromValue(existing.object).forEach(async id => await db.deleteLiked(id)); await db.deletePost(local_id); break; - // case "Share": await db.deleteShared(existing.object) + case "Dislike": idsFromValue(existing.object).forEach(async id => await db.deleteDisliked(id)); await db.deletePost(local_id); break; + case "Announce": idsFromValue(existing.object).forEach(async id => await db.deleteShared(id)); await db.deletePost(local_id); break; + } } catch { return false } + return true +} + +async function deletePost(activity:any) { + const id = await idsFromValue(activity.object).at(0) + if(!id) return false + const post = await db.getPostByURL(id) + if(!post) return false + await db.deletePost(post.local_id) return true } \ No newline at end of file diff --git a/src/request.ts b/src/request.ts index 8096a40..c648c5a 100644 --- a/src/request.ts +++ b/src/request.ts @@ -71,6 +71,7 @@ export async function fetchObject(sender:string, object_url:string) { export async function send(sender:string, recipient:string, message:any) { console.log(`Sending to ${recipient}`, message) const url = new URL(recipient) + // TODO: revisit fetch actor to use webfinger to get the inbox maybe? const actor = await fetchActor(recipient) const path = actor.inbox.replace("https://" + url.hostname, "")