diff --git a/netlify.toml b/netlify.toml
new file mode 100644
index 0000000..84688ff
--- /dev/null
+++ b/netlify.toml
@@ -0,0 +1,74 @@
+[build]
+functions = "netlify/functions"
+
+# Redirects from old AMP and HTML URLs
+[[redirects]]
+from = "/amp/:slug/"
+to = "/:slug/"
+status = 301
+force = true
+
+[[redirects]]
+from = "/:year/:month/:slug.html"
+to = "/:slug/"
+status = 301
+force = true
+
+# Rewrites for ActivityPub API endpoints
+[[redirects]]
+from = "/.well-known/*"
+to = "/.netlify/functions/well-known/:splat"
+status = 200
+
+[[redirects]]
+from = "/authorize_interaction"
+to = "/.netlify/functions/authorize_interaction"
+status = 200
+
+[[redirects]]
+from = "/@blog"
+to = "/.netlify/functions/actor"
+status = 200
+
+[[redirects]]
+from = "/coder"
+to = "/.netlify/functions/old"
+status = 200
+
+[[redirects]]
+from = "/followers"
+to = "/.netlify/functions/followers"
+status = 200
+
+[[redirects]]
+from = "/following"
+to = "/.netlify/functions/following"
+status = 200
+
+[[redirects]]
+from = "/inbox"
+to = "/.netlify/functions/inbox"
+status = 200
+
+[[redirects]]
+from = "/sharedInbox"
+to = "/.netlify/functions/sharedInbox"
+status = 200
+
+[[redirects]]
+from = "/outbox"
+to = "/.netlify/functions/outbox"
+status = 200
+
+# Rewrite for trailing slashes to index.html
+[[redirects]]
+from = "/:path/"
+to = "/:path/index.html"
+status = 200
+
+# Custom content-type for ActivityPub JSON
+[[headers]]
+for = "/*.json"
+ [headers.values]
+ Content-Type = "application/activity+json"
+
diff --git a/netlify/functions/actor.js b/netlify/functions/actor.js
new file mode 100644
index 0000000..6cb1b07
--- /dev/null
+++ b/netlify/functions/actor.js
@@ -0,0 +1,71 @@
+exports.handler = async (event, context) => {
+ const headers = event.headers;
+
+ if (headers.accept && headers.accept.includes("text/html")) {
+ return {
+ statusCode: 302,
+ headers: {
+ Location: "https://coder.is-a.dev/"
+ },
+ };
+ }
+
+ return {
+ statusCode: 200,
+ headers: {
+ "Content-Type": "application/activity+json"
+ },
+ body: JSON.stringify({
+ "@context": ["https://www.w3.org/ns/activitystreams", { "@language": "en-US" }],
+ "type": "Person",
+ "id": "https://coder.is-a.dev/@blog",
+ "outbox": "https://coder.is-a.dev/api/activitypub/outbox",
+ "following": "https://coder.is-a.dev/api/activitypub/following",
+ "followers": "https://coder.is-a.dev/api/activitypub/followers",
+ "sharedInbox": "https://coder.is-a.dev/api/activitypub/sharedInbox",
+ "inbox": "https://coder.is-a.dev/api/activitypub/inbox",
+ "url": "https://coder.is-a.dev/@blog",
+ "published": "2024-05-02T15:25:40Z",
+ "preferredUsername": "blog",
+ "name": "Deva Midhun's blog",
+ "manuallyApprovesFollowers": false,
+ "discoverable": true,
+ "indexable": true,
+ "memorial": false,
+ "summary": "Software developer & self-hosting enthusiast. This is a bridge between my blog and the fediverse!",
+ "tag": [],
+ "icon": {
+ "type": "Image",
+ "mediaType": "image/png",
+ "url": "https://i.ibb.co/N6J5b8WS/download20250102015611.png"
+ },
+ "publicKey": {
+ "id": "https://coder.is-a.dev/@blog#main-key",
+ "owner": "https://coder.is-a.dev/@blog",
+ "publicKeyPem": process.env.ACTIVITYPUB_PUBLIC_KEY || "MISSING_PUBLIC_KEY"
+ },
+ "attachment": [
+ {
+ "type": "PropertyValue",
+ "value": "https://coder.is-a.dev",
+ "name": "Website"
+ },
+ {
+ "type": "PropertyValue",
+ "value": "https://github.com/turbomaster95",
+ "name": "GitHub"
+ },
+ {
+ "type": "PropertyValue",
+ "value": "https://usr.cloud/@coder",
+ "name": "Owner"
+ },
+ {
+ "type": "PropertyValue",
+ "value": "https://keyoxide.org/6389542B98CB868DAC73A373ED1190B780583CF6",
+ "name": "Keyoxide"
+ }
+ ]
+ })
+ };
+};
diff --git a/netlify/functions/authorize_interaction.js b/netlify/functions/authorize_interaction.js
new file mode 100644
index 0000000..058b7c5
--- /dev/null
+++ b/netlify/functions/authorize_interaction.js
@@ -0,0 +1,9 @@
+exports.handler = async (event, context) => {
+ return {
+ statusCode: 200,
+ headers: {
+ "Content-Type": "application/jrd+json"
+ },
+ body: "ok"
+ };
+};
diff --git a/netlify/functions/followers.ts b/netlify/functions/followers.ts
new file mode 100644
index 0000000..998c8c3
--- /dev/null
+++ b/netlify/functions/followers.ts
@@ -0,0 +1,43 @@
+import { Handler } from '@netlify/functions';
+import { firestore } from '../../../lib/firebase';
+import { generateActor } from '../../../lib/actor';
+
+const handler: Handler = async (event, context) => {
+ const [, , username] = event.path.split('/'); // /users/:username/followers
+
+ try {
+ const followersSnapshot = await firestore
+ .collection('followers')
+ .where('following', '==', username)
+ .get();
+
+ const followers = followersSnapshot.docs.map(doc => ({
+ type: 'Link',
+ href: doc.data().follower,
+ }));
+
+ const actor = await generateActor(username);
+
+ return {
+ statusCode: 200,
+ headers: {
+ 'Content-Type': 'application/activity+json',
+ },
+ body: JSON.stringify({
+ '@context': 'https://www.w3.org/ns/activitystreams',
+ id: `${actor.id}/followers`,
+ type: 'OrderedCollection',
+ totalItems: followers.length,
+ orderedItems: followers,
+ }),
+ };
+ } catch (error) {
+ return {
+ statusCode: 500,
+ body: JSON.stringify({ error: error.message }),
+ };
+ }
+};
+
+export { handler };
+
diff --git a/netlify/functions/following.ts b/netlify/functions/following.ts
new file mode 100644
index 0000000..7cee8e9
--- /dev/null
+++ b/netlify/functions/following.ts
@@ -0,0 +1,24 @@
+import { Handler } from '@netlify/functions';
+
+const handler: Handler = async (event, context) => {
+ const output = {
+ "@context": "https://www.w3.org/ns/activitystreams",
+ "id": "https://coder.is-a.dev/api/activitypub/following",
+ "type": "OrderedCollection",
+ "totalItems": 2,
+ "orderedItems": [
+ "https://mastodon.social/users/coderrrrr",
+ "https://usr.cloud/users/coder"
+ ]
+ };
+
+ return {
+ statusCode: 200,
+ headers: {
+ "Content-Type": "application/activity+json",
+ },
+ body: JSON.stringify(output),
+ };
+};
+
+export { handler };
diff --git a/netlify/functions/inbox.ts b/netlify/functions/inbox.ts
new file mode 100644
index 0000000..f28c3f5
--- /dev/null
+++ b/netlify/functions/inbox.ts
@@ -0,0 +1,46 @@
+import { Handler } from '@netlify/functions';
+import { firestore } from '../../../lib/firebase';
+import { verifySignature } from '../../../lib/verify';
+import { handleFollow } from '../../../lib/handleFollow';
+
+const handler: Handler = async (event, context) => {
+ if (event.httpMethod !== 'POST') {
+ return {
+ statusCode: 405,
+ body: 'Method Not Allowed',
+ };
+ }
+
+ try {
+ const inboxData = JSON.parse(event.body || '{}');
+ const username = event.path.split('/')[2]; // /users/:username/inbox
+
+ const isVerified = await verifySignature(event.headers, event.body);
+ if (!isVerified) {
+ return {
+ statusCode: 401,
+ body: 'Unauthorized',
+ };
+ }
+
+ if (inboxData.type === 'Follow') {
+ await handleFollow(inboxData, username);
+ return {
+ statusCode: 202,
+ body: 'Follow request accepted',
+ };
+ }
+
+ return {
+ statusCode: 200,
+ body: 'Activity received',
+ };
+ } catch (error) {
+ return {
+ statusCode: 500,
+ body: JSON.stringify({ error: error.message }),
+ };
+ }
+};
+
+export { handler };
diff --git a/netlify/functions/outbox.ts b/netlify/functions/outbox.ts
new file mode 100644
index 0000000..9c9eb88
--- /dev/null
+++ b/netlify/functions/outbox.ts
@@ -0,0 +1,20 @@
+import { Handler } from '@netlify/functions';
+import { join } from 'path';
+import { cwd } from 'process';
+import { readFileSync } from 'fs';
+
+const handler: Handler = async (event, context) => {
+ const file = join(cwd(), 'public', 'outbox.ajson');
+ const stringified = readFileSync(file, 'utf8');
+
+ return {
+ statusCode: 200,
+ headers: {
+ "Content-Type": "application/activity+json",
+ },
+ body: stringified,
+ };
+};
+
+export { handler };
+
diff --git a/netlify/functions/sendNote.ts b/netlify/functions/sendNote.ts
new file mode 100644
index 0000000..0674f92
--- /dev/null
+++ b/netlify/functions/sendNote.ts
@@ -0,0 +1,100 @@
+import { Handler } from '@netlify/functions';
+import { AP } from 'activitypub-core-types';
+import admin from 'firebase-admin';
+import { OrderedCollection } from 'activitypub-core-types/lib/activitypub/index.js';
+import { sendSignedRequest } from '../../lib/activitypub/utils/sendSignedRequest.js';
+import { fetchActorInformation } from '../../lib/activitypub/utils/fetchActorInformation.js';
+
+process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
+
+if (!admin.apps.length) {
+ admin.initializeApp({
+ credential: admin.credential.cert({
+ projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
+ clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
+ privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
+ }),
+ });
+}
+
+const db = admin.firestore();
+
+const handler: Handler = async (event, context) => {
+ if (event.headers.authorization !== `Bearer ${process.env.CRON_SECRET}`) {
+ return {
+ statusCode: 401,
+ body: "Unauthorized",
+ };
+ }
+
+ const configRef = db.collection('config').doc('config');
+ const config = await configRef.get();
+ const lastId = config.exists ? config.data()?.lastId || '' : '';
+
+ // Fetch notes from outbox
+ const outboxResponse = await fetch("https://coder.is-a.dev/api/activitypub/outbox");
+ const outbox = await outboxResponse.json();
+
+ const followersSnapshot = await db.collection('followers').get();
+ let lastSuccessfulSentId = "";
+
+ const inboxes = new Set(); // Track unique inboxes to avoid duplicate sending
+
+ for (const followerDoc of followersSnapshot.docs) {
+ const follower = followerDoc.data();
+ const actorUrl = typeof follower.actor === 'string' ? follower.actor : follower.actor.id;
+
+ console.log(`Fetching actor information for ${actorUrl}`);
+ const actorInformation = await fetchActorInformation(actorUrl);
+ if (!actorInformation || !actorInformation.inbox) {
+ console.log(`Skipping ${actorUrl}: No valid inbox`);
+ continue;
+ }
+
+ const inboxUrl = actorInformation.sharedInbox || actorInformation.inbox;
+ inboxes.add(inboxUrl.toString()); // Add to set to ensure unique delivery
+ }
+
+ for (const item of outbox.orderedItems) {
+ if (item.id === lastId) {
+ console.log(`${item.id} has already been posted - skipping`);
+ break;
+ }
+
+ if (item.object) {
+ item.object.published = new Date().toISOString();
+ }
+
+ for (const inboxUrl of inboxes) {
+ try {
+ console.log(`Sending to ${inboxUrl}`);
+
+ const response = await sendSignedRequest(new URL(inboxUrl), item, {
+ headers: {
+ "Accept": "application/activity+json",
+ "Content-Type": "application/activity+json",
+ },
+ });
+
+ console.log(`Send result: ${response.status} ${response.statusText}`);
+ const responseText = await response.text();
+ console.log("Response body:", responseText);
+
+ lastSuccessfulSentId = item.id;
+ } catch (error) {
+ console.error(`Error sending to ${inboxUrl}:`, error);
+ }
+ }
+ break; // Only send the latest post for now
+ }
+
+ await configRef.set({ lastId: lastSuccessfulSentId });
+
+ return {
+ statusCode: 200,
+ body: "ok",
+ };
+};
+
+export { handler };
+