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 ? {
|
||||
id: row.postId,
|
||||
content: row.postContent,
|
||||
authorHandle: row.actorHandle, // The actor is the post author for likes/reposts
|
||||
} : 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) {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -153,6 +156,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
})();
|
||||
}
|
||||
*/
|
||||
|
||||
// Handle local mentions (create notifications for users on this node)
|
||||
(async () => {
|
||||
|
||||
@@ -43,114 +43,12 @@ const swarmPostSchema = z.object({
|
||||
/**
|
||||
* POST /api/swarm/inbox
|
||||
*
|
||||
* Receives a post from another swarm node.
|
||||
* Stores it for local users who follow the author.
|
||||
* DEPRECATED: This endpoint is disabled.
|
||||
* We now use real-time pull-based federation via /api/swarm/timeline
|
||||
* instead of push-based caching.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
error: 'This endpoint is deprecated. Swarm uses real-time pull-based federation.',
|
||||
}, { status: 410 }); // 410 Gone
|
||||
}
|
||||
|
||||
@@ -30,86 +30,13 @@ const swarmReplySchema = z.object({
|
||||
/**
|
||||
* POST /api/swarm/replies
|
||||
*
|
||||
* Receives a reply from another node in the swarm.
|
||||
* The reply is stored as a remote reply linked to the local post.
|
||||
* DEPRECATED: This endpoint is disabled.
|
||||
* We now use real-time pull-based federation instead of push-based caching.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
error: 'This endpoint is deprecated. Swarm uses real-time pull-based federation.',
|
||||
}, { status: 410 }); // 410 Gone
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
-4
@@ -489,15 +489,21 @@ export default function ChatPage() {
|
||||
marginLeft: msg.isSentByMe ? 'auto' : '0',
|
||||
flexDirection: msg.isSentByMe ? 'row-reverse' : 'row'
|
||||
}}>
|
||||
{!msg.isSentByMe && (
|
||||
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
||||
{msg.senderAvatarUrl ? (
|
||||
{msg.isSentByMe ? (
|
||||
user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt="" />
|
||||
) : (
|
||||
user.displayName[0]
|
||||
)
|
||||
) : (
|
||||
msg.senderAvatarUrl ? (
|
||||
<img src={msg.senderAvatarUrl} alt="" />
|
||||
) : (
|
||||
msg.senderDisplayName?.[0]
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: msg.isSentByMe ? 'flex-end' : 'flex-start' }}>
|
||||
<div style={{
|
||||
@@ -682,7 +688,7 @@ export default function ChatPage() {
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{conv.lastMessagePreview}
|
||||
{conv.lastMessagePreview === 'New message' ? 'Encrypted Message' : conv.lastMessagePreview}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ interface User {
|
||||
|
||||
function UserCard({ user }: { user: User }) {
|
||||
return (
|
||||
<Link href={`/${user.handle}`} className="user-card">
|
||||
<Link href={`/u/${user.handle}`} className="user-card">
|
||||
<div className="avatar">
|
||||
{user.avatarUrl ? (
|
||||
<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', gap: '12px', flex: 1, minWidth: 0 }}>
|
||||
<Link
|
||||
href={`/${bot.handle}`}
|
||||
href={`/u/${bot.handle}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="avatar"
|
||||
style={{
|
||||
@@ -147,7 +147,7 @@ export default function BotsPage() {
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/${bot.handle}`}
|
||||
href={`/u/${bot.handle}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}
|
||||
>
|
||||
|
||||
@@ -35,7 +35,7 @@ const stripHtml = (html: string | null | undefined): string | null => {
|
||||
|
||||
function UserRow({ user }: { user: UserSummary }) {
|
||||
return (
|
||||
<Link href={`/${user.handle}`} className="user-row">
|
||||
<Link href={`/u/${user.handle}`} className="user-row">
|
||||
<div className="avatar">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.displayName || user.handle} />
|
||||
@@ -215,7 +215,7 @@ export default function ProfilePage() {
|
||||
const handleComment = (post: Post) => {
|
||||
// Navigation is handled by the PostCard overlay,
|
||||
// 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) => {
|
||||
@@ -624,7 +624,7 @@ export default function ProfilePage() {
|
||||
<>
|
||||
{' · Managed by '}
|
||||
<Link
|
||||
href={`/${(user as any).botOwner.handle}`}
|
||||
href={`/u/${(user as any).botOwner.handle}`}
|
||||
style={{ color: 'var(--accent)', fontWeight: 500 }}
|
||||
>
|
||||
@{(user as any).botOwner.handle}
|
||||
@@ -85,7 +85,7 @@ export default function PostDetailPage() {
|
||||
|
||||
const handleDelete = (postId: string) => {
|
||||
if (postId === id) {
|
||||
router.push(`/${handle}`);
|
||||
router.push(`/u/${handle}`);
|
||||
} else {
|
||||
setReplies(prev => prev.filter(r => r.id !== postId));
|
||||
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)
|
||||
const getProfileHandle = () => {
|
||||
@@ -384,7 +384,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
return (
|
||||
<article className="post thread-parent">
|
||||
<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">
|
||||
{post.author.avatarUrl ? (
|
||||
<img src={post.author.avatarUrl} alt={post.author.displayName || ''} />
|
||||
@@ -394,7 +394,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
</div>
|
||||
</Link>
|
||||
<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}
|
||||
</Link>
|
||||
<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" />}
|
||||
|
||||
<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">
|
||||
{post.author.avatarUrl ? (
|
||||
<img src={post.author.avatarUrl} alt={post.author.displayName} />
|
||||
@@ -438,7 +438,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
</Link>
|
||||
<div className="post-author">
|
||||
<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}
|
||||
</Link>
|
||||
{(post.bot || post.author.isBot) && (
|
||||
@@ -585,7 +585,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
|
||||
{effectiveReplyTo && !showThread && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ export function RightSidebar() {
|
||||
{nodeInfo.admins.map((admin) => (
|
||||
<Link
|
||||
key={admin.handle}
|
||||
href={`/${admin.handle}`}
|
||||
href={`/u/${admin.handle}`}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '10px', textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -144,7 +144,7 @@ export function Sidebar() {
|
||||
</Link>
|
||||
)}
|
||||
{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 />
|
||||
<span>Profile</span>
|
||||
</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