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 }; +