Added dislikes, shares/boosts, replies and deleting posts

All admin/outbox stuff for now. Nothing inbox, yet
This commit is contained in:
Gordon Pedersen 2023-09-21 17:04:27 +10:00
parent a55a301d35
commit 36d8ace51f
6 changed files with 214 additions and 15 deletions

View file

@ -0,0 +1 @@
[]

View file

@ -0,0 +1 @@
[]

View file

@ -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<Response> | 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<Response> => {
const create = async (req:Request, inReplyTo:string|null = null):Promise<Response> => {
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<Response> => {
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<Response> => {
const unfollow = async (req:Request, handle:string):Promise<Response> => {
// 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<Response> => {
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<Response> => {
// 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<Response> => {
})
}
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<Response> => {
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<Response> => {
// 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<Response> => {
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<Response> => {
// 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<Response> => {
// 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<Response> => {
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"
}
})
}

View file

@ -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<any>
}
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<any>
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<any>
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<any>
}
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<any>
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<any>
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<any>
}

View file

@ -47,8 +47,11 @@ export default async function outbox(activity:any):Promise<Response> {
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
}

View file

@ -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, "")