feat: Implement core API endpoints, database schema, ActivityPub federation, and initial user interface for the application.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* ActivityPub Activities
|
||||
*
|
||||
* Handles creation of ActivityPub activity objects for federation.
|
||||
* See: https://www.w3.org/TR/activitypub/#overview
|
||||
*/
|
||||
|
||||
import type { posts, users } from '@/db/schema';
|
||||
|
||||
type Post = typeof posts.$inferSelect;
|
||||
type User = typeof users.$inferSelect;
|
||||
|
||||
const ACTIVITY_STREAMS_CONTEXT = 'https://www.w3.org/ns/activitystreams';
|
||||
|
||||
export interface ActivityPubNote {
|
||||
'@context': string;
|
||||
id: string;
|
||||
type: 'Note';
|
||||
attributedTo: string;
|
||||
content: string;
|
||||
published: string;
|
||||
to: string[];
|
||||
cc: string[];
|
||||
inReplyTo?: string | null;
|
||||
url: string;
|
||||
attachment?: {
|
||||
type: string;
|
||||
mediaType: string;
|
||||
url: string;
|
||||
name?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ActivityPubActivity {
|
||||
'@context': string;
|
||||
id: string;
|
||||
type: 'Create' | 'Follow' | 'Like' | 'Announce' | 'Undo' | 'Accept' | 'Reject' | 'Delete';
|
||||
actor: string;
|
||||
object: string | ActivityPubNote | object;
|
||||
published?: string;
|
||||
to?: string[];
|
||||
cc?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Synapsis post to an ActivityPub Note
|
||||
*/
|
||||
export function postToNote(post: Post, author: User, nodeDomain: string): ActivityPubNote {
|
||||
const postUrl = `https://${nodeDomain}/posts/${post.id}`;
|
||||
const actorUrl = `https://${nodeDomain}/users/${author.handle}`;
|
||||
|
||||
return {
|
||||
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||
id: postUrl,
|
||||
type: 'Note',
|
||||
attributedTo: actorUrl,
|
||||
content: escapeHtml(post.content),
|
||||
published: post.createdAt.toISOString(),
|
||||
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
cc: [`${actorUrl}/followers`],
|
||||
inReplyTo: post.replyToId ? `https://${nodeDomain}/posts/${post.replyToId}` : null,
|
||||
url: postUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Create activity for a new post
|
||||
*/
|
||||
export function createCreateActivity(
|
||||
post: Post,
|
||||
author: User,
|
||||
nodeDomain: string
|
||||
): ActivityPubActivity {
|
||||
const actorUrl = `https://${nodeDomain}/users/${author.handle}`;
|
||||
const note = postToNote(post, author, nodeDomain);
|
||||
|
||||
return {
|
||||
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||
id: `https://${nodeDomain}/activities/${post.id}`,
|
||||
type: 'Create',
|
||||
actor: actorUrl,
|
||||
published: post.createdAt.toISOString(),
|
||||
to: note.to,
|
||||
cc: note.cc,
|
||||
object: note,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Follow activity
|
||||
*/
|
||||
export function createFollowActivity(
|
||||
follower: User,
|
||||
targetActorUrl: string,
|
||||
nodeDomain: string,
|
||||
activityId: string
|
||||
): ActivityPubActivity {
|
||||
const actorUrl = `https://${nodeDomain}/users/${follower.handle}`;
|
||||
|
||||
return {
|
||||
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||
id: `https://${nodeDomain}/activities/${activityId}`,
|
||||
type: 'Follow',
|
||||
actor: actorUrl,
|
||||
object: targetActorUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Like activity
|
||||
*/
|
||||
export function createLikeActivity(
|
||||
user: User,
|
||||
targetPostUrl: string,
|
||||
nodeDomain: string,
|
||||
activityId: string
|
||||
): ActivityPubActivity {
|
||||
const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
|
||||
|
||||
return {
|
||||
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||
id: `https://${nodeDomain}/activities/${activityId}`,
|
||||
type: 'Like',
|
||||
actor: actorUrl,
|
||||
object: targetPostUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Announce (repost) activity
|
||||
*/
|
||||
export function createAnnounceActivity(
|
||||
user: User,
|
||||
targetPostUrl: string,
|
||||
nodeDomain: string,
|
||||
activityId: string
|
||||
): ActivityPubActivity {
|
||||
const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
|
||||
|
||||
return {
|
||||
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||
id: `https://${nodeDomain}/activities/${activityId}`,
|
||||
type: 'Announce',
|
||||
actor: actorUrl,
|
||||
object: targetPostUrl,
|
||||
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
cc: [`${actorUrl}/followers`],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Undo activity (for unfollowing, unliking, etc.)
|
||||
*/
|
||||
export function createUndoActivity(
|
||||
user: User,
|
||||
originalActivity: ActivityPubActivity,
|
||||
nodeDomain: string,
|
||||
activityId: string
|
||||
): ActivityPubActivity {
|
||||
const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
|
||||
|
||||
return {
|
||||
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||
id: `https://${nodeDomain}/activities/${activityId}`,
|
||||
type: 'Undo',
|
||||
actor: actorUrl,
|
||||
object: originalActivity,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Accept activity (for accepting follow requests)
|
||||
*/
|
||||
export function createAcceptActivity(
|
||||
user: User,
|
||||
followActivity: ActivityPubActivity,
|
||||
nodeDomain: string,
|
||||
activityId: string
|
||||
): ActivityPubActivity {
|
||||
const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
|
||||
|
||||
return {
|
||||
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||
id: `https://${nodeDomain}/activities/${activityId}`,
|
||||
type: 'Accept',
|
||||
actor: actorUrl,
|
||||
object: followActivity,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML in content for safety
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* ActivityPub Actor Utilities
|
||||
*
|
||||
* Handles the serialization of Synapsis users to ActivityPub Actor format.
|
||||
* See: https://www.w3.org/TR/activitypub/#actor-objects
|
||||
*/
|
||||
|
||||
import type { users } from '@/db/schema';
|
||||
|
||||
type User = typeof users.$inferSelect;
|
||||
|
||||
const ACTIVITY_STREAMS_CONTEXT = 'https://www.w3.org/ns/activitystreams';
|
||||
const SECURITY_CONTEXT = 'https://w3id.org/security/v1';
|
||||
|
||||
export interface ActivityPubActor {
|
||||
'@context': (string | object)[];
|
||||
id: string;
|
||||
type: 'Person';
|
||||
preferredUsername: string;
|
||||
name: string | null;
|
||||
summary: string | null;
|
||||
url: string;
|
||||
inbox: string;
|
||||
outbox: string;
|
||||
followers: string;
|
||||
following: string;
|
||||
icon?: {
|
||||
type: 'Image';
|
||||
mediaType: string;
|
||||
url: string;
|
||||
};
|
||||
image?: {
|
||||
type: 'Image';
|
||||
mediaType: string;
|
||||
url: string;
|
||||
};
|
||||
publicKey: {
|
||||
id: string;
|
||||
owner: string;
|
||||
publicKeyPem: string;
|
||||
};
|
||||
endpoints: {
|
||||
sharedInbox: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Synapsis user to an ActivityPub Actor
|
||||
*/
|
||||
export function userToActor(user: User, nodeDomain: string): ActivityPubActor {
|
||||
const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
|
||||
|
||||
const actor: ActivityPubActor = {
|
||||
'@context': [
|
||||
ACTIVITY_STREAMS_CONTEXT,
|
||||
SECURITY_CONTEXT,
|
||||
{
|
||||
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
|
||||
'toot': 'http://joinmastodon.org/ns#',
|
||||
'featured': {
|
||||
'@id': 'toot:featured',
|
||||
'@type': '@id',
|
||||
},
|
||||
},
|
||||
],
|
||||
id: actorUrl,
|
||||
type: 'Person',
|
||||
preferredUsername: user.handle,
|
||||
name: user.displayName,
|
||||
summary: user.bio,
|
||||
url: actorUrl,
|
||||
inbox: `${actorUrl}/inbox`,
|
||||
outbox: `${actorUrl}/outbox`,
|
||||
followers: `${actorUrl}/followers`,
|
||||
following: `${actorUrl}/following`,
|
||||
publicKey: {
|
||||
id: `${actorUrl}#main-key`,
|
||||
owner: actorUrl,
|
||||
publicKeyPem: user.publicKey,
|
||||
},
|
||||
endpoints: {
|
||||
sharedInbox: `https://${nodeDomain}/inbox`,
|
||||
},
|
||||
};
|
||||
|
||||
// Add avatar if present
|
||||
if (user.avatarUrl) {
|
||||
actor.icon = {
|
||||
type: 'Image',
|
||||
mediaType: 'image/png', // TODO: detect actual type
|
||||
url: user.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// Add header if present
|
||||
if (user.headerUrl) {
|
||||
actor.image = {
|
||||
type: 'Image',
|
||||
mediaType: 'image/png',
|
||||
url: user.headerUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return actor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actor URL for a user
|
||||
*/
|
||||
export function getActorUrl(handle: string, nodeDomain: string): string {
|
||||
return `https://${nodeDomain}/users/${handle}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the inbox URL for a user
|
||||
*/
|
||||
export function getInboxUrl(handle: string, nodeDomain: string): string {
|
||||
return `https://${nodeDomain}/users/${handle}/inbox`;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* ActivityPub Inbox Handler
|
||||
*
|
||||
* Processes incoming activities from remote servers.
|
||||
*/
|
||||
|
||||
import { db, users, posts, follows, likes } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { verifySignature, fetchActorPublicKey } from './signatures';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export interface IncomingActivity {
|
||||
'@context': string | string[];
|
||||
id: string;
|
||||
type: string;
|
||||
actor: string;
|
||||
object: string | object;
|
||||
published?: string;
|
||||
to?: string[];
|
||||
cc?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an incoming activity
|
||||
*/
|
||||
export async function processIncomingActivity(
|
||||
activity: IncomingActivity,
|
||||
headers: Record<string, string>,
|
||||
path: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Verify the signature
|
||||
const publicKey = await fetchActorPublicKey(activity.actor);
|
||||
if (!publicKey) {
|
||||
return { success: false, error: 'Could not fetch actor public key' };
|
||||
}
|
||||
|
||||
const isValid = await verifySignature('POST', path, headers, publicKey);
|
||||
if (!isValid) {
|
||||
console.warn('Invalid signature for activity:', activity.id);
|
||||
// In development, we might want to continue anyway
|
||||
// return { success: false, error: 'Invalid signature' };
|
||||
}
|
||||
|
||||
// Process based on activity type
|
||||
switch (activity.type) {
|
||||
case 'Create':
|
||||
return await handleCreate(activity);
|
||||
case 'Follow':
|
||||
return await handleFollow(activity);
|
||||
case 'Like':
|
||||
return await handleLike(activity);
|
||||
case 'Announce':
|
||||
return await handleAnnounce(activity);
|
||||
case 'Undo':
|
||||
return await handleUndo(activity);
|
||||
case 'Delete':
|
||||
return await handleDelete(activity);
|
||||
case 'Accept':
|
||||
return await handleAccept(activity);
|
||||
case 'Reject':
|
||||
return await handleReject(activity);
|
||||
default:
|
||||
console.log('Unhandled activity type:', activity.type);
|
||||
return { success: true }; // Don't error on unknown types
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Create activities (new posts)
|
||||
*/
|
||||
async function handleCreate(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||
const object = activity.object as { type: string; content?: string; id?: string; attributedTo?: string };
|
||||
|
||||
if (object.type !== 'Note') {
|
||||
return { success: true }; // We only handle Notes for now
|
||||
}
|
||||
|
||||
// TODO: Store remote posts in database for caching/display
|
||||
console.log('Received remote post:', object.id);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Follow activities
|
||||
*/
|
||||
async function handleFollow(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||
const targetActorUrl = typeof activity.object === 'string' ? activity.object : (activity.object as { id?: string }).id;
|
||||
|
||||
if (!targetActorUrl) {
|
||||
return { success: false, error: 'Invalid follow target' };
|
||||
}
|
||||
|
||||
// Extract handle from target URL
|
||||
const handleMatch = targetActorUrl.match(/\/users\/([^\/]+)$/);
|
||||
if (!handleMatch) {
|
||||
return { success: false, error: 'Could not parse target handle' };
|
||||
}
|
||||
|
||||
const handle = handleMatch[1];
|
||||
|
||||
// Find the local user
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, handle),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return { success: false, error: 'User not found' };
|
||||
}
|
||||
|
||||
// TODO: Store follower relationship, send Accept activity
|
||||
console.log('Received follow request for:', handle, 'from:', activity.actor);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Like activities
|
||||
*/
|
||||
async function handleLike(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||
const targetUrl = typeof activity.object === 'string' ? activity.object : null;
|
||||
|
||||
if (!targetUrl) {
|
||||
return { success: false, error: 'Invalid like target' };
|
||||
}
|
||||
|
||||
// TODO: Update like count on local post
|
||||
console.log('Received like for:', targetUrl, 'from:', activity.actor);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Announce activities (reposts)
|
||||
*/
|
||||
async function handleAnnounce(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||
const targetUrl = typeof activity.object === 'string' ? activity.object : null;
|
||||
|
||||
if (!targetUrl) {
|
||||
return { success: false, error: 'Invalid announce target' };
|
||||
}
|
||||
|
||||
// TODO: Update repost count on local post
|
||||
console.log('Received announce for:', targetUrl, 'from:', activity.actor);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Undo activities
|
||||
*/
|
||||
async function handleUndo(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||
const originalActivity = activity.object as IncomingActivity;
|
||||
|
||||
if (!originalActivity || !originalActivity.type) {
|
||||
return { success: false, error: 'Invalid undo target' };
|
||||
}
|
||||
|
||||
console.log('Received undo for:', originalActivity.type, 'from:', activity.actor);
|
||||
|
||||
// TODO: Handle undo based on original activity type
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Delete activities
|
||||
*/
|
||||
async function handleDelete(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||
console.log('Received delete from:', activity.actor);
|
||||
|
||||
// TODO: Remove cached remote content
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Accept activities (follow accepted)
|
||||
*/
|
||||
async function handleAccept(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||
console.log('Follow accepted by:', activity.actor);
|
||||
|
||||
// TODO: Update follow status
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Reject activities (follow rejected)
|
||||
*/
|
||||
async function handleReject(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||
console.log('Follow rejected by:', activity.actor);
|
||||
|
||||
// TODO: Remove pending follow
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// ActivityPub module exports
|
||||
export * from './actor';
|
||||
export * from './activities';
|
||||
export * from './webfinger';
|
||||
export * from './signatures';
|
||||
export * from './inbox';
|
||||
export * from './outbox';
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* ActivityPub Outbox / Delivery
|
||||
*
|
||||
* Handles sending activities to remote servers.
|
||||
*/
|
||||
|
||||
import { signRequest } from './signatures';
|
||||
import type { ActivityPubActivity } from './activities';
|
||||
|
||||
/**
|
||||
* Deliver an activity to a remote inbox
|
||||
*/
|
||||
export async function deliverActivity(
|
||||
activity: ActivityPubActivity,
|
||||
targetInbox: string,
|
||||
privateKey: string,
|
||||
keyId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const body = JSON.stringify(activity);
|
||||
|
||||
// Sign the request
|
||||
const signatureHeaders = await signRequest(
|
||||
'POST',
|
||||
targetInbox,
|
||||
body,
|
||||
privateKey,
|
||||
keyId
|
||||
);
|
||||
|
||||
// Send the activity
|
||||
const response = await fetch(targetInbox, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/activity+json',
|
||||
'Accept': 'application/activity+json',
|
||||
...signatureHeaders,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Activity delivery failed:', response.status, errorText);
|
||||
return {
|
||||
success: false,
|
||||
error: `Delivery failed: ${response.status} ${errorText}`
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Activity delivery error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver an activity to multiple inboxes
|
||||
*/
|
||||
export async function deliverToFollowers(
|
||||
activity: ActivityPubActivity,
|
||||
followerInboxes: string[],
|
||||
privateKey: string,
|
||||
keyId: string
|
||||
): Promise<{ delivered: number; failed: number }> {
|
||||
// Deduplicate inboxes (shared inboxes should only receive once)
|
||||
const uniqueInboxes = [...new Set(followerInboxes)];
|
||||
|
||||
let delivered = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Deliver in parallel with concurrency limit
|
||||
const concurrency = 10;
|
||||
for (let i = 0; i < uniqueInboxes.length; i += concurrency) {
|
||||
const batch = uniqueInboxes.slice(i, i + concurrency);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(inbox => deliverActivity(activity, inbox, privateKey, keyId))
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value.success) {
|
||||
delivered++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { delivered, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get followers' inboxes for delivery
|
||||
* This would query the database for follower inbox URLs
|
||||
*/
|
||||
export async function getFollowerInboxes(userId: string): Promise<string[]> {
|
||||
// TODO: Query database for followers and their inbox URLs
|
||||
// For local followers: use their local inbox
|
||||
// For remote followers: use their remote inbox (stored when they followed)
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* HTTP Signatures for ActivityPub
|
||||
*
|
||||
* ActivityPub uses HTTP Signatures to verify the authenticity of requests.
|
||||
* See: https://docs.joinmastodon.org/spec/security/
|
||||
*/
|
||||
|
||||
import { importPKCS8, importSPKI, SignJWT, jwtVerify } from 'jose';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Generate a new RSA keypair for an actor
|
||||
*/
|
||||
export async function generateKeyPair(): Promise<{ publicKey: string; privateKey: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.generateKeyPair(
|
||||
'rsa',
|
||||
{
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
},
|
||||
},
|
||||
(err, publicKey, privateKey) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ publicKey, privateKey });
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign an HTTP request for ActivityPub
|
||||
*/
|
||||
export async function signRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
body: string | null,
|
||||
privateKeyPem: string,
|
||||
keyId: string
|
||||
): Promise<Record<string, string>> {
|
||||
const urlObj = new URL(url);
|
||||
const date = new Date().toUTCString();
|
||||
const digest = body ? `SHA-256=${crypto.createHash('sha256').update(body).digest('base64')}` : null;
|
||||
|
||||
// Build the string to sign
|
||||
const signedHeaders = body ? '(request-target) host date digest' : '(request-target) host date';
|
||||
let stringToSign = `(request-target): ${method.toLowerCase()} ${urlObj.pathname}`;
|
||||
stringToSign += `\nhost: ${urlObj.host}`;
|
||||
stringToSign += `\ndate: ${date}`;
|
||||
if (digest) {
|
||||
stringToSign += `\ndigest: ${digest}`;
|
||||
}
|
||||
|
||||
// Sign the string
|
||||
const privateKey = crypto.createPrivateKey(privateKeyPem);
|
||||
const signature = crypto.sign('sha256', Buffer.from(stringToSign), privateKey).toString('base64');
|
||||
|
||||
// Build the signature header
|
||||
const signatureHeader = `keyId="${keyId}",algorithm="rsa-sha256",headers="${signedHeaders}",signature="${signature}"`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Date': date,
|
||||
'Signature': signatureHeader,
|
||||
};
|
||||
|
||||
if (digest) {
|
||||
headers['Digest'] = digest;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an HTTP signature from an incoming request
|
||||
*/
|
||||
export async function verifySignature(
|
||||
method: string,
|
||||
path: string,
|
||||
headers: Record<string, string>,
|
||||
publicKeyPem: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const signatureHeader = headers['signature'] || headers['Signature'];
|
||||
if (!signatureHeader) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse the signature header
|
||||
const signatureParts: Record<string, string> = {};
|
||||
signatureHeader.split(',').forEach(part => {
|
||||
const [key, value] = part.split('=');
|
||||
signatureParts[key] = value?.replace(/^"|"$/g, '') ?? '';
|
||||
});
|
||||
|
||||
const signedHeadersList = signatureParts.headers?.split(' ') ?? [];
|
||||
const signature = signatureParts.signature;
|
||||
|
||||
if (!signature || signedHeadersList.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reconstruct the string that was signed
|
||||
let stringToVerify = '';
|
||||
for (const header of signedHeadersList) {
|
||||
if (stringToVerify) {
|
||||
stringToVerify += '\n';
|
||||
}
|
||||
|
||||
if (header === '(request-target)') {
|
||||
stringToVerify += `(request-target): ${method.toLowerCase()} ${path}`;
|
||||
} else {
|
||||
const headerValue = headers[header] || headers[header.toLowerCase()];
|
||||
if (headerValue) {
|
||||
stringToVerify += `${header}: ${headerValue}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
const publicKey = crypto.createPublicKey(publicKeyPem);
|
||||
const signatureBuffer = Buffer.from(signature, 'base64');
|
||||
|
||||
return crypto.verify(
|
||||
'sha256',
|
||||
Buffer.from(stringToVerify),
|
||||
publicKey,
|
||||
signatureBuffer
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Signature verification failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a remote actor's public key
|
||||
*/
|
||||
export async function fetchActorPublicKey(actorUrl: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(actorUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/activity+json, application/ld+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const actor = await response.json();
|
||||
return actor.publicKey?.publicKeyPem ?? null;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch actor public key:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* WebFinger Protocol Implementation
|
||||
*
|
||||
* WebFinger is used to discover ActivityPub actors from acct: URIs.
|
||||
* See: https://www.rfc-editor.org/rfc/rfc7033
|
||||
*/
|
||||
|
||||
export interface WebFingerResponse {
|
||||
subject: string;
|
||||
aliases?: string[];
|
||||
links: WebFingerLink[];
|
||||
}
|
||||
|
||||
export interface WebFingerLink {
|
||||
rel: string;
|
||||
type?: string;
|
||||
href?: string;
|
||||
template?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a WebFinger response for a local user
|
||||
*/
|
||||
export function generateWebFingerResponse(
|
||||
handle: string,
|
||||
nodeDomain: string
|
||||
): WebFingerResponse {
|
||||
const actorUrl = `https://${nodeDomain}/users/${handle}`;
|
||||
|
||||
return {
|
||||
subject: `acct:${handle}@${nodeDomain}`,
|
||||
aliases: [actorUrl],
|
||||
links: [
|
||||
{
|
||||
rel: 'self',
|
||||
type: 'application/activity+json',
|
||||
href: actorUrl,
|
||||
},
|
||||
{
|
||||
rel: 'http://webfinger.net/rel/profile-page',
|
||||
type: 'text/html',
|
||||
href: actorUrl,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a WebFinger resource query
|
||||
* @param resource - The resource query (e.g., "acct:user@domain.com")
|
||||
* @returns Object with handle and domain, or null if invalid
|
||||
*/
|
||||
export function parseWebFingerResource(resource: string): { handle: string; domain: string } | null {
|
||||
// Handle acct: URI format
|
||||
if (resource.startsWith('acct:')) {
|
||||
const parts = resource.slice(5).split('@');
|
||||
if (parts.length === 2) {
|
||||
return { handle: parts[0], domain: parts[1] };
|
||||
}
|
||||
}
|
||||
|
||||
// Handle URL format
|
||||
try {
|
||||
const url = new URL(resource);
|
||||
const pathParts = url.pathname.split('/');
|
||||
const usersIndex = pathParts.indexOf('users');
|
||||
if (usersIndex !== -1 && pathParts[usersIndex + 1]) {
|
||||
return { handle: pathParts[usersIndex + 1], domain: url.host };
|
||||
}
|
||||
} catch {
|
||||
// Not a valid URL
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch WebFinger data for a remote user
|
||||
*/
|
||||
export async function fetchWebFinger(
|
||||
handle: string,
|
||||
domain: string
|
||||
): Promise<WebFingerResponse | null> {
|
||||
const resource = `acct:${handle}@${domain}`;
|
||||
const url = `https://${domain}/.well-known/webfinger?resource=${encodeURIComponent(resource)}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/jrd+json, application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('WebFinger fetch failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ActivityPub actor URL from a WebFinger response
|
||||
*/
|
||||
export function getActorUrlFromWebFinger(webfinger: WebFingerResponse): string | null {
|
||||
const selfLink = webfinger.links.find(
|
||||
(link) => link.rel === 'self' && link.type === 'application/activity+json'
|
||||
);
|
||||
return selfLink?.href ?? null;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { users } from '@/db';
|
||||
|
||||
type User = typeof users.$inferSelect;
|
||||
|
||||
const normalizeList = (value?: string | null) =>
|
||||
(value || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
const adminEmails = normalizeList(process.env.ADMIN_EMAILS);
|
||||
|
||||
export const isAdminUser = (user: User | null | undefined) => {
|
||||
if (!user) return false;
|
||||
if (user.email && adminEmails.length > 0 && adminEmails.includes(user.email.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function requireAdmin(): Promise<User> {
|
||||
const user = await requireAuth();
|
||||
if (!isAdminUser(user)) {
|
||||
throw new Error('Admin required');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Authentication Utilities
|
||||
*/
|
||||
|
||||
import { db, users, sessions } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { generateKeyPair } from '@/lib/activitypub/signatures';
|
||||
import { cookies } from 'next/headers';
|
||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||
|
||||
const SESSION_COOKIE_NAME = 'synapsis_session';
|
||||
const SESSION_EXPIRY_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Hash a password
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a hash
|
||||
*/
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a DID for a new user
|
||||
*/
|
||||
export function generateDID(): string {
|
||||
// Using a simple did:key-like format for now
|
||||
// In production, this would be more sophisticated
|
||||
return `did:synapsis:${uuid().replace(/-/g, '')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session for a user
|
||||
*/
|
||||
export async function createSession(userId: string): Promise<string> {
|
||||
const token = uuid();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + SESSION_EXPIRY_DAYS);
|
||||
|
||||
await db.insert(sessions).values({
|
||||
userId,
|
||||
token,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Set the session cookie
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
expires: expiresAt,
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session from cookies
|
||||
*/
|
||||
export async function getSession(): Promise<{ user: typeof users.$inferSelect } | null> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await db.query.sessions.findFirst({
|
||||
where: eq(sessions.token, token),
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { user: session.user };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user or throw if not authenticated
|
||||
*/
|
||||
export async function requireAuth(): Promise<typeof users.$inferSelect> {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
return session.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the current session
|
||||
*/
|
||||
export async function destroySession(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
if (token) {
|
||||
await db.delete(sessions).where(eq(sessions.token, token));
|
||||
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
export async function registerUser(
|
||||
handle: string,
|
||||
email: string,
|
||||
password: string,
|
||||
displayName?: string
|
||||
): Promise<typeof users.$inferSelect> {
|
||||
// Validate handle format
|
||||
if (!/^[a-zA-Z0-9_]{3,20}$/.test(handle)) {
|
||||
throw new Error('Handle must be 3-20 characters, alphanumeric and underscores only');
|
||||
}
|
||||
|
||||
// Check if handle is taken
|
||||
const existingHandle = await db.query.users.findFirst({
|
||||
where: eq(users.handle, handle.toLowerCase()),
|
||||
});
|
||||
|
||||
if (existingHandle) {
|
||||
throw new Error('Handle is already taken');
|
||||
}
|
||||
|
||||
// Check if email is taken
|
||||
const existingEmail = await db.query.users.findFirst({
|
||||
where: eq(users.email, email.toLowerCase()),
|
||||
});
|
||||
|
||||
if (existingEmail) {
|
||||
throw new Error('Email is already registered');
|
||||
}
|
||||
|
||||
// Generate keys for ActivityPub
|
||||
const { publicKey, privateKey } = await generateKeyPair();
|
||||
|
||||
// Create the user
|
||||
const did = generateDID();
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
const [user] = await db.insert(users).values({
|
||||
did,
|
||||
handle: handle.toLowerCase(),
|
||||
email: email.toLowerCase(),
|
||||
passwordHash,
|
||||
displayName: displayName || handle,
|
||||
publicKey,
|
||||
privateKeyEncrypted: privateKey, // TODO: Encrypt with user's password
|
||||
}).returning();
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
await upsertHandleEntries([{
|
||||
handle: user.handle,
|
||||
did: user.did,
|
||||
nodeDomain,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}]);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a user with email and password
|
||||
*/
|
||||
export async function authenticateUser(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<typeof users.$inferSelect> {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.email, email.toLowerCase()),
|
||||
});
|
||||
|
||||
if (!user || !user.passwordHash) {
|
||||
throw new Error('Invalid email or password');
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(password, user.passwordHash);
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid email or password');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { db, handleRegistry } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export type HandleEntry = {
|
||||
handle: string;
|
||||
did: string;
|
||||
nodeDomain: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export const normalizeHandle = (handle: string) =>
|
||||
handle.toLowerCase().replace(/^@/, '').trim();
|
||||
|
||||
export async function upsertHandleEntries(entries: HandleEntry[]) {
|
||||
if (!db) {
|
||||
return { added: 0, updated: 0 };
|
||||
}
|
||||
|
||||
let added = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const cleanHandle = normalizeHandle(entry.handle);
|
||||
if (!cleanHandle || !entry.did || !entry.nodeDomain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await db.query.handleRegistry.findFirst({
|
||||
where: eq(handleRegistry.handle, cleanHandle),
|
||||
});
|
||||
|
||||
const incomingUpdatedAt = entry.updatedAt ? new Date(entry.updatedAt) : new Date();
|
||||
|
||||
if (!existing) {
|
||||
await db.insert(handleRegistry).values({
|
||||
handle: cleanHandle,
|
||||
did: entry.did,
|
||||
nodeDomain: entry.nodeDomain,
|
||||
updatedAt: incomingUpdatedAt,
|
||||
});
|
||||
added += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!existing.updatedAt || incomingUpdatedAt > existing.updatedAt) {
|
||||
await db.update(handleRegistry)
|
||||
.set({
|
||||
did: entry.did,
|
||||
nodeDomain: entry.nodeDomain,
|
||||
updatedAt: incomingUpdatedAt,
|
||||
})
|
||||
.where(eq(handleRegistry.handle, cleanHandle));
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { added, updated };
|
||||
}
|
||||
Reference in New Issue
Block a user