Added dislikes, shares/boosts, replies and deleting posts
All admin/outbox stuff for now. Nothing inbox, yet
This commit is contained in:
parent
a55a301d35
commit
36d8ace51f
6 changed files with 214 additions and 15 deletions
1
_content/_data/disliked.json
Normal file
1
_content/_data/disliked.json
Normal file
|
@ -0,0 +1 @@
|
|||
[]
|
1
_content/_data/shared.json
Normal file
1
_content/_data/shared.json
Normal file
|
@ -0,0 +1 @@
|
|||
[]
|
116
src/admin.ts
116
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<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"
|
||||
}
|
||||
})
|
||||
}
|
68
src/db.ts
68
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'))
|
||||
}
|
||||
|
@ -163,3 +187,39 @@ 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>
|
||||
}
|
|
@ -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,7 +145,9 @@ 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 {
|
||||
|
@ -125,3 +156,12 @@ async function undo(activity:any) {
|
|||
|
||||
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
|
||||
}
|
|
@ -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, "")
|
||||
|
||||
|
|
Loading…
Reference in a new issue