Migrate user/profile URLs to /u/[handle] and remove legacy scripts
All user and post profile URLs have been updated from /[handle] to /u/[handle] for consistency. Added a redirect page for /posts/[id] to the canonical /u/[handle]/posts/[id] route. Deprecated and removed legacy scripts and documentation related to chat and federation endpoints. Push-based federation endpoints are now disabled in favor of real-time pull-based federation.
This commit is contained in:
-160
@@ -1,160 +0,0 @@
|
|||||||
# Swarm Chat
|
|
||||||
|
|
||||||
A real-time, end-to-end encrypted chat system built exclusively for the Synapsis Swarm network.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **End-to-End Encryption**: Messages are encrypted using recipient's public key
|
|
||||||
- **Cross-Node Messaging**: Chat with users on any Synapsis node
|
|
||||||
- **Real-Time Delivery**: Messages delivered instantly via swarm inbox
|
|
||||||
- **Read Receipts**: See when messages are delivered and read
|
|
||||||
- **Typing Indicators**: Know when someone is typing (coming soon)
|
|
||||||
- **Native Swarm Protocol**: Built specifically for the swarm network
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Database Schema
|
|
||||||
|
|
||||||
**chat_conversations**: Tracks conversations between users
|
|
||||||
- Stores participant info and last message preview
|
|
||||||
- Unique constraint ensures one conversation per user pair
|
|
||||||
|
|
||||||
**chat_messages**: Individual encrypted messages
|
|
||||||
- Content encrypted with recipient's public key
|
|
||||||
- Swarm message ID for deduplication
|
|
||||||
- Delivery and read status tracking
|
|
||||||
|
|
||||||
**chat_typing_indicators**: Real-time typing status (future)
|
|
||||||
|
|
||||||
### API Endpoints
|
|
||||||
|
|
||||||
**POST /api/swarm/chat/send**
|
|
||||||
- Send a message to any user (local or remote)
|
|
||||||
- Encrypts content with recipient's public key
|
|
||||||
- Delivers to remote nodes via swarm inbox
|
|
||||||
|
|
||||||
**POST /api/swarm/chat/inbox**
|
|
||||||
- Receives messages from other swarm nodes
|
|
||||||
- Validates and stores encrypted messages
|
|
||||||
- Updates conversation metadata
|
|
||||||
|
|
||||||
**GET /api/swarm/chat/conversations**
|
|
||||||
- Lists all conversations for current user
|
|
||||||
- Includes unread counts and last message preview
|
|
||||||
|
|
||||||
**GET /api/swarm/chat/messages**
|
|
||||||
- Fetches messages for a conversation
|
|
||||||
- Supports cursor-based pagination
|
|
||||||
- Returns encrypted content for client-side decryption
|
|
||||||
|
|
||||||
**PATCH /api/swarm/chat/messages**
|
|
||||||
- Marks messages as read
|
|
||||||
- Updates read receipts
|
|
||||||
|
|
||||||
### Encryption
|
|
||||||
|
|
||||||
Messages are encrypted using RSA-OAEP with SHA-256:
|
|
||||||
|
|
||||||
1. **Sending**: Message encrypted with recipient's public key
|
|
||||||
2. **Storage**: Only encrypted content stored in database
|
|
||||||
3. **Decryption**: Client-side decryption using user's private key
|
|
||||||
|
|
||||||
This ensures true end-to-end encryption - even the server cannot read messages.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Starting a Chat
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Send a message to start a conversation
|
|
||||||
await fetch('/api/swarm/chat/send', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
recipientHandle: 'user@remote.node',
|
|
||||||
content: 'Hello!',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Receiving Messages
|
|
||||||
|
|
||||||
Messages are automatically received via the swarm inbox endpoint. The system:
|
|
||||||
1. Validates the sender and recipient
|
|
||||||
2. Checks for duplicate messages
|
|
||||||
3. Creates or updates the conversation
|
|
||||||
4. Stores the encrypted message
|
|
||||||
5. Returns success to the sender
|
|
||||||
|
|
||||||
### Client-Side Decryption
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { decryptMessage } from '@/lib/swarm/chat-crypto';
|
|
||||||
|
|
||||||
// Decrypt a message using user's private key
|
|
||||||
const plaintext = decryptMessage(
|
|
||||||
message.encryptedContent,
|
|
||||||
userPrivateKey
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
1. **Private Key Storage**: User private keys should be encrypted at rest
|
|
||||||
2. **Key Derivation**: Consider using password-based key derivation
|
|
||||||
3. **Forward Secrecy**: Future enhancement - implement Signal protocol
|
|
||||||
4. **Message Signing**: Verify sender authenticity with signatures
|
|
||||||
5. **Rate Limiting**: Prevent spam and abuse
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
- [ ] Group chats (multi-party encryption)
|
|
||||||
- [ ] Voice/video calls (WebRTC)
|
|
||||||
- [ ] File attachments (encrypted)
|
|
||||||
- [ ] Message reactions
|
|
||||||
- [ ] Message editing/deletion
|
|
||||||
- [ ] Typing indicators (real-time)
|
|
||||||
- [ ] Online/offline status
|
|
||||||
- [ ] Push notifications
|
|
||||||
- [ ] Desktop notifications
|
|
||||||
- [ ] Message search
|
|
||||||
- [ ] Conversation archiving
|
|
||||||
- [ ] Block/mute users
|
|
||||||
- [ ] Forward secrecy (Signal protocol)
|
|
||||||
|
|
||||||
## Migration
|
|
||||||
|
|
||||||
Run the migration to create the chat tables:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run db:generate
|
|
||||||
npm run db:migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Test the chat system:
|
|
||||||
|
|
||||||
1. Create two accounts on different nodes
|
|
||||||
2. Navigate to `/chat` on one account
|
|
||||||
3. Click "New Chat" and enter the other user's handle
|
|
||||||
4. Send a message
|
|
||||||
5. Check the other account's `/chat` page
|
|
||||||
|
|
||||||
## Why Swarm Chat?
|
|
||||||
|
|
||||||
Swarm Chat was built from the ground up for the Synapsis network:
|
|
||||||
|
|
||||||
- **True E2E Encryption**: Messages encrypted with recipient's public key
|
|
||||||
- **Real-Time**: Direct delivery, no polling required
|
|
||||||
- **Proper Chat UX**: Conversations, read receipts, typing indicators
|
|
||||||
- **Lightweight**: Simple JSON protocol
|
|
||||||
- **Swarm-Native**: Built for the swarm, not retrofitted
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions welcome! Priority areas:
|
|
||||||
- Client-side encryption implementation
|
|
||||||
- Real-time updates (WebSocket/SSE)
|
|
||||||
- Mobile-responsive UI improvements
|
|
||||||
- Group chat support
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# Cloudflare Turnstile Setup Guide
|
|
||||||
|
|
||||||
Cloudflare Turnstile has been fully integrated into your Synapsis node to protect against bot registrations and logins.
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
1. **Admin Configuration**: Admins can add their Cloudflare Turnstile keys in the Admin Settings panel
|
|
||||||
2. **Automatic Activation**: Once both Site Key and Secret Key are configured, Turnstile is automatically enabled
|
|
||||||
3. **Frontend Integration**: The Turnstile widget appears on login and registration forms
|
|
||||||
4. **Server Verification**: All login/register requests are verified server-side with Cloudflare
|
|
||||||
|
|
||||||
## Setup Steps
|
|
||||||
|
|
||||||
### 1. Get Turnstile Keys from Cloudflare
|
|
||||||
|
|
||||||
1. Go to https://dash.cloudflare.com/?to=/:account/turnstile
|
|
||||||
2. Create a new site
|
|
||||||
3. Copy your **Site Key** (public) and **Secret Key** (private)
|
|
||||||
|
|
||||||
### 2. Configure in Admin Panel
|
|
||||||
|
|
||||||
1. Log in as an admin
|
|
||||||
2. Go to Admin → Settings tab
|
|
||||||
3. Scroll to "Cloudflare Turnstile (Bot Protection)" section
|
|
||||||
4. Paste your Site Key and Secret Key
|
|
||||||
5. Click "Save Settings"
|
|
||||||
|
|
||||||
### 3. Test It
|
|
||||||
|
|
||||||
1. Log out
|
|
||||||
2. Go to the login page
|
|
||||||
3. You should see the Turnstile widget appear
|
|
||||||
4. Complete the challenge and try logging in
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- ✅ Automatic widget rendering when keys are configured
|
|
||||||
- ✅ Works on both login and registration forms
|
|
||||||
- ✅ Server-side token verification
|
|
||||||
- ✅ Automatic widget reset on form errors
|
|
||||||
- ✅ Graceful fallback if Turnstile is not configured
|
|
||||||
- ✅ Submit button disabled until challenge is completed
|
|
||||||
- ✅ IP address forwarding for better verification
|
|
||||||
|
|
||||||
## Technical Details
|
|
||||||
|
|
||||||
### Database Schema
|
|
||||||
- `nodes.turnstile_site_key` - Public site key (exposed to frontend)
|
|
||||||
- `nodes.turnstile_secret_key` - Private secret key (server-side only)
|
|
||||||
|
|
||||||
### API Endpoints Modified
|
|
||||||
- `POST /api/auth/login` - Now accepts optional `turnstileToken`
|
|
||||||
- `POST /api/auth/register` - Now accepts optional `turnstileToken`
|
|
||||||
- `GET /api/node` - Returns `turnstileSiteKey` for frontend
|
|
||||||
|
|
||||||
### Files Modified
|
|
||||||
- `src/db/schema.ts` - Added Turnstile fields
|
|
||||||
- `src/lib/turnstile.ts` - Verification helper functions
|
|
||||||
- `src/app/api/auth/login/route.ts` - Token verification
|
|
||||||
- `src/app/api/auth/register/route.ts` - Token verification
|
|
||||||
- `src/app/api/node/route.ts` - Expose site key
|
|
||||||
- `src/app/api/admin/node/route.ts` - Save/update keys
|
|
||||||
- `src/app/admin/page.tsx` - Admin UI for configuration
|
|
||||||
- `src/app/login/page.tsx` - Frontend widget integration
|
|
||||||
|
|
||||||
## Security Notes
|
|
||||||
|
|
||||||
- The Secret Key is NEVER exposed to the frontend
|
|
||||||
- Only the Site Key is public
|
|
||||||
- Verification happens server-side with Cloudflare's API
|
|
||||||
- Failed verifications reject the login/registration attempt
|
|
||||||
- IP addresses are forwarded for better bot detection
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { db, users } from './src/db';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
|
|
||||||
async function checkKeys() {
|
|
||||||
const allUsers = await db.select({
|
|
||||||
handle: users.handle,
|
|
||||||
hasChatPublicKey: users.chatPublicKey,
|
|
||||||
hasChatPrivateKeyEncrypted: users.chatPrivateKeyEncrypted,
|
|
||||||
}).from(users);
|
|
||||||
|
|
||||||
console.log('Users and their chat keys:');
|
|
||||||
allUsers.forEach(u => {
|
|
||||||
console.log(`- ${u.handle}: chatPublicKey=${!!u.hasChatPublicKey}, chatPrivateKeyEncrypted=${!!u.hasChatPrivateKeyEncrypted}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
checkKeys().catch(console.error);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { db, chatMessages } from './src/db';
|
|
||||||
import { isNull } from 'drizzle-orm';
|
|
||||||
|
|
||||||
async function cleanOldMessages() {
|
|
||||||
console.log('Deleting old RSA-encrypted messages...');
|
|
||||||
|
|
||||||
const result = await db.delete(chatMessages)
|
|
||||||
.where(isNull(chatMessages.senderChatPublicKey));
|
|
||||||
|
|
||||||
console.log('Deleted old messages. Now only E2E encrypted messages remain.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanOldMessages().catch(console.error);
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
|
|
||||||
import { db } from './src/db/index';
|
|
||||||
import { users } from './src/db/schema';
|
|
||||||
import { chatConversations } from './src/db/schema';
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('--- USERS ---');
|
|
||||||
try {
|
|
||||||
const allUsers = await db.select().from(users);
|
|
||||||
console.log(`Found ${allUsers.length} users.`);
|
|
||||||
allUsers.forEach(u => {
|
|
||||||
console.log(`User: ${u.handle} | ID: ${u.id} | Local: ${!u.handle.includes('@')}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\n--- CONVERSATIONS ---');
|
|
||||||
const convs = await db.select().from(chatConversations);
|
|
||||||
console.log(`Found ${convs.length} conversations.`);
|
|
||||||
convs.forEach(c => {
|
|
||||||
console.log(`Conv: ${c.id} | Type: ${c.type} | P1: ${c.participant1Id} | P2Handle: ${c.participant2Handle}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error:', e);
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
main();
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import 'dotenv/config';
|
|
||||||
import { db } from './src/db/index';
|
|
||||||
import { chatMessages } from './src/db/schema';
|
|
||||||
import { desc } from 'drizzle-orm';
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('--- LATEST CHAT MESSAGES ---');
|
|
||||||
try {
|
|
||||||
const messages = await db.select().from(chatMessages).orderBy(desc(chatMessages.createdAt)).limit(20);
|
|
||||||
console.log(`Found ${messages.length} messages.`);
|
|
||||||
messages.forEach(m => {
|
|
||||||
console.log(`\nID: ${m.id}`);
|
|
||||||
console.log(`Sender: ${m.senderHandle}`);
|
|
||||||
console.log(`Created: ${m.createdAt}`);
|
|
||||||
console.log(`EncryptedContent (${m.encryptedContent?.length} chars): ${m.encryptedContent}`);
|
|
||||||
console.log(`SenderEncryptedContent (${m.senderEncryptedContent?.length} chars): ${m.senderEncryptedContent}`);
|
|
||||||
console.log('-----------------------------------');
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error:', e);
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -102,6 +102,7 @@ export async function GET(request: Request) {
|
|||||||
post: row.postId ? {
|
post: row.postId ? {
|
||||||
id: row.postId,
|
id: row.postId,
|
||||||
content: row.postContent,
|
content: row.postContent,
|
||||||
|
authorHandle: row.actorHandle, // The actor is the post author for likes/reposts
|
||||||
} : null,
|
} : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -115,7 +115,10 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this is a reply to a swarm post, deliver it to the origin node
|
// DEPRECATED: Push-based federation disabled
|
||||||
|
// Swarm now uses real-time pull-based federation
|
||||||
|
// Replies are fetched in real-time from the origin node
|
||||||
|
/*
|
||||||
if (data.swarmReplyTo) {
|
if (data.swarmReplyTo) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -153,6 +156,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
// Handle local mentions (create notifications for users on this node)
|
// Handle local mentions (create notifications for users on this node)
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
@@ -43,114 +43,12 @@ const swarmPostSchema = z.object({
|
|||||||
/**
|
/**
|
||||||
* POST /api/swarm/inbox
|
* POST /api/swarm/inbox
|
||||||
*
|
*
|
||||||
* Receives a post from another swarm node.
|
* DEPRECATED: This endpoint is disabled.
|
||||||
* Stores it for local users who follow the author.
|
* We now use real-time pull-based federation via /api/swarm/timeline
|
||||||
|
* instead of push-based caching.
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
return NextResponse.json({
|
||||||
if (!db) {
|
error: 'This endpoint is deprecated. Swarm uses real-time pull-based federation.',
|
||||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
}, { status: 410 }); // 410 Gone
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const data = swarmPostSchema.parse(body);
|
|
||||||
|
|
||||||
// Construct the swarm post ID
|
|
||||||
const swarmPostId = `swarm:${data.nodeDomain}:${data.post.id}`;
|
|
||||||
|
|
||||||
// Check if we already have this post
|
|
||||||
const existingPost = await db.query.posts.findFirst({
|
|
||||||
where: eq(posts.apId, swarmPostId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingPost) {
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Post already exists',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if anyone on this node follows the author
|
|
||||||
const authorActorUrl = `swarm://${data.nodeDomain}/${data.author.handle}`;
|
|
||||||
const hasFollowers = await db.query.remoteFollowers.findFirst({
|
|
||||||
where: eq(remoteFollowers.actorUrl, authorActorUrl),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Even if no one follows, we might want to cache for timeline
|
|
||||||
// For now, only store if someone follows
|
|
||||||
if (!hasFollowers) {
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: 'No local followers',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create placeholder user for the remote author
|
|
||||||
const remoteHandle = `${data.author.handle}@${data.nodeDomain}`;
|
|
||||||
let remoteUser = await db.query.users.findFirst({
|
|
||||||
where: eq(users.handle, remoteHandle),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!remoteUser) {
|
|
||||||
const [newUser] = await db.insert(users).values({
|
|
||||||
did: `did:swarm:${data.nodeDomain}:${data.author.handle}`,
|
|
||||||
handle: remoteHandle,
|
|
||||||
displayName: data.author.displayName,
|
|
||||||
avatarUrl: data.author.avatarUrl || null,
|
|
||||||
isNsfw: data.author.isNsfw,
|
|
||||||
publicKey: 'swarm-remote-user',
|
|
||||||
}).returning();
|
|
||||||
remoteUser = newUser;
|
|
||||||
} else {
|
|
||||||
// Update profile info if changed
|
|
||||||
await db.update(users)
|
|
||||||
.set({
|
|
||||||
displayName: data.author.displayName,
|
|
||||||
avatarUrl: data.author.avatarUrl || remoteUser.avatarUrl,
|
|
||||||
isNsfw: data.author.isNsfw,
|
|
||||||
})
|
|
||||||
.where(eq(users.id, remoteUser.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the post
|
|
||||||
const [newPost] = await db.insert(posts).values({
|
|
||||||
userId: remoteUser.id,
|
|
||||||
content: data.post.content,
|
|
||||||
isNsfw: data.post.isNsfw || data.author.isNsfw,
|
|
||||||
apId: swarmPostId,
|
|
||||||
apUrl: `https://${data.nodeDomain}/${data.author.handle}/posts/${data.post.id}`,
|
|
||||||
createdAt: new Date(data.post.createdAt),
|
|
||||||
linkPreviewUrl: data.post.linkPreviewUrl || null,
|
|
||||||
linkPreviewTitle: data.post.linkPreviewTitle || null,
|
|
||||||
linkPreviewDescription: data.post.linkPreviewDescription || null,
|
|
||||||
linkPreviewImage: data.post.linkPreviewImage || null,
|
|
||||||
}).returning();
|
|
||||||
|
|
||||||
// Store media attachments
|
|
||||||
if (data.post.media && data.post.media.length > 0) {
|
|
||||||
for (const m of data.post.media) {
|
|
||||||
await db.insert(media).values({
|
|
||||||
userId: remoteUser.id,
|
|
||||||
postId: newPost.id,
|
|
||||||
url: m.url,
|
|
||||||
mimeType: m.mimeType || null,
|
|
||||||
altText: m.altText || null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Swarm] Received post from ${remoteHandle}: ${newPost.id}`);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Post received',
|
|
||||||
postId: newPost.id,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
|
|
||||||
}
|
|
||||||
console.error('[Swarm] Inbox error:', error);
|
|
||||||
return NextResponse.json({ error: 'Failed to process post' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,86 +30,13 @@ const swarmReplySchema = z.object({
|
|||||||
/**
|
/**
|
||||||
* POST /api/swarm/replies
|
* POST /api/swarm/replies
|
||||||
*
|
*
|
||||||
* Receives a reply from another node in the swarm.
|
* DEPRECATED: This endpoint is disabled.
|
||||||
* The reply is stored as a remote reply linked to the local post.
|
* We now use real-time pull-based federation instead of push-based caching.
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
return NextResponse.json({
|
||||||
if (!db) {
|
error: 'This endpoint is deprecated. Swarm uses real-time pull-based federation.',
|
||||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
}, { status: 410 }); // 410 Gone
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const data = swarmReplySchema.parse(body);
|
|
||||||
|
|
||||||
// Verify the target post exists on this node
|
|
||||||
const targetPost = await db.query.posts.findFirst({
|
|
||||||
where: eq(posts.id, data.postId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!targetPost) {
|
|
||||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we already have this reply (by swarm ID)
|
|
||||||
const swarmReplyId = `swarm:${data.reply.nodeDomain}:${data.reply.id}`;
|
|
||||||
const existingReply = await db.query.posts.findFirst({
|
|
||||||
where: eq(posts.apId, swarmReplyId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingReply) {
|
|
||||||
return NextResponse.json({ success: true, message: 'Reply already exists' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// We need a system user to attribute swarm replies to
|
|
||||||
// For now, we'll store them with metadata in the apId/apUrl fields
|
|
||||||
// and create a virtual representation
|
|
||||||
|
|
||||||
// Get or create a placeholder user for this remote author
|
|
||||||
let remoteUser = await db.query.users.findFirst({
|
|
||||||
where: eq(users.handle, `${data.reply.author.handle}@${data.reply.nodeDomain}`),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!remoteUser) {
|
|
||||||
// Create a placeholder user for the remote author
|
|
||||||
const [newUser] = await db.insert(users).values({
|
|
||||||
did: `did:swarm:${data.reply.nodeDomain}:${data.reply.author.handle}`,
|
|
||||||
handle: `${data.reply.author.handle}@${data.reply.nodeDomain}`,
|
|
||||||
displayName: data.reply.author.displayName,
|
|
||||||
avatarUrl: data.reply.author.avatarUrl || null,
|
|
||||||
publicKey: 'swarm-remote-user', // Placeholder
|
|
||||||
}).returning();
|
|
||||||
remoteUser = newUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the reply post
|
|
||||||
const [replyPost] = await db.insert(posts).values({
|
|
||||||
userId: remoteUser.id,
|
|
||||||
content: data.reply.content,
|
|
||||||
replyToId: data.postId,
|
|
||||||
apId: swarmReplyId,
|
|
||||||
apUrl: `https://${data.reply.nodeDomain}/${data.reply.author.handle}/posts/${data.reply.id}`,
|
|
||||||
createdAt: new Date(data.reply.createdAt),
|
|
||||||
}).returning();
|
|
||||||
|
|
||||||
// Update the parent post's reply count
|
|
||||||
await db.update(posts)
|
|
||||||
.set({ repliesCount: targetPost.repliesCount + 1 })
|
|
||||||
.where(eq(posts.id, data.postId));
|
|
||||||
|
|
||||||
console.log(`[Swarm] Received reply from ${data.reply.nodeDomain} to post ${data.postId}`);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
replyId: replyPost.id,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
|
|
||||||
}
|
|
||||||
console.error('[Swarm] Reply error:', error);
|
|
||||||
return NextResponse.json({ error: 'Failed to process reply' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+13
-7
@@ -489,15 +489,21 @@ export default function ChatPage() {
|
|||||||
marginLeft: msg.isSentByMe ? 'auto' : '0',
|
marginLeft: msg.isSentByMe ? 'auto' : '0',
|
||||||
flexDirection: msg.isSentByMe ? 'row-reverse' : 'row'
|
flexDirection: msg.isSentByMe ? 'row-reverse' : 'row'
|
||||||
}}>
|
}}>
|
||||||
{!msg.isSentByMe && (
|
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
||||||
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
{msg.isSentByMe ? (
|
||||||
{msg.senderAvatarUrl ? (
|
user.avatarUrl ? (
|
||||||
|
<img src={user.avatarUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
user.displayName[0]
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
msg.senderAvatarUrl ? (
|
||||||
<img src={msg.senderAvatarUrl} alt="" />
|
<img src={msg.senderAvatarUrl} alt="" />
|
||||||
) : (
|
) : (
|
||||||
msg.senderDisplayName?.[0]
|
msg.senderDisplayName?.[0]
|
||||||
)}
|
)
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: msg.isSentByMe ? 'flex-end' : 'flex-start' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: msg.isSentByMe ? 'flex-end' : 'flex-start' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -682,7 +688,7 @@ export default function ChatPage() {
|
|||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap'
|
whiteSpace: 'nowrap'
|
||||||
}}>
|
}}>
|
||||||
{conv.lastMessagePreview}
|
{conv.lastMessagePreview === 'New message' ? 'Encrypted Message' : conv.lastMessagePreview}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ interface User {
|
|||||||
|
|
||||||
function UserCard({ user }: { user: User }) {
|
function UserCard({ user }: { user: User }) {
|
||||||
return (
|
return (
|
||||||
<Link href={`/${user.handle}`} className="user-card">
|
<Link href={`/u/${user.handle}`} className="user-card">
|
||||||
<div className="avatar">
|
<div className="avatar">
|
||||||
{user.avatarUrl ? (
|
{user.avatarUrl ? (
|
||||||
<img src={user.avatarUrl} alt={user.displayName} />
|
<img src={user.avatarUrl} alt={user.displayName} />
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function PostRedirect() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const id = params.id as string;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAndRedirect = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/posts/${id}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
router.push('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
router.push(`/u/${data.post.author.handle}/posts/${id}`);
|
||||||
|
} catch {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAndRedirect();
|
||||||
|
}, [id, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--foreground-tertiary)',
|
||||||
|
}}>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -111,7 +111,7 @@ export default function BotsPage() {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '12px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '12px' }}>
|
||||||
<div style={{ display: 'flex', gap: '12px', flex: 1, minWidth: 0 }}>
|
<div style={{ display: 'flex', gap: '12px', flex: 1, minWidth: 0 }}>
|
||||||
<Link
|
<Link
|
||||||
href={`/${bot.handle}`}
|
href={`/u/${bot.handle}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="avatar"
|
className="avatar"
|
||||||
style={{
|
style={{
|
||||||
@@ -147,7 +147,7 @@ export default function BotsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href={`/${bot.handle}`}
|
href={`/u/${bot.handle}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}
|
style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const stripHtml = (html: string | null | undefined): string | null => {
|
|||||||
|
|
||||||
function UserRow({ user }: { user: UserSummary }) {
|
function UserRow({ user }: { user: UserSummary }) {
|
||||||
return (
|
return (
|
||||||
<Link href={`/${user.handle}`} className="user-row">
|
<Link href={`/u/${user.handle}`} className="user-row">
|
||||||
<div className="avatar">
|
<div className="avatar">
|
||||||
{user.avatarUrl ? (
|
{user.avatarUrl ? (
|
||||||
<img src={user.avatarUrl} alt={user.displayName || user.handle} />
|
<img src={user.avatarUrl} alt={user.displayName || user.handle} />
|
||||||
@@ -215,7 +215,7 @@ export default function ProfilePage() {
|
|||||||
const handleComment = (post: Post) => {
|
const handleComment = (post: Post) => {
|
||||||
// Navigation is handled by the PostCard overlay,
|
// Navigation is handled by the PostCard overlay,
|
||||||
// but we can also use router.push if they explicitly click the comment button.
|
// but we can also use router.push if they explicitly click the comment button.
|
||||||
router.push(`/${post.author.handle}/posts/${post.id}`);
|
router.push(`/u/${post.author.handle}/posts/${post.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (postId: string) => {
|
const handleDelete = (postId: string) => {
|
||||||
@@ -624,7 +624,7 @@ export default function ProfilePage() {
|
|||||||
<>
|
<>
|
||||||
{' · Managed by '}
|
{' · Managed by '}
|
||||||
<Link
|
<Link
|
||||||
href={`/${(user as any).botOwner.handle}`}
|
href={`/u/${(user as any).botOwner.handle}`}
|
||||||
style={{ color: 'var(--accent)', fontWeight: 500 }}
|
style={{ color: 'var(--accent)', fontWeight: 500 }}
|
||||||
>
|
>
|
||||||
@{(user as any).botOwner.handle}
|
@{(user as any).botOwner.handle}
|
||||||
@@ -85,7 +85,7 @@ export default function PostDetailPage() {
|
|||||||
|
|
||||||
const handleDelete = (postId: string) => {
|
const handleDelete = (postId: string) => {
|
||||||
if (postId === id) {
|
if (postId === id) {
|
||||||
router.push(`/${handle}`);
|
router.push(`/u/${handle}`);
|
||||||
} else {
|
} else {
|
||||||
setReplies(prev => prev.filter(r => r.id !== postId));
|
setReplies(prev => prev.filter(r => r.id !== postId));
|
||||||
if (post) {
|
if (post) {
|
||||||
@@ -248,7 +248,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const postUrl = `/${post.author.handle}/posts/${post.id}`;
|
const postUrl = `/u/${post.author.handle}/posts/${post.id}`;
|
||||||
|
|
||||||
// Get the full handle for profile links (includes domain for remote users)
|
// Get the full handle for profile links (includes domain for remote users)
|
||||||
const getProfileHandle = () => {
|
const getProfileHandle = () => {
|
||||||
@@ -384,7 +384,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
return (
|
return (
|
||||||
<article className="post thread-parent">
|
<article className="post thread-parent">
|
||||||
<div className="post-header">
|
<div className="post-header">
|
||||||
<Link href={`/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
|
<Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="avatar">
|
<div className="avatar">
|
||||||
{post.author.avatarUrl ? (
|
{post.author.avatarUrl ? (
|
||||||
<img src={post.author.avatarUrl} alt={post.author.displayName || ''} />
|
<img src={post.author.avatarUrl} alt={post.author.displayName || ''} />
|
||||||
@@ -394,7 +394,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="post-author">
|
<div className="post-author">
|
||||||
<Link href={`/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
|
<Link href={`/u/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
|
||||||
{post.author.displayName || post.author.handle}
|
{post.author.displayName || post.author.handle}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="post-time">{formatFullHandle(post.author.handle, post.nodeDomain)}</span>
|
<span className="post-time">{formatFullHandle(post.author.handle, post.nodeDomain)}</span>
|
||||||
@@ -427,7 +427,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
{!isDetail && <Link href={postUrl} className="post-link-overlay" aria-label="View post" />}
|
{!isDetail && <Link href={postUrl} className="post-link-overlay" aria-label="View post" />}
|
||||||
|
|
||||||
<div className="post-header">
|
<div className="post-header">
|
||||||
<Link href={`/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
|
<Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="avatar">
|
<div className="avatar">
|
||||||
{post.author.avatarUrl ? (
|
{post.author.avatarUrl ? (
|
||||||
<img src={post.author.avatarUrl} alt={post.author.displayName} />
|
<img src={post.author.avatarUrl} alt={post.author.displayName} />
|
||||||
@@ -438,7 +438,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="post-author">
|
<div className="post-author">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
<Link href={`/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
|
<Link href={`/u/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
|
||||||
{post.author.displayName || post.author.handle}
|
{post.author.displayName || post.author.handle}
|
||||||
</Link>
|
</Link>
|
||||||
{(post.bot || post.author.isBot) && (
|
{(post.bot || post.author.isBot) && (
|
||||||
@@ -585,7 +585,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
|
|
||||||
{effectiveReplyTo && !showThread && (
|
{effectiveReplyTo && !showThread && (
|
||||||
<div className="post-reply-to">
|
<div className="post-reply-to">
|
||||||
Replying to <Link href={`/${effectiveReplyTo.author.handle}`} onClick={(e) => e.stopPropagation()}>{formatFullHandle(effectiveReplyTo.author.handle)}</Link>
|
Replying to <Link href={`/u/${effectiveReplyTo.author.handle}`} onClick={(e) => e.stopPropagation()}>{formatFullHandle(effectiveReplyTo.author.handle)}</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function RightSidebar() {
|
|||||||
{nodeInfo.admins.map((admin) => (
|
{nodeInfo.admins.map((admin) => (
|
||||||
<Link
|
<Link
|
||||||
key={admin.handle}
|
key={admin.handle}
|
||||||
href={`/${admin.handle}`}
|
href={`/u/${admin.handle}`}
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: '10px', textDecoration: 'none', color: 'inherit' }}
|
style={{ display: 'flex', alignItems: 'center', gap: '10px', textDecoration: 'none', color: 'inherit' }}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export function Sidebar() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{user ? (
|
{user ? (
|
||||||
<Link href={`/${user.handle}`} className={`nav-item ${pathname === '/' + user.handle ? 'active' : ''}`}>
|
<Link href={`/u/${user.handle}`} className={`nav-item ${pathname === '/u/' + user.handle ? 'active' : ''}`}>
|
||||||
<UserIcon />
|
<UserIcon />
|
||||||
<span>Profile</span>
|
<span>Profile</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
|
|
||||||
import { db } from './src/db/index';
|
|
||||||
import { users, chatConversations, chatMessages } from './src/db/schema';
|
|
||||||
import { eq, and } from 'drizzle-orm';
|
|
||||||
import { encryptMessage } from './src/lib/swarm/chat-crypto';
|
|
||||||
import crypto from 'crypto';
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('--- SIMULATING CHAT SEND ---');
|
|
||||||
try {
|
|
||||||
// 1. Get Sender (Cypher)
|
|
||||||
const sender = await db.query.users.findFirst({
|
|
||||||
where: eq(users.handle, 'cypher'),
|
|
||||||
});
|
|
||||||
if (!sender) throw new Error('Sender not found');
|
|
||||||
console.log('Sender found:', sender.handle);
|
|
||||||
|
|
||||||
// 2. Get Recipient (newinnightvale)
|
|
||||||
const recipientHandle = 'newinnightvale';
|
|
||||||
const recipient = await db.query.users.findFirst({
|
|
||||||
where: eq(users.handle, recipientHandle),
|
|
||||||
});
|
|
||||||
if (!recipient) throw new Error('Recipient not found');
|
|
||||||
console.log('Recipient found:', recipient.handle);
|
|
||||||
console.log('Recipient PK length:', recipient.publicKey?.length);
|
|
||||||
|
|
||||||
// 3. Encrypt
|
|
||||||
console.log('Encrypting...');
|
|
||||||
try {
|
|
||||||
const encrypted = encryptMessage('Hello World', recipient.publicKey);
|
|
||||||
console.log('Encryption successful. Length:', encrypted.length);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Encryption FAILED:', e);
|
|
||||||
// Dump key for manual inspection if needed (truncated)
|
|
||||||
console.log('Key start:', recipient.publicKey.substring(0, 50));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Simulation complete (not inserting to DB to avoid pollution, but encryption passed).');
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error:', e);
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
main();
|
|
||||||
Reference in New Issue
Block a user