diff --git a/.eleventy.js b/.eleventy.js new file mode 100644 index 0000000..e74cd36 --- /dev/null +++ b/.eleventy.js @@ -0,0 +1,10 @@ +module.exports = function(eleventyConfig) { + eleventyConfig.setUseGitIgnore(false) + // Return your Object options: + return { + dir: { + input: "_content", + output: "_site" + } + } +}; \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8dddefc..06100bd 100644 --- a/.gitignore +++ b/.gitignore @@ -169,4 +169,5 @@ dist .pnp.\* # My custom ignores -_content \ No newline at end of file +_content +_site diff --git a/bun.lockb b/bun.lockb index 97de569..62950fa 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 45375fc..55d599b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "typescript": "^5.0.0" }, "dependencies": { + "@11ty/eleventy": "^2.0.1", "gray-matter": "^4.0.3", "node-forge": "^1.3.1" } diff --git a/src/activitypub.ts b/src/activitypub.ts index f86877c..af71645 100644 --- a/src/activitypub.ts +++ b/src/activitypub.ts @@ -8,7 +8,7 @@ export default (req: Request): Response | Promise | undefined => { const url = new URL(req.url) let match - if(req.method === "GET" && url.pathname === "/test") return new Response("", { status: 204 }) + if(!reqIsActivityPub(req)) return undefined // else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/?$/i))) return getActor(req, match[1]) // else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/outbox\/?$/i))) return getOutbox(req, match[1]) @@ -26,7 +26,8 @@ export default (req: Request): Response | Promise | undefined => { else if(req.method == "GET" && (match = url.pathname.match(/^\/followers\/?$/i))) return getFollowers(req, ACCOUNT) else if(req.method == "GET" && (match = url.pathname.match(/^\/following\/?$/i))) return getFollowing(req, ACCOUNT) else if(req.method == "GET" && (match = url.pathname.match(/^\/posts\/([^\/]+)\/?$/i))) return getPost(req, ACCOUNT, match[1]) - else if(req.method == "GET" && (match = url.pathname.match(/^\/posts\/([^\/]+)\/activity\/?$/i))) return getActivity(req, ACCOUNT, match[1]) + else if(req.method == "GET" && (match = url.pathname.match(/^\/posts\/([^\/]+)\/activity\/?$/i))) return getOutboxActivity(req, ACCOUNT, match[1]) + else if(req.method == "GET" && (match = url.pathname.match(/^\/outbox\/([^\/]+)\/?$/i))) return getOutboxActivity(req, ACCOUNT, match[1]) return undefined } @@ -190,13 +191,13 @@ const getPost = async (req:Request, account:string, id:string):Promise console.log("GetPost", account, id) if (ACCOUNT !== account) return new Response("", { status: 404 }) - if(reqIsActivityPub(req)) return Response.json((await db.getActivity(id)).object, { headers: { "Content-Type": "application/activity+json"}}) + if(reqIsActivityPub(req)) return Response.json((await db.getOutboxActivity(id)).object, { headers: { "Content-Type": "application/activity+json"}}) else return Response.json(await db.getPost(id)) } -const getActivity = async (req:Request, account:string, id:string):Promise => { - console.log("GetActivity", account, id) +const getOutboxActivity = async (req:Request, account:string, id:string):Promise => { + console.log("GetOutboxActivity", account, id) if (ACCOUNT !== account) return new Response("", { status: 404 }) - return Response.json((await db.getActivity(id)), { headers: { "Content-Type": "application/activity+json"}}) + return Response.json((await db.getOutboxActivity(id)), { headers: { "Content-Type": "application/activity+json"}}) } \ No newline at end of file diff --git a/src/admin.ts b/src/admin.ts index 6d3ac53..3df6a4a 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -1,6 +1,6 @@ import { idsFromValue } from "./activitypub" import * as db from "./db" -import { ACTOR, ADMIN_PASSWORD, ADMIN_USERNAME, BASE_URL } from "./env" +import { ACTOR, ADMIN_PASSWORD, ADMIN_USERNAME, BASE_URL, CONTENT_PATH, STATIC_PATH } from "./env" import outbox from "./outbox" import { activityMimeTypes, fetchObject } from "./request" @@ -14,6 +14,7 @@ export default (req: Request): Response | Promise | undefined => { let match if(req.method === "GET" && (match = url.pathname.match(/^\/test\/?$/i))) return new Response("", { status: 204 }) + else if(req.method == "POST" && (match = url.pathname.match(/^\/rebuild\/?$/i))) return rebuild(req) else if(req.method == "POST" && (match = url.pathname.match(/^\/create\/?$/i))) return create(req) else if(req.method == "POST" && (match = url.pathname.match(/^\/follow\/([^\/]+)\/?$/i))) return follow(req, match[1]) else if(req.method == "DELETE" && (match = url.pathname.match(/^\/follow\/([^\/]+)\/?$/i))) return unfollow(req, match[1]) @@ -41,6 +42,12 @@ const checkAuth = (headers: Headers): Boolean => { return username === ADMIN_USERNAME && password === ADMIN_PASSWORD } +// rebuild the 11ty static pages +export const rebuild = async(req:Request):Promise => { + await db.rebuild() + return new Response("", { status: 201 }) +} + // create an activity const create = async (req:Request, inReplyTo:string|null = null):Promise => { const body = await req.json() diff --git a/src/db.ts b/src/db.ts index da4f115..722ec9f 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,21 +1,16 @@ -import { ACTIVITY_INBOX_PATH, ACTIVITY_OUTBOX_PATH, ACTIVITY_PATH, ACTOR, BASE_URL, DATA_PATH, POSTS_PATH } from "./env"; +import { ACTIVITY_INBOX_PATH, ACTIVITY_OUTBOX_PATH, ACTOR, CONTENT_PATH, DATA_PATH, POSTS_PATH, STATIC_PATH } from "./env"; 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') +const Eleventy = require("@11ty/eleventy") -export async function doActivity(activity:any, object_id:string|null|undefined) { - if(activity.type === "Create" && activity.object) { - if(!object_id) object_id = new Date(activity.object.published).getTime().toString(16) - const file = Bun.file(path.join(POSTS_PATH, `${object_id}.md`)) - const { content, published, id, attributedTo } = activity.object - //TODO: add appropriate content for different types (e.g. like-of, etc) - await Bun.write(file, matter.stringify(content || "", { id, published, attributedTo })) - const activityFile = Bun.file(path.join(ACTIVITY_PATH, `${object_id}.activity.json`)) - await Bun.write(activityFile, JSON.stringify(activity)) - } +// rebuild the 11ty static pages +export async function rebuild() { + console.info(`Building 11ty from ${CONTENT_PATH}, to ${STATIC_PATH}`) + await new Eleventy(CONTENT_PATH, STATIC_PATH, { configPath: '.eleventy.js' }).write() } export async function createInboxActivity(activity:any, object_id:any) { @@ -82,6 +77,7 @@ export async function createPost(post_object:any, object_id:string) { if(inReplyTo) data.inReplyTo = idsFromValue(inReplyTo).at(0) await Bun.write(file, matter.stringify((reply_content || "") + (content || ""), data)) } + rebuild() } export async function getPost(id:string) { @@ -95,7 +91,7 @@ export async function getPost(id:string) { } export async function getPostByURL(url_id:string) { - if(!url_id || !url_id.startsWith(ACTOR + '/post/')) return null + if(!url_id || !url_id.startsWith(ACTOR + '/posts/')) return null const match = url_id.match(/\/([0-9a-f]+)\/?$/) const local_id = match ? match[1] : url_id return await getPost(local_id) @@ -103,28 +99,26 @@ export async function getPostByURL(url_id:string) { export async function deletePost(id:string) { unlinkSync(path.join(POSTS_PATH, id + '.md')) + rebuild() } export async function listPosts() { return await Promise.all((await readdir(POSTS_PATH)).filter(v => v.endsWith('.md')).map(async filename => await getPost(filename.slice(0, -3)))) } -export async function getActivity(id:string) { - const file = Bun.file(path.join(ACTIVITY_PATH, `${id}.activity.json`)) - return await file.json() -} - export async function createFollowing(handle:string, id:string) { const file = Bun.file(path.join(DATA_PATH, `following.json`)) const following_list = await file.json() as Array if(!following_list.find(v => v.id === id || v.handle === handle)) following_list.push({id, handle, createdAt: new Date().toISOString()}) await Bun.write(file, JSON.stringify(following_list)) + rebuild() } export async function deleteFollowing(handle:string) { const file = Bun.file(path.join(DATA_PATH, `following.json`)) const following_list = await file.json() as Array await Bun.write(file, JSON.stringify(following_list.filter(v => v.handle !== handle))) + rebuild() } export async function getFollowing(handle:string) { @@ -144,6 +138,7 @@ export async function acceptFollowing(handle:string) { const following = following_list.find(v => v.handle === handle) if(following) following.accepted = new Date().toISOString() await Bun.write(file, JSON.stringify(following_list)) + rebuild() } export async function createFollower(actor:string, id:string) { @@ -151,12 +146,14 @@ export async function createFollower(actor:string, id:string) { const followers_list = await file.json() as Array if(!followers_list.find(v => v.id === id || v.actor === actor)) followers_list.push({id, actor, createdAt: new Date().toISOString()}) await Bun.write(file, JSON.stringify(followers_list)) + rebuild() } export async function deleteFollower(actor:string) { const file = Bun.file(path.join(DATA_PATH, `followers.json`)) const followers_list = await file.json() as Array await Bun.write(file, JSON.stringify(followers_list.filter(v => v.actor !== actor))) + rebuild() } export async function getFollower(actor:string) { @@ -175,12 +172,14 @@ export async function createLiked(object_id:string, id:string) { const liked_list = await file.json() as Array if(!liked_list.find(v => v.object_id === object_id)) liked_list.push({id, object_id, createdAt: new Date().toISOString()}) await Bun.write(file, JSON.stringify(liked_list)) + rebuild() } export async function deleteLiked(object_id:string) { const file = Bun.file(path.join(DATA_PATH, `liked.json`)) const liked_list = await file.json() as Array await Bun.write(file, JSON.stringify(liked_list.filter(v => v.object_id !== object_id))) + rebuild() } export async function listLiked() { @@ -193,12 +192,14 @@ export async function createDisliked(object_id:string, id:string) { 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)) + rebuild() } 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))) + rebuild() } export async function listDisliked() { @@ -211,12 +212,14 @@ export async function createShared(object_id:string, id:string) { 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)) + rebuild() } 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))) + rebuild() } export async function listShared() { diff --git a/src/env.ts b/src/env.ts index 97e9200..6b30c42 100644 --- a/src/env.ts +++ b/src/env.ts @@ -2,16 +2,16 @@ import forge from "node-forge" // import crypto from "node:crypto" import path from "path" // change "activitypub" to whatever you want your account name to be -export const ACCOUNT = Bun.env.ACCOUNT || "activitypub" +export const ACCOUNT = process.env.ACCOUNT || "activitypub" // set up username and password for admin actions export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || ""; export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ""; // get the hostname (`PROJECT_DOMAIN` is set via glitch, but since we're using Bun now, this won't matter) -export const HOSTNAME = /*(Bun.env.PROJECT_DOMAIN && `${Bun.env.PROJECT_DOMAIN}.glitch.me`) ||*/ Bun.env.HOSTNAME || "localhost" -export const NODE_ENV = Bun.env.NODE_ENV || "development" -export const PORT = Bun.env.PORT || "3000" +export const HOSTNAME = /*(process.env.PROJECT_DOMAIN && `${process.env.PROJECT_DOMAIN}.glitch.me`) ||*/ process.env.HOSTNAME || "localhost" +export const NODE_ENV = process.env.NODE_ENV || "development" +export const PORT = process.env.PORT || "3000" export const BASE_URL = (HOSTNAME === "localhost" ? "http://" : "https://") + HOSTNAME @@ -32,9 +32,20 @@ export const PRIVATE_KEY = (keypair && forge.pki.privateKeyToPem(keypair.privateKey)) || //keypair?.privateKey.export({ type: "pkcs8", format: "pem" }) || "" +export const STATIC_PATH = path.join('.', '_site') export const CONTENT_PATH = path.join('.', '_content') export const POSTS_PATH = path.join(CONTENT_PATH, "posts") -export const ACTIVITY_PATH = path.join(CONTENT_PATH, "posts") export const DATA_PATH = path.join(CONTENT_PATH, "_data") export const ACTIVITY_INBOX_PATH = path.join(DATA_PATH, "_inbox") -export const ACTIVITY_OUTBOX_PATH = path.join(DATA_PATH, "_outbox") \ No newline at end of file +export const ACTIVITY_OUTBOX_PATH = path.join(DATA_PATH, "_outbox") + +export const DEFAULT_DOCUMENTS = process.env.DEFAULT_DOCUMENTS || [ + 'index.html', + 'index.shtml', + 'index.htm', + 'Index.html', + 'Index.shtml', + 'Index.htm', + 'default.html', + 'default.htm' +] \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index d2fb3f9..c7758e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,10 @@ -import { ACCOUNT, ACTOR, HOSTNAME, PORT } from "./env"; +import { ACCOUNT, ACTOR, DEFAULT_DOCUMENTS, HOSTNAME, PORT, STATIC_PATH } from "./env"; import admin from './admin' import activitypub from "./activitypub"; import { fetchObject } from "./request"; +import path from "path" +import { BunFile } from "bun"; +const { stat } = require("fs").promises const server = Bun.serve({ port: 3000, @@ -20,7 +23,7 @@ const server = Bun.serve({ return fetchObject(ACTOR, object_url) } - return admin(req) || activitypub(req) || new Response("How did we get here?", { status: 404 }) + return admin(req) || activitypub(req) || staticFile(req) }, }); @@ -45,5 +48,31 @@ const webfinger = async (req: Request, resource: string | null) => { ], }, { headers: { "content-type": "application/activity+json" }}) } + +const getDefaultDocument = async(base_path: string) => { + for(const d of DEFAULT_DOCUMENTS){ + const filePath = path.join(base_path, d) + const file = Bun.file(filePath) + if(await file.exists()) return file + } +} + +const staticFile = async (req:Request): Promise => { + try{ + const url = new URL(req.url) + const filePath = path.join(STATIC_PATH, url.pathname) + let file:BunFile|undefined = Bun.file(filePath) + // if the file doesn't exist, attempt to get the default document for the path + if(!(await file.exists())) file = await getDefaultDocument(filePath) + + if(file && await file.exists()) return new Response(file) + // if the file still doesn't exist, just return a 404 + else return new Response("", { status: 404 }) + } + catch(err) { + console.error(err) + return new Response("", { status: 404 }) + } +} console.log(`Listening on http://localhost:${server.port} ...`); \ No newline at end of file diff --git a/src/outbox.ts b/src/outbox.ts index cdeccb8..5217614 100644 --- a/src/outbox.ts +++ b/src/outbox.ts @@ -68,7 +68,7 @@ export default async function outbox(activity:any):Promise { } async function create(activity:any, id:string) { - activity.object.id = activity.object.url = `${ACTOR}/post/${id}` + activity.object.id = activity.object.url = `${ACTOR}/posts/${id}` await db.createPost(activity.object, id) return true }