diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 41e2957..9edbfed 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -1,13 +1,12 @@
name: Build and Push Docker Image to GHCR
on:
- # Manual trigger only - you decide when to build
workflow_dispatch:
inputs:
tag:
- description: 'Image tag (default: latest)'
+ description: 'Optional explicit version tag (for example 2026.03.09.10). Leave blank to auto-increment.'
required: false
- default: 'latest'
+ default: ''
# Also build on version tags
push:
tags:
@@ -42,26 +41,22 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- - name: Extract metadata
- id: meta
- uses: docker/metadata-action@v5
- with:
- images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- tags: |
- type=semver,pattern={{version}}
- type=semver,pattern={{major}}.{{minor}}
- type=semver,pattern={{major}}
- type=sha,prefix=,suffix=,format=short
- # Use input tag for manual runs, 'latest' for tag pushes
- type=raw,value=${{ inputs.tag || 'latest' }}
-
- name: Build and push Docker image
- uses: docker/build-push-action@v5
- with:
- context: .
- file: ./docker/Dockerfile
- push: ${{ github.event_name != 'pull_request' }}
- tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
- no-cache: true
- platforms: linux/amd64,linux/arm64
+ env:
+ IMAGE_REPO: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ PLATFORMS: linux/amd64,linux/arm64
+ PRUNE_BUILD_CACHE: '0'
+ APP_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || '' }}
+ EXTRA_TAGS: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
+ run: |
+ if [ -n "$EXTRA_TAGS" ]; then
+ EXTRA_TAGS="${EXTRA_TAGS#v}"
+ export EXTRA_TAGS
+ if [ -z "$APP_VERSION" ]; then
+ APP_VERSION="$EXTRA_TAGS"
+ export APP_VERSION
+ fi
+ fi
+
+ chmod +x ./scripts/docker-publish.sh
+ ./scripts/docker-publish.sh
diff --git a/.tmp/remote-docker-doc.mdx b/.tmp/remote-docker-doc.mdx
new file mode 100644
index 0000000..6618fa4
--- /dev/null
+++ b/.tmp/remote-docker-doc.mdx
@@ -0,0 +1,247 @@
+# Quick Start with Docker
+
+Get your Synapsis node running in under 10 minutes with Docker. This is the **recommended and easiest** way to self-host.
+
+## Prerequisites
+
+- A Linux server with a domain pointed to it
+- A domain name pointed to your server
+
+## Quick Start (5 Minutes)
+
+```bash
+# 1. Bootstrap the deployment directory
+curl -fsSL https://synapsis.social/install.sh | bash
+
+# 2. Review the generated config
+nano /opt/synapsis/.env
+
+# 3. Start your node
+cd /opt/synapsis
+docker compose up -d
+```
+
+Done! Your node is live at `https://your-domain.com` with automatic SSL.
+
+## Existing nginx / reverse proxy host
+
+If your server already has nginx, Traefik, or another reverse proxy using `80/443`, use proxyless mode instead:
+
+```bash
+curl -fsSL https://synapsis.social/install.sh | PROXY=none bash
+nano /opt/synapsis/.env
+cd /opt/synapsis
+docker compose up -d
+```
+
+In `PROXY=none` mode:
+
+- Synapsis skips the bundled Caddy service
+- the app binds to `127.0.0.1:${PORT}`
+- your existing reverse proxy should forward traffic to that localhost port
+
+Example nginx upstream:
+
+```nginx
+location / {
+ proxy_pass http://127.0.0.1:3000;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+}
+```
+
+## Detailed Setup
+
+### 1. Bootstrap the Deployment Directory
+
+```bash
+curl -fsSL https://synapsis.social/install.sh | bash
+```
+
+If Docker is missing, the installer will install it for you on supported Linux hosts.
+
+This downloads:
+
+- `docker-compose.yml`
+- `Caddyfile`
+- `caddy-entrypoint.sh`
+- `.env.example`
+- `.env`
+
+The installer also generates `AUTH_SECRET` and `DB_PASSWORD` if `openssl` is available.
+
+### 2. Configure Environment
+
+Edit `/opt/synapsis/.env` with your settings:
+
+```bash
+nano /opt/synapsis/.env
+```
+
+**Required variables:**
+
+```env
+# Your domain
+DOMAIN=your-domain.com
+
+# Admin email(s)
+ADMIN_EMAILS=you@example.com
+
+# Generate with: openssl rand -hex 32
+AUTH_SECRET=your-secret-key-here
+
+# Database password
+DB_PASSWORD=your-secure-password
+```
+
+You can also set these before running the installer to prefill `.env`:
+
+```bash
+DOMAIN=your-domain.com
+ADMIN_EMAILS=you@example.com
+```
+
+Thatโs it. Save and exit.
+
+### 3. Start Synapsis
+
+```bash
+cd /opt/synapsis
+docker compose up -d
+```
+
+This starts:
+- **PostgreSQL** database
+- **Synapsis** app (pre-built image from GitHub Container Registry)
+- **Caddy** reverse proxy with automatic SSL
+
+Visit `https://your-domain.com` to create your admin account.
+
+## Storage Model
+
+### Node installation
+
+The node itself only needs Docker, PostgreSQL, and a domain to start.
+
+### User media storage
+
+At the moment, new user registration requires each account to provide **its own S3-compatible storage credentials** for media uploads. That requirement is enforced in the app today, so plan onboarding accordingly.
+
+## Configuration Reference
+
+### Core Variables
+
+| Variable | Required | Description |
+|----------|----------|-------------|
+| `DOMAIN` | Yes | Your domain name |
+| `AUTH_SECRET` | Yes | Generate with `openssl rand -hex 32` |
+| `ADMIN_EMAILS` | Yes | Comma-separated admin emails |
+| `DB_PASSWORD` | Yes | Database password |
+
+### Optional Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `BOT_MAX_PER_USER` | 5 | Max AI bots per user |
+| `PORT` | auto | Application port. Default installs use `auto` to scan 3000-3020. In `PROXY=none` mode this is the localhost port your reverse proxy should target. |
+| `NEXT_PUBLIC_NODE_DOMAIN` | `DOMAIN` | Override node domain if needed |
+| `NEXT_PUBLIC_APP_URL` | Derived from domain | Public URL used by background jobs |
+
+## Updating Synapsis
+
+Updating is a single command:
+
+```bash
+cd /opt/synapsis
+docker compose pull
+docker compose up -d
+```
+
+This will:
+- Pull the latest pre-built image from GitHub Container Registry
+- Recreate containers if needed
+- Run database migrations automatically
+- Keep your data volumes intact
+
+### Check Update Status
+
+```bash
+# See running containers
+docker compose ps
+
+# View logs
+docker compose logs -f app
+```
+
+## Troubleshooting
+
+### Container won't start
+
+```bash
+# Check logs
+docker compose logs app
+
+# Common issues:
+# - Missing required env vars
+# - Port 3000 already in use
+```
+
+### Database connection errors
+
+```bash
+# Check database container
+docker compose logs postgres
+
+# Verify env vars are loaded
+docker compose exec app env | grep DATABASE
+```
+
+### Port already in use
+
+If `80` or `443` is already in use, your server already has another reverse proxy bound there. Use `PROXY=none` instead of the default Caddy install:
+
+```bash
+curl -fsSL https://synapsis.social/install.sh | PROXY=none bash
+```
+
+For the application port itself, Synapsis uses `PORT=auto` by default, which automatically finds an available port between 3000-3020. If you need to use a specific port:
+
+```bash
+# Edit .env and set a specific port
+PORT=3001
+
+# Restart
+docker compose down
+docker compose up -d
+```
+
+### Reset everything (data loss!)
+
+```bash
+docker compose down -v # Remove containers AND volumes
+docker compose up -d # Start fresh
+```
+
+### View all logs
+
+```bash
+# All services
+docker compose logs -f
+
+# Just the app
+docker compose logs -f app
+
+# Just the database
+docker compose logs -f postgres
+```
+
+## Next Steps
+
+- [Configure your node settings](/user-guide)
+- [Learn about the Swarm](/swarm)
+- [Learn how Synapsis differs from traditional federation](/user-guide/synapsis-difference)
diff --git a/HOW_TO_WORK_ON_SYNAPIS.md b/HOW_TO_WORK_ON_SYNAPIS.md
index d080b92..e228548 100644
--- a/HOW_TO_WORK_ON_SYNAPIS.md
+++ b/HOW_TO_WORK_ON_SYNAPIS.md
@@ -68,21 +68,21 @@ git push origin main
Then build and push the multi-arch image:
```bash
-docker buildx build \
- --builder colima \
- --platform linux/amd64,linux/arm64 \
- -f docker/Dockerfile \
- -t ghcr.io/gnosyslabs/synapsis:latest \
- -t ghcr.io/gnosyslabs/synapsis:$(git rev-parse --short HEAD) \
- --push \
- .
+BUILDER=colima ./scripts/docker-publish.sh
```
That publishes:
- `ghcr.io/gnosyslabs/synapsis:latest`
+- `ghcr.io/gnosyslabs/synapsis:`
- `ghcr.io/gnosyslabs/synapsis:`
-If you are not on a Mac/Colima setup, swap `--builder colima` for whatever local buildx builder you use.
+If you are not on a Mac/Colima setup, set `BUILDER` to your buildx builder or leave it empty to use the default builder.
+
+To force an explicit version instead of auto-incrementing:
+
+```bash
+APP_VERSION=2026.03.09.10 BUILDER=colima ./scripts/docker-publish.sh
+```
## 4. Update The Server
Once a new image is published, update the server with:
diff --git a/README.md b/README.md
index 210e1c4..4facf69 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ curl -fsSL https://synapsis.social/install.sh | PROXY=none bash
**Updating (migrations run automatically):**
```bash
-docker compose pull && docker compose up -d
+curl -fsSL https://synapsis.social/update.sh | bash
```
**Full uninstall:**
diff --git a/docker/README.md b/docker/README.md
index ef19144..7d9d774 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -96,9 +96,20 @@ server {
## ๐ Updates (migrations run automatically)
+Use the updater for existing installs. It preserves `.env`, refreshes the installed compose/proxy files, pulls the latest published image, and restarts the stack.
+
+```bash
+curl -fsSL https://synapsis.social/update.sh | bash
+```
+
+If the host was installed with `PROXY=none`, the updater detects that and keeps using the proxyless compose file automatically.
+
+Manual update still works if you only want to restart with the already-installed files:
+
```bash
cd /opt/synapsis
-docker compose pull && docker compose up -d
+docker compose pull
+docker compose up -d
```
## ๐๏ธ Full Uninstall
@@ -222,6 +233,18 @@ cd synapsis/docker
docker compose up -d --build
```
+To publish a stamped GHCR image with embedded app version, commit, and build date:
+
+```bash
+cd /path/to/Synapsis
+./scripts/docker-publish.sh
+```
+
+That script automatically publishes:
+- `ghcr.io/gnosyslabs/synapsis:latest`
+- `ghcr.io/gnosyslabs/synapsis:`
+- `ghcr.io/gnosyslabs/synapsis:`
+
---
For full documentation, visit [docs.synapsis.social](https://docs.synapsis.social)
diff --git a/docker/__pycache__/host-updater.cpython-314.pyc b/docker/__pycache__/host-updater.cpython-314.pyc
new file mode 100644
index 0000000..70eccda
Binary files /dev/null and b/docker/__pycache__/host-updater.cpython-314.pyc differ
diff --git a/scripts/docker-publish.sh b/scripts/docker-publish.sh
index f9b1206..7b234ef 100755
--- a/scripts/docker-publish.sh
+++ b/scripts/docker-publish.sh
@@ -4,11 +4,13 @@ set -eu
IMAGE_REPO="${IMAGE_REPO:-ghcr.io/gnosyslabs/synapsis}"
PACKAGE_API="${PACKAGE_API:-/orgs/GnosysLabs/packages/container/synapsis/versions?per_page=100}"
-BUILDER="${BUILDER:-colima}"
-PLATFORMS="${PLATFORMS:-linux/amd64}"
+BUILDER="${BUILDER:-}"
+PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}"
DATE_PREFIX="${DATE_PREFIX:-$(date -u +%Y.%m.%d)}"
SOURCE_REPO="${SOURCE_REPO:-https://github.com/GnosysLabs/Synapsis}"
PRUNE_BUILD_CACHE="${PRUNE_BUILD_CACHE:-1}"
+APP_VERSION="${APP_VERSION:-}"
+EXTRA_TAGS="${EXTRA_TAGS:-}"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
@@ -24,56 +26,78 @@ require_command git
CURRENT_SHA="$(git rev-parse --short HEAD)"
CURRENT_FULL_SHA="$(git rev-parse HEAD)"
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
-
-existing_tags="$(
- gh api "${PACKAGE_API}" --paginate --jq '.[].metadata.container.tags[]?' 2>/dev/null || true
-)"
-
-max_build=0
-for tag in ${existing_tags}; do
- case "${tag}" in
- "${DATE_PREFIX}".*)
- build_number="${tag##${DATE_PREFIX}.}"
- case "${build_number}" in
- ''|*[!0-9]*)
- ;;
- *)
- if [ "${build_number}" -gt "${max_build}" ]; then
- max_build="${build_number}"
- fi
- ;;
- esac
- ;;
- esac
-done
-
-next_build=$((max_build + 1))
-APP_VERSION="${DATE_PREFIX}.${next_build}"
GITHUB_URL="${SOURCE_REPO}/commit/${CURRENT_FULL_SHA}"
+if [ -z "${APP_VERSION}" ]; then
+ existing_tags="$(
+ gh api "${PACKAGE_API}" --paginate --jq '.[].metadata.container.tags[]?' 2>/dev/null || true
+ )"
+
+ max_build=0
+ for tag in ${existing_tags}; do
+ case "${tag}" in
+ "${DATE_PREFIX}".*)
+ build_number="${tag##${DATE_PREFIX}.}"
+ case "${build_number}" in
+ ''|*[!0-9]*)
+ ;;
+ *)
+ if [ "${build_number}" -gt "${max_build}" ]; then
+ max_build="${build_number}"
+ fi
+ ;;
+ esac
+ ;;
+ esac
+ done
+
+ next_build=$((max_build + 1))
+ APP_VERSION="${DATE_PREFIX}.${next_build}"
+fi
+
+BUILD_ARGS="
+ --platform ${PLATFORMS}
+ --build-arg APP_VERSION=${APP_VERSION}
+ --build-arg APP_COMMIT=${CURRENT_FULL_SHA}
+ --build-arg APP_BUILD_DATE=${BUILD_DATE}
+ --build-arg APP_GITHUB_URL=${GITHUB_URL}
+ --build-arg APP_IMAGE_REPO=${IMAGE_REPO}
+ --build-arg APP_SOURCE_REPO=${SOURCE_REPO}
+"
+
+TAG_ARGS="
+ -t ${IMAGE_REPO}:latest
+ -t ${IMAGE_REPO}:${APP_VERSION}
+ -t ${IMAGE_REPO}:${CURRENT_SHA}
+"
+
+for extra_tag in ${EXTRA_TAGS}; do
+ TAG_ARGS="${TAG_ARGS}
+ -t ${IMAGE_REPO}:${extra_tag}"
+done
+
echo "========================================"
echo " Synapsis Docker Publish"
echo "========================================"
echo " Version: ${APP_VERSION}"
echo " Commit: ${CURRENT_SHA}"
+echo " Build Date: ${BUILD_DATE}"
echo " Image: ${IMAGE_REPO}"
+echo " Platforms: ${PLATFORMS}"
echo "========================================"
-docker buildx build \
- --builder "${BUILDER}" \
- --platform "${PLATFORMS}" \
- --build-arg "APP_VERSION=${APP_VERSION}" \
- --build-arg "APP_COMMIT=${CURRENT_FULL_SHA}" \
- --build-arg "APP_BUILD_DATE=${BUILD_DATE}" \
- --build-arg "APP_GITHUB_URL=${GITHUB_URL}" \
- --build-arg "APP_IMAGE_REPO=${IMAGE_REPO}" \
- --build-arg "APP_SOURCE_REPO=${SOURCE_REPO}" \
- -f docker/Dockerfile \
- -t "${IMAGE_REPO}:latest" \
- -t "${IMAGE_REPO}:${APP_VERSION}" \
- -t "${IMAGE_REPO}:${CURRENT_SHA}" \
- --push \
- .
+set -- docker buildx build
+
+if [ -n "${BUILDER}" ]; then
+ set -- "$@" --builder "${BUILDER}"
+fi
+
+# shellcheck disable=SC2086
+set -- "$@" $BUILD_ARGS -f docker/Dockerfile
+# shellcheck disable=SC2086
+set -- "$@" $TAG_ARGS --push .
+
+"$@"
echo ""
echo "โ
Published:"
@@ -81,8 +105,16 @@ echo " ${IMAGE_REPO}:latest"
echo " ${IMAGE_REPO}:${APP_VERSION}"
echo " ${IMAGE_REPO}:${CURRENT_SHA}"
+for extra_tag in ${EXTRA_TAGS}; do
+ echo " ${IMAGE_REPO}:${extra_tag}"
+done
+
if [ "${PRUNE_BUILD_CACHE}" = "1" ]; then
echo ""
echo "๐งน Pruning BuildKit cache"
- docker buildx prune --builder "${BUILDER}" -af >/dev/null
+ if [ -n "${BUILDER}" ]; then
+ docker buildx prune --builder "${BUILDER}" -af >/dev/null
+ else
+ docker buildx prune -af >/dev/null
+ fi
fi
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 846d8d0..4d25ce8 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -387,23 +387,6 @@ export default function AdminPage() {
);
}
- const formatTimestamp = (value?: string | null) => {
- if (!value) return 'Never';
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return value;
- return date.toLocaleString();
- };
-
- const updateStatusLabel = (() => {
- if (loadingUpdateStatus) return 'Checking...';
- if (!updateStatus) return 'Unavailable';
- if (!updateStatus.updater.available) return updateStatus.updater.message || 'Updater unavailable';
- if (updateStatus.updater.status === 'updating') return updateStatus.updater.message || 'Update in progress';
- if (updateStatus.updater.status === 'error') return updateStatus.updater.message || 'Last update failed';
- if (updateStatus.updateAvailable) return 'Update available';
- return 'Up to date';
- })();
-
return (
<>
)}
-
-
- Current build: {' '}
- {updateStatus?.current?.version || 'Unknown'}
-
-
- Latest build: {' '}
- {updateStatus?.latest?.version || 'Unavailable'}
-
-
- Status: {' '}
- {updateStatusLabel}
-
- {updateStatus?.updater.lastStartedAt && (
-
- Last started: {' '}
- {formatTimestamp(updateStatus.updater.lastStartedAt)}
-
- )}
- {updateStatus?.updater.lastFinishedAt && (
-
- Last finished: {' '}
- {formatTimestamp(updateStatus.updater.lastFinishedAt)}
-
- )}
- {typeof updateStatus?.updater.lastExitCode === 'number' && (
-
- Last exit code: {' '}
- {updateStatus.updater.lastExitCode}
-
- )}
- {updateStatus?.updater.trigger && (
-
- Last trigger: {' '}
- {updateStatus.updater.trigger === 'auto' ? 'Automatic update' : 'Manual update'}
-
- )}
- {updateStatus?.updater.lastError && (
-
- Last error: {' '}
- {updateStatus.updater.lastError}
-
- )}
-
-
{!updateStatus?.updater.available && (
({
+ id: node.id,
+ domain: node.domain,
+ name: node.name,
+ description: node.description,
+ isActive: node.isActive,
+ isBlocked: node.isBlocked,
+ blockReason: node.blockReason,
+ blockedAt: node.blockedAt,
+ lastSeenAt: node.lastSeenAt,
+ trustScore: node.trustScore,
+ isNsfw: node.isNsfw,
+ })),
+ });
+ } catch (error) {
+ console.error('Admin get nodes error:', error);
+ return NextResponse.json({ error: 'Failed to load nodes' }, { status: 500 });
+ }
+}
+
+export async function PATCH(request: NextRequest) {
+ try {
+ await requireAdmin();
+
+ const body = await request.json();
+ const data = mutateNodeSchema.parse(body);
+ const domain = normalizeNodeDomain(data.domain);
+ const localDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000');
+
+ if (domain === localDomain) {
+ return NextResponse.json({ error: 'Cannot block this node itself' }, { status: 400 });
+ }
+
+ const node = data.action === 'block'
+ ? await upsertBlockedNode(domain, data.reason || null)
+ : await unblockNode(domain);
+
+ if (!node) {
+ return NextResponse.json({ error: 'Node not found' }, { status: 404 });
+ }
+
+ return NextResponse.json({ node });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 });
+ }
+ console.error('Admin update nodes error:', error);
+ return NextResponse.json({ error: 'Failed to update node blocklist' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts
index 777d598..1cfc6f2 100644
--- a/src/app/api/auth/logout/route.ts
+++ b/src/app/api/auth/logout/route.ts
@@ -1,11 +1,25 @@
import { NextResponse } from 'next/server';
-import { destroySession } from '@/lib/auth';
+import { destroySession, getSession } from '@/lib/auth';
import { clearStorageSession } from '@/lib/storage/session';
+import { z } from 'zod';
-export async function POST() {
+const logoutSchema = z.object({
+ userId: z.string().uuid().optional(),
+});
+
+export async function POST(request: Request) {
try {
- await clearStorageSession();
- await destroySession();
+ const currentSession = await getSession();
+ const body = await request.json().catch(() => ({}));
+ const data = logoutSchema.parse(body);
+
+ const targetUserId = data.userId ?? currentSession?.user.id;
+
+ if (targetUserId) {
+ await clearStorageSession(targetUserId);
+ }
+
+ await destroySession(targetUserId);
return NextResponse.json({ success: true });
} catch (error) {
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
index a56e9c6..53eff47 100644
--- a/src/app/api/auth/me/route.ts
+++ b/src/app/api/auth/me/route.ts
@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
-import { getSession } from '@/lib/auth';
+import { getSession, getSessionAccounts } from '@/lib/auth';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
@@ -22,9 +22,10 @@ export async function GET() {
}
const session = await getSession();
+ const accounts = await getSessionAccounts();
if (!session) {
- return NextResponse.json({ user: null });
+ return NextResponse.json({ user: null, accounts: [] });
}
return NextResponse.json({
@@ -40,10 +41,11 @@ export async function GET() {
publicKey: session.user.publicKey,
privateKeyEncrypted: session.user.privateKeyEncrypted,
},
+ accounts,
});
} catch (error) {
console.error('Session check error:', error);
- return NextResponse.json({ user: null });
+ return NextResponse.json({ user: null, accounts: [] });
}
}
diff --git a/src/app/api/auth/switch/route.ts b/src/app/api/auth/switch/route.ts
new file mode 100644
index 0000000..8602dd3
--- /dev/null
+++ b/src/app/api/auth/switch/route.ts
@@ -0,0 +1,37 @@
+import { NextResponse } from 'next/server';
+import { switchSession } from '@/lib/auth';
+import { z } from 'zod';
+
+const switchSchema = z.object({
+ userId: z.string().uuid(),
+});
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const data = switchSchema.parse(body);
+ const session = await switchSession(data.userId);
+
+ return NextResponse.json({
+ success: true,
+ user: {
+ id: session.user.id,
+ handle: session.user.handle,
+ displayName: session.user.displayName,
+ avatarUrl: session.user.avatarUrl,
+ bio: session.user.bio,
+ website: session.user.website,
+ dmPrivacy: session.user.dmPrivacy,
+ did: session.user.did,
+ publicKey: session.user.publicKey,
+ privateKeyEncrypted: session.user.privateKeyEncrypted,
+ },
+ });
+ } catch (error) {
+ console.error('Switch session error:', error);
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Failed to switch account' },
+ { status: 400 }
+ );
+ }
+}
diff --git a/src/app/api/bots/[id]/api-key/route.ts b/src/app/api/bots/[id]/api-key/route.ts
index 5e7b375..c88b931 100644
--- a/src/app/api/bots/[id]/api-key/route.ts
+++ b/src/app/api/bots/[id]/api-key/route.ts
@@ -24,7 +24,7 @@ type RouteContext = { params: Promise<{ id: string }> };
// Schema for setting API key
const setApiKeySchema = z.object({
apiKey: z.string().min(1, 'API key is required'),
- provider: z.enum(['openrouter', 'openai', 'anthropic']).optional(),
+ provider: z.enum(['openrouter', 'openai', 'anthropic', 'custom']).optional(),
});
/**
diff --git a/src/app/api/bots/[id]/route.ts b/src/app/api/bots/[id]/route.ts
index e2eefbc..6367d7a 100644
--- a/src/app/api/bots/[id]/route.ts
+++ b/src/app/api/bots/[id]/route.ts
@@ -34,8 +34,9 @@ const updateBotSchema = z.object({
maxTokens: z.number().int().min(1).max(100000),
responseStyle: z.string().optional(),
}).optional(),
- llmProvider: z.enum(['openrouter', 'openai', 'anthropic']).optional(),
+ llmProvider: z.enum(['openrouter', 'openai', 'anthropic', 'custom']).optional(),
llmModel: z.string().min(1).optional(),
+ llmEndpoint: z.string().url().optional().nullable(),
llmApiKey: z.string().min(1).optional(),
schedule: z.object({
type: z.enum(['interval', 'times', 'cron']),
@@ -91,6 +92,7 @@ export async function GET(request: Request, context: RouteContext) {
personalityConfig: bot.personalityConfig,
llmProvider: bot.llmProvider,
llmModel: bot.llmModel,
+ llmEndpoint: bot.llmEndpoint,
scheduleConfig: bot.scheduleConfig,
autonomousMode: bot.autonomousMode,
isActive: bot.isActive,
@@ -162,6 +164,7 @@ async function handleUpdate(request: Request, context: RouteContext) {
if (data.personality !== undefined) updateInput.personality = data.personality;
if (data.llmProvider !== undefined) updateInput.llmProvider = data.llmProvider;
if (data.llmModel !== undefined) updateInput.llmModel = data.llmModel;
+ if (data.llmEndpoint !== undefined) updateInput.llmEndpoint = data.llmEndpoint;
if (data.llmApiKey !== undefined) updateInput.llmApiKey = data.llmApiKey;
if (data.schedule !== undefined) updateInput.schedule = data.schedule;
if (data.autonomousMode !== undefined) updateInput.autonomousMode = data.autonomousMode;
@@ -183,6 +186,7 @@ async function handleUpdate(request: Request, context: RouteContext) {
personalityConfig: updatedBot.personalityConfig,
llmProvider: updatedBot.llmProvider,
llmModel: updatedBot.llmModel,
+ llmEndpoint: updatedBot.llmEndpoint,
scheduleConfig: updatedBot.scheduleConfig,
autonomousMode: updatedBot.autonomousMode,
isActive: updatedBot.isActive,
diff --git a/src/app/api/bots/route.ts b/src/app/api/bots/route.ts
index 1b4844a..1fdbf5c 100644
--- a/src/app/api/bots/route.ts
+++ b/src/app/api/bots/route.ts
@@ -33,8 +33,9 @@ const createBotSchema = z.object({
maxTokens: z.number().int().min(1).max(100000),
responseStyle: z.string().optional(),
}),
- llmProvider: z.enum(['openrouter', 'openai', 'anthropic']),
+ llmProvider: z.enum(['openrouter', 'openai', 'anthropic', 'custom']),
llmModel: z.string().min(1),
+ llmEndpoint: z.string().url().optional(),
llmApiKey: z.string().min(1),
schedule: z.object({
type: z.enum(['interval', 'times', 'cron']),
@@ -96,6 +97,7 @@ export async function POST(request: Request) {
personality: data.personality,
llmProvider: data.llmProvider,
llmModel: data.llmModel,
+ llmEndpoint: data.llmEndpoint,
llmApiKey: data.llmApiKey,
schedule: data.schedule,
autonomousMode: data.autonomousMode,
@@ -114,6 +116,7 @@ export async function POST(request: Request) {
personalityConfig: bot.personalityConfig,
llmProvider: bot.llmProvider,
llmModel: bot.llmModel,
+ llmEndpoint: bot.llmEndpoint,
scheduleConfig: bot.scheduleConfig,
autonomousMode: bot.autonomousMode,
isActive: bot.isActive,
@@ -189,6 +192,7 @@ export async function GET() {
personalityConfig: bot.personalityConfig,
llmProvider: bot.llmProvider,
llmModel: bot.llmModel,
+ llmEndpoint: bot.llmEndpoint,
scheduleConfig: bot.scheduleConfig,
autonomousMode: bot.autonomousMode,
isActive: bot.isActive,
diff --git a/src/app/api/chat/receive/route.ts b/src/app/api/chat/receive/route.ts
index 14f3b91..3843bba 100644
--- a/src/app/api/chat/receive/route.ts
+++ b/src/app/api/chat/receive/route.ts
@@ -6,6 +6,7 @@ import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-sign
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache';
import { z } from 'zod';
+import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
const signedChatActionSchema = z.object({
action: z.string().min(1),
@@ -76,6 +77,11 @@ export async function POST(request: NextRequest) {
let fullSenderHandle: string | null = null;
if (swarmSignature && sourceDomain && body.userAction) {
+ const normalizedSourceDomain = normalizeNodeDomain(sourceDomain);
+ if (await isNodeBlocked(normalizedSourceDomain)) {
+ return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
+ }
+
// Federated envelope format - validate and verify node signature
const envelopeValidation = federatedEnvelopeSchema.safeParse(body);
if (!envelopeValidation.success) {
@@ -117,6 +123,12 @@ export async function POST(request: NextRequest) {
// Use full handle if provided in envelope, otherwise fall back to signed handle
const senderHandle = fullSenderHandle || handle;
+ const senderDomainFromHandle = senderHandle.includes('@')
+ ? normalizeNodeDomain(senderHandle.split('@').pop() || '')
+ : null;
+ if (senderDomainFromHandle && await isNodeBlocked(senderDomainFromHandle)) {
+ return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
+ }
console.log(`[Chat Receive] From: ${senderHandle} (DID: ${did}), To: ${recipientDid}`);
// 1. Resolve Sender Public Key
@@ -134,21 +146,25 @@ export async function POST(request: NextRequest) {
// Derive domain from full sender handle if possible
if (senderHandle.includes('@')) {
const parts = senderHandle.split('@');
- senderNodeDomain = parts[parts.length - 1];
+ senderNodeDomain = normalizeNodeDomain(parts[parts.length - 1]);
} else {
// Try to get from header first
const sourceDomainHeader = request.headers.get('X-Swarm-Source-Domain');
if (sourceDomainHeader) {
- senderNodeDomain = sourceDomainHeader;
+ senderNodeDomain = normalizeNodeDomain(sourceDomainHeader);
} else {
// Try handle registry (though we likely don't have it if we don't have the user)
const registryEntry = await db.query.handleRegistry.findFirst({
where: eq(handleRegistry.did, did)
});
- if (registryEntry) senderNodeDomain = registryEntry.nodeDomain;
+ if (registryEntry) senderNodeDomain = normalizeNodeDomain(registryEntry.nodeDomain);
}
}
+ if (senderNodeDomain && await isNodeBlocked(senderNodeDomain)) {
+ return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
+ }
+
if (senderNodeDomain) {
try {
const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https';
diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts
index 1b2bbb4..0fc2c0e 100644
--- a/src/app/api/chat/send/route.ts
+++ b/src/app/api/chat/send/route.ts
@@ -6,6 +6,7 @@ import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
import { createSignedPayload } from '@/lib/swarm/signature';
import { federatedHandleSchema } from '@/lib/utils/federation';
+import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
const chatSendSchema = z.object({
recipientDid: z.string().min(1).regex(/^did:/, 'Must be a valid DID (did:key:... or did:synapsis:...)'),
@@ -167,6 +168,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Recipient node not found' }, { status: 404 });
}
+ targetDomain = normalizeNodeDomain(targetDomain);
+ if (await isNodeBlocked(targetDomain)) {
+ return NextResponse.json({ error: 'This node is blocked by the server administrator' }, { status: 403 });
+ }
+
console.log(`[Remote Send] Sending to ${targetHandle} at ${targetDomain}`);
// 2. Send to Remote Node (Forward the Signed Action)
diff --git a/src/app/api/media/preview/route.ts b/src/app/api/media/preview/route.ts
index ad57f7f..38cff04 100644
--- a/src/app/api/media/preview/route.ts
+++ b/src/app/api/media/preview/route.ts
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
+import type { LinkPreviewData } from '@/lib/media/linkPreview';
+import { fetchRedditRichPreview } from '@/lib/media/redditPreview';
+import { fetchGenericLinkPreview } from '@/lib/media/genericPreview';
-/**
- * Check if a URL is from Reddit.
- */
function isRedditUrl(url: string): boolean {
try {
const parsed = new URL(url);
@@ -12,58 +12,16 @@ function isRedditUrl(url: string): boolean {
}
}
-/**
- * Fetch preview for Reddit URLs using their oEmbed API.
- */
-async function fetchRedditPreview(url: string): Promise<{
- url: string;
- title: string | null;
- description: string | null;
- image: string | null;
-} | null> {
- try {
- const oembedUrl = `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}`;
-
- const response = await fetch(oembedUrl, {
- headers: { 'Accept': 'application/json' },
- signal: AbortSignal.timeout(5000),
- });
-
- if (!response.ok) {
- return null;
- }
-
- const data = await response.json();
-
- // Extract title - try title field first, then parse from HTML
- let title = data.title || null;
- if (!title && data.html) {
- const titleMatch = data.html.match(/href="[^"]+">([^<]+)<\/a>/);
- if (titleMatch && titleMatch[1] && titleMatch[1] !== 'Comment') {
- title = titleMatch[1];
- }
- }
-
- // Build description from subreddit info
- let description = null;
- if (data.author_name) {
- description = `Posted by ${data.author_name}`;
- } else if (data.html) {
- const subredditMatch = data.html.match(/r\/([a-zA-Z0-9_]+)/);
- if (subredditMatch) {
- description = `r/${subredditMatch[1]}`;
- }
- }
-
- return {
- url,
- title,
- description,
- image: data.thumbnail_url || null,
- };
- } catch {
- return null;
- }
+function buildBasicPreview(url: string, title?: string | null, description?: string | null, image?: string | null): LinkPreviewData {
+ return {
+ url,
+ title: title || url,
+ description: description || null,
+ image: image || null,
+ type: image ? 'image' : 'card',
+ videoUrl: null,
+ media: image ? [{ url: image }] : null,
+ };
}
export async function GET(req: NextRequest) {
@@ -75,68 +33,30 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'No URL provided' }, { status: 400 });
}
- // Normalize URL
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
- // Use Reddit-specific handler
if (isRedditUrl(url)) {
- const preview = await fetchRedditPreview(url);
+ const preview = await fetchRedditRichPreview(url);
if (preview) {
return NextResponse.json(preview);
}
- // Fall back to URL-only response if oEmbed fails
- return NextResponse.json({
- url,
- title: 'Reddit',
- description: null,
- image: null,
- });
+
+ return NextResponse.json(buildBasicPreview(url, 'Reddit'));
}
- // Generic OG tag scraping for other sites
- let response;
- try {
- response = await fetch(url, {
- headers: {
- 'User-Agent': 'Mozilla/5.0 (compatible; SynapsisBot/1.0; +https://synapsis.social)',
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
- 'Accept-Language': 'en-US,en;q=0.5',
- },
- signal: AbortSignal.timeout(5000),
- });
- } catch (fetchError) {
- console.warn(`Fetch failed for URL: ${url}`, fetchError);
+ const preview = await fetchGenericLinkPreview(url);
+ if (!preview) {
return NextResponse.json({ error: 'Could not reach the URL' }, { status: 404 });
}
- if (!response.ok) {
- return NextResponse.json({ error: `URL returned status ${response.status}` }, { status: 404 });
- }
-
- const html = await response.text();
-
- const getMeta = (property: string) => {
- const regex = new RegExp(` ]+(?:property|name)=["'](?:og:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i');
- const match = html.match(regex);
- if (match) return match[1];
-
- const regexRev = new RegExp(` ]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:)?${property}["']`, 'i');
- const matchRev = html.match(regexRev);
- return matchRev ? matchRev[1] : null;
- };
-
- const title = getMeta('title') || html.match(/
([^<]+)<\/title>/i)?.[1];
- const description = getMeta('description');
- const image = getMeta('image');
-
- return NextResponse.json({
- url,
- title: title?.trim() || url,
- description: description?.trim() || null,
- image: image?.trim() || null,
- });
+ return NextResponse.json(buildBasicPreview(
+ preview.url,
+ preview.title?.trim() || url,
+ preview.description?.trim() || null,
+ preview.image?.trim() || null,
+ ));
} catch (error) {
console.error('Link preview error:', error);
return NextResponse.json({ error: 'Failed to fetch preview' }, { status: 500 });
diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts
index 97533db..b31e26a 100644
--- a/src/app/api/notifications/route.ts
+++ b/src/app/api/notifications/route.ts
@@ -99,6 +99,15 @@ export async function GET(request: Request) {
avatarUrl: freshProfile?.avatarUrl || row.actorAvatarUrl,
nodeDomain: row.actorNodeDomain,
},
+ target: row.targetHandle ? {
+ handle: row.targetNodeDomain
+ ? `${row.targetHandle}@${row.targetNodeDomain}`
+ : row.targetHandle,
+ displayName: row.targetDisplayName,
+ avatarUrl: row.targetAvatarUrl,
+ nodeDomain: row.targetNodeDomain,
+ isBot: row.targetIsBot,
+ } : null,
post: row.postId ? {
id: row.postId,
content: row.postContent,
diff --git a/src/app/api/posts/[id]/like/route.ts b/src/app/api/posts/[id]/like/route.ts
index 55e56d2..f49210f 100644
--- a/src/app/api/posts/[id]/like/route.ts
+++ b/src/app/api/posts/[id]/like/route.ts
@@ -1,9 +1,12 @@
import { NextResponse } from 'next/server';
import { db, posts, likes, users, notifications, userSwarmLikes } from '@/db';
+import { requireAuth } from '@/lib/auth';
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import crypto from 'crypto';
+import { buildNotificationTarget } from '@/lib/notifications';
+import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
type RouteContext = { params: Promise<{ id: string }> };
@@ -13,6 +16,25 @@ const postIdSchema = z.union([
z.string().regex(/^swarm:[^:]+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, 'Invalid swarm post ID format'),
]);
+function isSignedActionPayload(payload: unknown): payload is SignedAction {
+ if (!payload || typeof payload !== 'object') return false;
+ const value = payload as Record;
+ return typeof value.action === 'string'
+ && typeof value.did === 'string'
+ && typeof value.handle === 'string'
+ && typeof value.ts === 'number'
+ && typeof value.nonce === 'string'
+ && typeof value.sig === 'string'
+ && typeof value.data === 'object'
+ && value.data !== null;
+}
+
+async function readOptionalJson(request: Request) {
+ const rawBody = await request.text();
+ if (!rawBody.trim()) return null;
+ return JSON.parse(rawBody);
+}
+
/**
* Extract domain from a swarm post ID (swarm:domain:postId)
*/
@@ -71,6 +93,9 @@ async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) {
linkPreviewTitle: post.linkPreviewTitle || null,
linkPreviewDescription: post.linkPreviewDescription || null,
linkPreviewImage: post.linkPreviewImage || null,
+ linkPreviewType: post.linkPreviewType || null,
+ linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
+ linkPreviewMediaJson: serializeLinkPreviewMedia(post.linkPreviewMedia),
mediaJson: post.media ? JSON.stringify(post.media) : null,
};
} catch {
@@ -81,15 +106,19 @@ async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) {
// Like a post
export async function POST(request: Request, context: RouteContext) {
try {
- // Parse the signed action from the request body
- const signedAction: SignedAction = await request.json();
+ const body = await readOptionalJson(request);
+ const { id: paramId } = await context.params;
+ const decodedParamId = decodeURIComponent(paramId);
- // Verify the signature and get the user
- const user = await requireSignedAction(signedAction);
+ const user = isSignedActionPayload(body)
+ ? await requireSignedAction(body)
+ : await requireAuth();
- // Extract and validate postId from the signed action data
- const { postId: rawId } = signedAction.data;
- const decodedId = decodeURIComponent(rawId);
+ if (isSignedActionPayload(body) && body.data?.postId && body.data.postId !== decodedParamId) {
+ return NextResponse.json({ error: 'Post ID mismatch' }, { status: 400 });
+ }
+
+ const decodedId = decodedParamId;
const postId = postIdSchema.parse(decodedId);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
@@ -183,6 +212,10 @@ export async function POST(request: Request, context: RouteContext) {
.where(eq(posts.id, postId));
if (post.userId !== user.id) {
+ const postAuthor = await db.query.users.findFirst({
+ where: eq(users.id, post.userId),
+ });
+
// Create notification with actor info stored directly
await db.insert(notifications).values({
userId: post.userId,
@@ -193,13 +226,11 @@ export async function POST(request: Request, context: RouteContext) {
actorNodeDomain: null, // Local user
postId,
postContent: post.content?.slice(0, 200) || null,
+ ...(postAuthor?.isBot ? buildNotificationTarget(postAuthor) : {}),
type: 'like',
});
// Also notify bot owner if this is a bot's post
- const postAuthor = await db.query.users.findFirst({
- where: eq(users.id, post.userId),
- });
if (postAuthor?.isBot && postAuthor.botOwnerId) {
await db.insert(notifications).values({
userId: postAuthor.botOwnerId,
@@ -210,6 +241,7 @@ export async function POST(request: Request, context: RouteContext) {
actorNodeDomain: null,
postId,
postContent: post.content?.slice(0, 200) || null,
+ ...buildNotificationTarget(postAuthor),
type: 'like',
});
}
@@ -277,15 +309,19 @@ export async function POST(request: Request, context: RouteContext) {
// Unlike a post
export async function DELETE(request: Request, context: RouteContext) {
try {
- // Parse the signed action from the request body
- const signedAction: SignedAction = await request.json();
+ const body = await readOptionalJson(request);
+ const { id: paramId } = await context.params;
+ const decodedParamId = decodeURIComponent(paramId);
- // Verify the signature and get the user
- const user = await requireSignedAction(signedAction);
+ const user = isSignedActionPayload(body)
+ ? await requireSignedAction(body)
+ : await requireAuth();
- // Extract and validate postId from the signed action data
- const { postId: rawId } = signedAction.data;
- const decodedId = decodeURIComponent(rawId);
+ if (isSignedActionPayload(body) && body.data?.postId && body.data.postId !== decodedParamId) {
+ return NextResponse.json({ error: 'Post ID mismatch' }, { status: 400 });
+ }
+
+ const decodedId = decodedParamId;
const postId = postIdSchema.parse(decodedId);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
diff --git a/src/app/api/posts/[id]/repost/route.ts b/src/app/api/posts/[id]/repost/route.ts
index 8b9e922..fd358ce 100644
--- a/src/app/api/posts/[id]/repost/route.ts
+++ b/src/app/api/posts/[id]/repost/route.ts
@@ -1,9 +1,11 @@
import { NextResponse } from 'next/server';
-import { db, posts, users, notifications } from '@/db';
+import { db, posts, users, notifications, userSwarmReposts } from '@/db';
import { requireAuth } from '@/lib/auth';
import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import crypto from 'crypto';
+import { buildNotificationTarget } from '@/lib/notifications';
+import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
type RouteContext = { params: Promise<{ id: string }> };
@@ -40,6 +42,47 @@ function extractSwarmPostId(apId: string): string | null {
return apId.substring(lastColonIndex + 1);
}
+async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) {
+ try {
+ const protocol = domain.includes('localhost') ? 'http' : 'https';
+ const res = await fetch(`${protocol}://${domain}/api/swarm/posts/${originalPostId}`, {
+ headers: { Accept: 'application/json' },
+ signal: AbortSignal.timeout(5000),
+ });
+
+ if (!res.ok) {
+ return null;
+ }
+
+ const data = await res.json();
+ const post = data.post;
+ if (!post) {
+ return null;
+ }
+
+ return {
+ authorHandle: post.author?.handle || 'unknown',
+ authorDisplayName: post.author?.displayName || post.author?.handle || 'Unknown',
+ authorAvatarUrl: post.author?.avatarUrl || null,
+ content: post.content || '',
+ postCreatedAt: new Date(post.createdAt || new Date().toISOString()),
+ likesCount: post.likesCount || 0,
+ repostsCount: post.repostsCount || 0,
+ repliesCount: post.repliesCount || 0,
+ linkPreviewUrl: post.linkPreviewUrl || null,
+ linkPreviewTitle: post.linkPreviewTitle || null,
+ linkPreviewDescription: post.linkPreviewDescription || null,
+ linkPreviewImage: post.linkPreviewImage || null,
+ linkPreviewType: post.linkPreviewType || null,
+ linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
+ linkPreviewMediaJson: serializeLinkPreviewMedia(post.linkPreviewMedia),
+ mediaJson: post.media ? JSON.stringify(post.media) : null,
+ };
+ } catch {
+ return null;
+ }
+}
+
// Repost a post
export async function POST(request: Request, context: RouteContext) {
try {
@@ -62,6 +105,18 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 });
}
+ const existingRepost = await db.query.userSwarmReposts.findFirst({
+ where: and(
+ eq(userSwarmReposts.userId, user.id),
+ eq(userSwarmReposts.nodeDomain, targetDomain),
+ eq(userSwarmReposts.originalPostId, originalPostId),
+ ),
+ });
+
+ if (existingRepost) {
+ return NextResponse.json({ error: 'Already reposted' }, { status: 400 });
+ }
+
// Deliver repost directly to the origin node
const { deliverSwarmRepost } = await import('@/lib/swarm/interactions');
@@ -83,6 +138,27 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Failed to deliver repost to remote node' }, { status: 502 });
}
+ const snapshot = await fetchSwarmPostSnapshot(targetDomain, originalPostId);
+ if (snapshot) {
+ await db.insert(userSwarmReposts).values({
+ userId: user.id,
+ nodeDomain: targetDomain,
+ originalPostId,
+ ...snapshot,
+ repostedAt: new Date(),
+ }).onConflictDoUpdate({
+ target: [userSwarmReposts.userId, userSwarmReposts.nodeDomain, userSwarmReposts.originalPostId],
+ set: {
+ ...snapshot,
+ repostedAt: new Date(),
+ },
+ });
+ }
+
+ await db.update(users)
+ .set({ postsCount: sql`${users.postsCount} + 1` })
+ .where(eq(users.id, user.id));
+
console.log(`[Swarm] Repost delivered to ${targetDomain} for post ${originalPostId}`);
return NextResponse.json({ success: true, reposted: true });
}
@@ -133,6 +209,10 @@ export async function POST(request: Request, context: RouteContext) {
.where(eq(users.id, user.id));
if (originalPost.userId !== user.id) {
+ const postAuthor = await db.query.users.findFirst({
+ where: eq(users.id, originalPost.userId),
+ });
+
// Create notification with actor info stored directly
await db.insert(notifications).values({
userId: originalPost.userId,
@@ -143,13 +223,11 @@ export async function POST(request: Request, context: RouteContext) {
actorNodeDomain: null, // Local user
postId,
postContent: originalPost.content?.slice(0, 200) || null,
+ ...(postAuthor?.isBot ? buildNotificationTarget(postAuthor) : {}),
type: 'repost',
});
// Also notify bot owner if this is a bot's post
- const postAuthor = await db.query.users.findFirst({
- where: eq(users.id, originalPost.userId),
- });
if (postAuthor?.isBot && postAuthor.botOwnerId) {
await db.insert(notifications).values({
userId: postAuthor.botOwnerId,
@@ -160,6 +238,7 @@ export async function POST(request: Request, context: RouteContext) {
actorNodeDomain: null,
postId,
postContent: originalPost.content?.slice(0, 200) || null,
+ ...buildNotificationTarget(postAuthor),
type: 'repost',
});
}
@@ -236,6 +315,18 @@ export async function DELETE(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 });
}
+ const existingRepost = await db.query.userSwarmReposts.findFirst({
+ where: and(
+ eq(userSwarmReposts.userId, user.id),
+ eq(userSwarmReposts.nodeDomain, targetDomain),
+ eq(userSwarmReposts.originalPostId, originalPostId),
+ ),
+ });
+
+ if (!existingRepost) {
+ return NextResponse.json({ error: 'Not reposted' }, { status: 400 });
+ }
+
// Deliver unrepost directly to the origin node
const { deliverSwarmUnrepost } = await import('@/lib/swarm/interactions');
@@ -254,6 +345,16 @@ export async function DELETE(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Failed to deliver unrepost to remote node' }, { status: 502 });
}
+ await db.delete(userSwarmReposts).where(and(
+ eq(userSwarmReposts.userId, user.id),
+ eq(userSwarmReposts.nodeDomain, targetDomain),
+ eq(userSwarmReposts.originalPostId, originalPostId),
+ ));
+
+ await db.update(users)
+ .set({ postsCount: sql`GREATEST(0, ${users.postsCount} - 1)` })
+ .where(eq(users.id, user.id));
+
console.log(`[Swarm] Unrepost delivered to ${targetDomain} for post ${originalPostId}`);
return NextResponse.json({ success: true, reposted: false });
}
diff --git a/src/app/api/posts/[id]/route.ts b/src/app/api/posts/[id]/route.ts
index a9dfc00..4da2e65 100644
--- a/src/app/api/posts/[id]/route.ts
+++ b/src/app/api/posts/[id]/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { db, posts, users, media, remotePosts } from '@/db';
import { eq, desc, and, sql, inArray } from 'drizzle-orm';
import { z } from 'zod';
+import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
// Schema for local post ID (UUID)
const localPostIdSchema = z.string().uuid('Invalid post ID format');
@@ -15,6 +16,69 @@ const swarmPostIdSchema = z.string().regex(
// Combined schema that accepts either format
const postIdSchema = z.union([localPostIdSchema, swarmPostIdSchema]);
+const embeddedPostRelations = {
+ author: true,
+ bot: true,
+ media: true,
+ replyTo: {
+ with: {
+ author: true,
+ bot: true,
+ media: true,
+ },
+ },
+} as const;
+
+const postDetailRelations = {
+ ...embeddedPostRelations,
+ repostOf: {
+ with: embeddedPostRelations,
+ },
+} as const;
+
+function mapSwarmDetailPost(post: any, fallbackDomain: string): any {
+ const effectiveDomain = post.nodeDomain || fallbackDomain;
+ const rawId = post.originalPostId || post.id;
+
+ return {
+ id: post.id?.startsWith('swarm:') ? post.id : `swarm:${effectiveDomain}:${rawId}`,
+ originalPostId: rawId,
+ content: post.content,
+ createdAt: post.createdAt,
+ likesCount: post.likesCount || 0,
+ repostsCount: post.repostsCount || 0,
+ repliesCount: post.repliesCount || 0,
+ isSwarm: true,
+ nodeDomain: effectiveDomain,
+ repostOfId: post.repostOf
+ ? (post.repostOf.id?.startsWith('swarm:')
+ ? post.repostOf.id
+ : `swarm:${post.repostOf.nodeDomain || effectiveDomain}:${post.repostOf.originalPostId || post.repostOf.id}`)
+ : (post.repostOfId ? `swarm:${effectiveDomain}:${post.repostOfId}` : null),
+ repostOf: post.repostOf ? mapSwarmDetailPost(post.repostOf, post.repostOf.nodeDomain || effectiveDomain) : null,
+ author: post.author ? {
+ id: `swarm:${effectiveDomain}:${post.author.handle}`,
+ handle: post.author.handle.includes('@') ? post.author.handle : `${post.author.handle}@${effectiveDomain}`,
+ displayName: post.author.displayName,
+ avatarUrl: post.author.avatarUrl,
+ isSwarm: true,
+ nodeDomain: effectiveDomain,
+ } : null,
+ media: post.media?.map((m: any, idx: number) => ({
+ id: m.id || `swarm:${effectiveDomain}:${rawId}:media:${idx}`,
+ url: m.url,
+ altText: m.altText || null,
+ })) || [],
+ linkPreviewUrl: post.linkPreviewUrl,
+ linkPreviewTitle: post.linkPreviewTitle,
+ linkPreviewDescription: post.linkPreviewDescription,
+ linkPreviewImage: post.linkPreviewImage,
+ linkPreviewType: post.linkPreviewType || null,
+ linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
+ linkPreviewMedia: post.linkPreviewMedia || null,
+ };
+}
+
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -47,67 +111,25 @@ export async function GET(
if (res.ok) {
const data = await res.json();
- // Transform to match expected format
- mainPost = {
- id: id,
- originalPostId: originalPostId,
- content: data.post.content,
- createdAt: data.post.createdAt,
- likesCount: data.post.likesCount || 0,
- repostsCount: data.post.repostsCount || 0,
- repliesCount: data.post.repliesCount || 0,
- isSwarm: true,
+ mainPost = mapSwarmDetailPost({
+ ...data.post,
+ id,
+ originalPostId,
nodeDomain: originDomain,
- author: {
- id: `swarm:${originDomain}:${data.post.author.handle}`,
- handle: data.post.author.handle,
- displayName: data.post.author.displayName,
- avatarUrl: data.post.author.avatarUrl,
- isSwarm: true,
- nodeDomain: originDomain,
- },
- media: data.post.media?.map((m: any, idx: number) => ({
- id: `swarm:${originDomain}:${originalPostId}:media:${idx}`,
- url: m.url,
- altText: m.altText || null,
- })) || [],
- linkPreviewUrl: data.post.linkPreviewUrl,
- linkPreviewTitle: data.post.linkPreviewTitle,
- linkPreviewDescription: data.post.linkPreviewDescription,
- linkPreviewImage: data.post.linkPreviewImage,
- };
+ }, originDomain);
// Transform replies from the origin node
- replyPosts = (data.replies || []).map((r: any) => ({
- id: `swarm:${originDomain}:${r.id}`,
- originalPostId: r.id,
- content: r.content,
- createdAt: r.createdAt,
- likesCount: r.likesCount || 0,
- repostsCount: r.repostsCount || 0,
- repliesCount: r.repliesCount || 0,
- isSwarm: true,
+ replyPosts = (data.replies || []).map((reply: any) => mapSwarmDetailPost({
+ ...reply,
nodeDomain: originDomain,
- author: {
- id: `swarm:${originDomain}:${r.author.handle}`,
- handle: r.author.handle,
- displayName: r.author.displayName,
- avatarUrl: r.author.avatarUrl,
- isSwarm: true,
- nodeDomain: originDomain,
- },
- media: r.media?.map((m: any, idx: number) => ({
- id: `swarm:${originDomain}:${r.id}:media:${idx}`,
- url: m.url,
- altText: m.altText || null,
- })) || [],
- }));
+ }, originDomain));
mainPost.repliesCount = replyPosts.length;
// Check if current user has liked this post
try {
const { requireAuth } = await import('@/lib/auth');
+ const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts');
const viewer = await requireAuth();
const likeCheckRes = await fetch(
@@ -119,6 +141,15 @@ export async function GET(
const likeData = await likeCheckRes.json();
mainPost.isLiked = likeData.isLiked;
}
+
+ const repostedIds = await getViewerSwarmRepostedPostIds([
+ {
+ id,
+ nodeDomain: originDomain,
+ originalPostId,
+ },
+ ], viewer.id);
+ mainPost.isReposted = repostedIds.has(id);
} catch {
// Not logged in or timeout
}
@@ -138,13 +169,7 @@ export async function GET(
const post = await db.query.posts.findFirst({
where: eq(posts.id, id),
- with: {
- author: true,
- media: true,
- replyTo: {
- with: { author: true },
- },
- },
+ with: postDetailRelations,
});
if (post) {
@@ -155,10 +180,7 @@ export async function GET(
eq(posts.replyToId, id),
eq(posts.isRemoved, false)
),
- with: {
- author: true,
- media: true,
- },
+ with: postDetailRelations,
orderBy: [desc(posts.createdAt)],
});
@@ -235,6 +257,9 @@ export async function GET(
linkPreviewTitle: cached.linkPreviewTitle,
linkPreviewDescription: cached.linkPreviewDescription,
linkPreviewImage: cached.linkPreviewImage,
+ linkPreviewType: cached.linkPreviewType,
+ linkPreviewVideoUrl: cached.linkPreviewVideoUrl,
+ linkPreviewMedia: parseLinkPreviewMediaJson(cached.linkPreviewMediaJson) || null,
isLiked: false,
isReposted: false,
};
diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts
index 9c1aaf3..a9a6a4e 100644
--- a/src/app/api/posts/route.ts
+++ b/src/app/api/posts/route.ts
@@ -1,10 +1,12 @@
import { NextResponse } from 'next/server';
-import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts } from '@/db';
+import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts, userSwarmReposts, notifications } from '@/db';
import { requireAuth } from '@/lib/auth';
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
import { eq, desc, and, inArray, isNull, isNotNull, or, lt, sql } from 'drizzle-orm';
import type { SQL } from 'drizzle-orm';
import { z } from 'zod';
+import { buildNotificationTarget } from '@/lib/notifications';
+import { serializeLinkPreviewMedia, parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
const POST_MAX_LENGTH = 600;
const CURATION_WINDOW_HOURS = 72;
@@ -17,6 +19,138 @@ const buildWhere = (...conditions: Array) => {
return and(...filtered);
};
+type FeedPostWithChildren = {
+ id: string;
+ repostOf?: FeedPostWithChildren | null;
+ replyTo?: FeedPostWithChildren | null;
+ isLiked?: boolean;
+ isReposted?: boolean;
+ nodeDomain?: string | null;
+ originalPostId?: string | null;
+};
+
+function mapUserSwarmRepostToFeedPost(
+ row: typeof userSwarmReposts.$inferSelect,
+ author: Pick
+): FeedPostWithChildren {
+ const remoteAuthorHandle = row.authorHandle.includes('@')
+ ? row.authorHandle
+ : `${row.authorHandle}@${row.nodeDomain}`;
+ const remoteOriginalId = `swarm:${row.nodeDomain}:${row.originalPostId}`;
+
+ return {
+ id: `swarm-repost:${row.id}`,
+ content: '',
+ createdAt: row.repostedAt.toISOString(),
+ likesCount: 0,
+ repostsCount: 0,
+ repliesCount: 0,
+ author: {
+ id: author.id,
+ handle: author.handle,
+ displayName: author.displayName,
+ avatarUrl: author.avatarUrl,
+ isBot: author.isBot,
+ },
+ repostOfId: remoteOriginalId,
+ repostOf: {
+ id: remoteOriginalId,
+ originalPostId: row.originalPostId,
+ content: row.content,
+ createdAt: row.postCreatedAt.toISOString(),
+ likesCount: row.likesCount,
+ repostsCount: row.repostsCount,
+ repliesCount: row.repliesCount,
+ isSwarm: true,
+ nodeDomain: row.nodeDomain,
+ author: {
+ id: `swarm:${row.nodeDomain}:${row.authorHandle}`,
+ handle: remoteAuthorHandle,
+ displayName: row.authorDisplayName || row.authorHandle,
+ avatarUrl: row.authorAvatarUrl,
+ isRemote: true,
+ nodeDomain: row.nodeDomain,
+ },
+ media: row.mediaJson ? JSON.parse(row.mediaJson) : [],
+ linkPreviewUrl: row.linkPreviewUrl,
+ linkPreviewTitle: row.linkPreviewTitle,
+ linkPreviewDescription: row.linkPreviewDescription,
+ linkPreviewImage: row.linkPreviewImage,
+ linkPreviewType: row.linkPreviewType,
+ linkPreviewVideoUrl: row.linkPreviewVideoUrl,
+ linkPreviewMedia: parseLinkPreviewMediaJson(row.linkPreviewMediaJson) || null,
+ },
+ } as any;
+}
+
+async function getMixedFeedCursorDate(cursor: string | null) {
+ if (!cursor) {
+ return null;
+ }
+
+ if (cursor.startsWith('swarm-repost:')) {
+ const repostRow = await db.query.userSwarmReposts.findFirst({
+ where: eq(userSwarmReposts.id, cursor.replace('swarm-repost:', '')),
+ });
+ return repostRow?.repostedAt ?? null;
+ }
+
+ const cursorPost = await db.query.posts.findFirst({
+ where: eq(posts.id, cursor),
+ });
+ return cursorPost?.createdAt ?? null;
+}
+
+function collectNestedPosts(posts: FeedPostWithChildren[]): FeedPostWithChildren[] {
+ const collected: FeedPostWithChildren[] = [];
+ const seen = new Set();
+
+ const visit = (post: FeedPostWithChildren | null | undefined) => {
+ if (!post || seen.has(post.id)) return;
+ seen.add(post.id);
+ collected.push(post);
+ visit(post.repostOf);
+ visit(post.replyTo);
+ };
+
+ posts.forEach(visit);
+ return collected;
+}
+
+function applyInteractionFlags(
+ posts: FeedPostWithChildren[],
+ likedIds: Set,
+ repostedIds: Set
+): FeedPostWithChildren[] {
+ return posts.map((post) => ({
+ ...post,
+ isLiked: likedIds.has(post.id),
+ isReposted: repostedIds.has(post.id),
+ repostOf: post.repostOf ? applyInteractionFlags([post.repostOf], likedIds, repostedIds)[0] : post.repostOf,
+ replyTo: post.replyTo ? applyInteractionFlags([post.replyTo], likedIds, repostedIds)[0] : post.replyTo,
+ }));
+}
+
+const embeddedPostRelations = {
+ author: true,
+ bot: true,
+ media: true,
+ replyTo: {
+ with: {
+ author: true,
+ bot: true,
+ media: true,
+ },
+ },
+} as const;
+
+const feedPostRelations = {
+ ...embeddedPostRelations,
+ repostOf: {
+ with: embeddedPostRelations,
+ },
+} as const;
+
const createPostSchema = z.object({
content: z.string().min(1).max(POST_MAX_LENGTH),
replyToId: z.string().uuid().optional(), // Must be UUID (swarm replies use separate field)
@@ -38,21 +172,40 @@ const createPostSchema = z.object({
title: z.string().optional(),
description: z.string().optional(),
image: z.string().url().optional().nullable(),
+ type: z.enum(['card', 'image', 'gallery', 'video']).optional().nullable(),
+ videoUrl: z.string().url().optional().nullable(),
+ media: z.array(z.object({
+ url: z.string().url(),
+ width: z.number().optional().nullable(),
+ height: z.number().optional().nullable(),
+ mimeType: z.string().optional().nullable(),
+ })).optional().nullable(),
}).optional().nullable(),
});
+function isSignedActionPayload(payload: unknown): payload is SignedAction {
+ if (!payload || typeof payload !== 'object') return false;
+ const value = payload as Record;
+ return typeof value.action === 'string'
+ && typeof value.did === 'string'
+ && typeof value.handle === 'string'
+ && typeof value.ts === 'number'
+ && typeof value.nonce === 'string'
+ && typeof value.sig === 'string'
+ && typeof value.data === 'object'
+ && value.data !== null;
+}
+
// Create a new post
export async function POST(request: Request) {
try {
- // Parse the signed action from the request body
- const signedAction: SignedAction = await request.json();
-
- // Strictly verify the signature and get the user
- // This replaces requireAuth() - the signature proves identity AND intent
- const user = await requireSignedAction(signedAction);
-
- // Extract post data from the signed action
- const data = createPostSchema.parse(signedAction.data);
+ const requestBody = await request.json();
+ const user = isSignedActionPayload(requestBody)
+ ? await requireSignedAction(requestBody)
+ : await requireAuth();
+ const data = createPostSchema.parse(
+ isSignedActionPayload(requestBody) ? requestBody.data : requestBody
+ );
if (user.isSuspended || user.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
@@ -84,6 +237,9 @@ export async function POST(request: Request) {
linkPreviewTitle: data.linkPreview?.title,
linkPreviewDescription: data.linkPreview?.description,
linkPreviewImage: data.linkPreview?.image,
+ linkPreviewType: data.linkPreview?.type,
+ linkPreviewVideoUrl: data.linkPreview?.videoUrl,
+ linkPreviewMediaJson: serializeLinkPreviewMedia(data.linkPreview?.media),
}).returning();
let unattachedMedia: typeof media.$inferSelect[] = [];
@@ -181,12 +337,56 @@ export async function POST(request: Request) {
});
}
+ if (data.replyToId) {
+ try {
+ const parentPost = await db.query.posts.findFirst({
+ where: eq(posts.id, data.replyToId),
+ with: {
+ author: true,
+ },
+ });
+
+ if (parentPost && parentPost.userId !== user.id) {
+ const parentAuthor = parentPost.author as typeof users.$inferSelect | undefined;
+
+ await db.insert(notifications).values({
+ userId: parentPost.userId,
+ actorId: user.id,
+ actorHandle: user.handle,
+ actorDisplayName: user.displayName,
+ actorAvatarUrl: user.avatarUrl,
+ actorNodeDomain: null,
+ postId: parentPost.id,
+ postContent: post.content?.slice(0, 200) || null,
+ ...(parentAuthor?.isBot ? buildNotificationTarget(parentAuthor) : {}),
+ type: 'reply',
+ });
+
+ if (parentAuthor?.isBot && parentAuthor.botOwnerId) {
+ await db.insert(notifications).values({
+ userId: parentAuthor.botOwnerId,
+ actorId: user.id,
+ actorHandle: user.handle,
+ actorDisplayName: user.displayName,
+ actorAvatarUrl: user.avatarUrl,
+ actorNodeDomain: null,
+ postId: parentPost.id,
+ postContent: post.content?.slice(0, 200) || null,
+ ...buildNotificationTarget(parentAuthor),
+ type: 'reply',
+ });
+ }
+ }
+ } catch (err) {
+ console.error('[Posts] Error creating reply notifications:', err);
+ console.error('[Posts] Context:', { postId: post.id, replyToId: data.replyToId, userId: user.id });
+ }
+ }
+
// Handle local mentions (create notifications for users on this node)
(async () => {
try {
const { extractMentions } = await import('@/lib/swarm/interactions');
- const { notifications } = await import('@/db');
-
const mentions = extractMentions(data.content);
for (const mention of mentions) {
@@ -209,6 +409,7 @@ export async function POST(request: Request) {
actorNodeDomain: null, // Local user
postId: post.id,
postContent: post.content?.slice(0, 200) || null,
+ ...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}),
type: 'mention',
});
@@ -223,6 +424,7 @@ export async function POST(request: Request) {
actorNodeDomain: null,
postId: post.id,
postContent: post.content?.slice(0, 200) || null,
+ ...buildNotificationTarget(mentionedUser),
type: 'mention',
});
}
@@ -374,6 +576,9 @@ const transformRemotePosts = (remotePostsData: typeof remotePosts.$inferSelect[]
linkPreviewTitle: rp.linkPreviewTitle,
linkPreviewDescription: rp.linkPreviewDescription,
linkPreviewImage: rp.linkPreviewImage,
+ linkPreviewType: rp.linkPreviewType,
+ linkPreviewVideoUrl: rp.linkPreviewVideoUrl,
+ linkPreviewMedia: parseLinkPreviewMediaJson(rp.linkPreviewMediaJson) || null,
author: {
id: rp.authorActorUrl,
handle: rp.authorHandle,
@@ -437,14 +642,7 @@ export async function GET(request: Request) {
feedPosts = await db.query.posts.findMany({
where: whereCondition,
- with: {
- author: true,
- bot: true,
- media: true,
- replyTo: {
- with: { author: true, media: true },
- },
- },
+ with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
limit,
});
@@ -452,14 +650,7 @@ export async function GET(request: Request) {
// Public timeline - all local posts + all cached remote posts
const localPosts = await db.query.posts.findMany({
where: baseFilter,
- with: {
- author: true,
- bot: true,
- media: true,
- replyTo: {
- with: { author: true, media: true },
- },
- },
+ with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
limit: limit * 2,
});
@@ -492,14 +683,7 @@ export async function GET(request: Request) {
feedPosts = await db.query.posts.findMany({
where: whereCondition,
- with: {
- author: true,
- bot: true,
- media: true,
- replyTo: {
- with: { author: true, media: true },
- },
- },
+ with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
limit,
});
@@ -519,14 +703,7 @@ export async function GET(request: Request) {
feedPosts = await db.query.posts.findMany({
where: whereCondition,
- with: {
- author: true,
- bot: true,
- media: true,
- replyTo: {
- with: { author: true, media: true },
- },
- },
+ with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
limit,
});
@@ -554,7 +731,7 @@ export async function GET(request: Request) {
includeNsfw,
});
- const swarmPosts = swarmResult.posts.map(sp => ({
+ const mapSwarmPostToFeedPost = (sp: (typeof swarmResult.posts)[number]): any => ({
id: `swarm:${sp.nodeDomain}:${sp.id}`,
originalPostId: sp.id, // Keep the original ID for replies
content: sp.content,
@@ -564,6 +741,8 @@ export async function GET(request: Request) {
repliesCount: sp.replyCount,
isSwarm: true,
nodeDomain: sp.nodeDomain,
+ repostOfId: sp.repostOfId ? `swarm:${sp.nodeDomain}:${sp.repostOfId}` : null,
+ repostOf: sp.repostOf ? mapSwarmPostToFeedPost(sp.repostOf) : null,
author: {
id: `swarm:${sp.nodeDomain}:${sp.author.handle}`,
handle: sp.author.handle,
@@ -583,8 +762,13 @@ export async function GET(request: Request) {
linkPreviewTitle: sp.linkPreviewTitle || null,
linkPreviewDescription: sp.linkPreviewDescription || null,
linkPreviewImage: sp.linkPreviewImage || null,
+ linkPreviewType: sp.linkPreviewType || null,
+ linkPreviewVideoUrl: sp.linkPreviewVideoUrl || null,
+ linkPreviewMedia: sp.linkPreviewMedia || null,
replyTo: null,
- }));
+ });
+
+ const swarmPosts = swarmResult.posts.map(mapSwarmPostToFeedPost);
let mutedIds = new Set();
let blockedIds = new Set();
@@ -674,31 +858,48 @@ export async function GET(request: Request) {
// Build where condition with cursor support
let whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds));
+ const cursorDate = await getMixedFeedCursorDate(cursor);
- if (cursor) {
- const cursorPost = await db.query.posts.findFirst({
- where: eq(posts.id, cursor),
- });
- if (cursorPost) {
- whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds), lt(posts.createdAt, cursorPost.createdAt));
- }
+ if (cursorDate) {
+ whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds), lt(posts.createdAt, cursorDate));
}
// Get local posts from people the user follows + their own posts
const localPosts = await db.query.posts.findMany({
where: whereCondition,
- with: {
- author: true,
- bot: true,
- media: true,
- replyTo: {
- with: { author: true, media: true },
- },
- },
+ with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
limit: cursor ? limit : limit * 2, // Get more on first load to account for mixing with remote
});
+ const swarmRepostWhere = cursorDate
+ ? and(
+ inArray(userSwarmReposts.userId, allowedUserIds),
+ lt(userSwarmReposts.repostedAt, cursorDate)
+ )
+ : inArray(userSwarmReposts.userId, allowedUserIds);
+ const swarmRepostRows = await db.query.userSwarmReposts.findMany({
+ where: swarmRepostWhere,
+ orderBy: [desc(userSwarmReposts.repostedAt)],
+ limit: cursor ? limit : limit * 2,
+ });
+
+ const swarmRepostAuthors = swarmRepostRows.length > 0
+ ? await db.query.users.findMany({
+ where: inArray(users.id, Array.from(new Set(swarmRepostRows.map((row) => row.userId)))),
+ })
+ : [];
+ const swarmRepostAuthorMap = new Map(swarmRepostAuthors.map((author) => [author.id, author]));
+ const localRepostEvents = swarmRepostRows
+ .map((row) => {
+ const author = swarmRepostAuthorMap.get(row.userId);
+ if (!author) {
+ return null;
+ }
+ return mapUserSwarmRepostToFeedPost(row, author);
+ })
+ .filter(Boolean);
+
// Get handles of remote users we follow
const followedRemoteUsers = await db.query.remoteFollows.findMany({
where: eq(remoteFollows.followerId, user.id),
@@ -708,6 +909,7 @@ export async function GET(request: Request) {
let liveRemotePosts: any[] = [];
if (followedRemoteUsers.length > 0) {
const { fetchSwarmUserProfile, isSwarmNode } = await import('@/lib/swarm/interactions');
+ const { mapRemoteProfilePost } = await import('@/lib/swarm/remote-profile-posts');
// Wrap each fetch with a timeout to prevent slow nodes from blocking
const withTimeout = (promise: Promise, ms: number): Promise => {
@@ -737,34 +939,15 @@ export async function GET(request: Request) {
return profileData.posts
.filter((post: any) => !post.replyToId && !post.swarmReplyToId && !post.isReply)
- .map(post => ({
- id: `swarm:${domain}:${post.id}`,
- content: post.content,
- createdAt: new Date(post.createdAt),
- likesCount: post.likesCount || 0,
- repostsCount: post.repostsCount || 0,
- repliesCount: post.repliesCount || 0,
- isRemote: true,
- isNsfw: post.isNsfw,
- linkPreviewUrl: post.linkPreviewUrl,
- linkPreviewTitle: post.linkPreviewTitle,
- linkPreviewDescription: post.linkPreviewDescription,
- linkPreviewImage: post.linkPreviewImage,
- author: {
- id: `swarm:${domain}:${handle}`,
- handle: follow.targetHandle,
- displayName: follow.displayName || profileData.profile?.displayName || handle,
- avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl,
- isRemote: true,
- isBot: profileData.profile?.isBot,
- },
- media: post.media?.map((m: any, idx: number) => ({
- id: `swarm:${domain}:${post.id}:media:${idx}`,
- url: m.url,
- altText: m.altText || null,
- })) || [],
- replyTo: null,
- }));
+ .map((post: any) => mapRemoteProfilePost({
+ ...post,
+ author: post.author || {
+ handle,
+ displayName: follow.displayName || profileData.profile?.displayName || handle,
+ avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl,
+ isBot: profileData.profile?.isBot,
+ },
+ }, domain));
} catch (error) {
console.error(`[Home] Error fetching posts from ${follow.targetHandle}:`, error);
return [];
@@ -776,7 +959,7 @@ export async function GET(request: Request) {
}
// Merge and sort by date
- const allPosts = [...localPosts, ...liveRemotePosts]
+ const allPosts = [...localPosts, ...localRepostEvents, ...liveRemotePosts]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, limit);
@@ -785,14 +968,7 @@ export async function GET(request: Request) {
// Not authenticated, return public timeline
feedPosts = await db.query.posts.findMany({
where: baseFilter,
- with: {
- author: true,
- bot: true,
- media: true,
- replyTo: {
- with: { author: true, media: true },
- },
- },
+ with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
limit,
});
@@ -807,12 +983,13 @@ export async function GET(request: Request) {
if (session?.user && feedPosts && feedPosts.length > 0) {
const viewer = session.user;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
+ const allFeedPosts = collectNestedPosts(feedPosts as FeedPostWithChildren[]);
// Separate local and swarm posts
const localPostIds: string[] = [];
const swarmPosts: Array<{ id: string; domain: string; originalId: string }> = [];
- for (const p of feedPosts as Array<{ id: string }>) {
+ for (const p of allFeedPosts) {
if (p.id.startsWith('swarm:')) {
const parts = p.id.split(':');
if (parts.length >= 3) {
@@ -852,6 +1029,8 @@ export async function GET(request: Request) {
// Check swarm likes in real-time (query origin nodes)
if (swarmPosts.length > 0) {
+ const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts');
+
const checkPromises = swarmPosts.map(async (sp) => {
try {
const protocol = sp.domain.includes('localhost') ? 'http' : 'https';
@@ -874,13 +1053,23 @@ export async function GET(request: Request) {
});
await Promise.all(checkPromises);
+
+ const swarmRepostedIds = await getViewerSwarmRepostedPostIds(
+ swarmPosts.map((sp) => ({
+ id: sp.id,
+ nodeDomain: sp.domain,
+ originalPostId: sp.originalId,
+ })),
+ viewer.id
+ );
+ swarmRepostedIds.forEach((id) => repostedPostIds.add(id));
}
- feedPosts = feedPosts.map((p: { id: string }) => ({
- ...p,
- isLiked: likedPostIds.has(p.id),
- isReposted: repostedPostIds.has(p.id),
- })) as any;
+ feedPosts = applyInteractionFlags(
+ feedPosts as FeedPostWithChildren[],
+ likedPostIds,
+ repostedPostIds
+ ) as any;
}
} catch (error) {
console.error('Error populating interaction flags:', error);
diff --git a/src/app/api/posts/swarm/route.ts b/src/app/api/posts/swarm/route.ts
index cf8b3aa..1bb9fff 100644
--- a/src/app/api/posts/swarm/route.ts
+++ b/src/app/api/posts/swarm/route.ts
@@ -8,6 +8,51 @@ import { NextRequest, NextResponse } from 'next/server';
import { fetchSwarmTimeline } from '@/lib/swarm/timeline';
import { getSession } from '@/lib/auth';
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
+import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
+
+type SwarmFeedPost = {
+ id: string;
+ nodeDomain: string;
+ repostOf?: SwarmFeedPost | null;
+ replyTo?: SwarmFeedPost | null;
+ isLiked?: boolean;
+ isReposted?: boolean;
+};
+
+function collectNestedSwarmPosts(posts: SwarmFeedPost[]): SwarmFeedPost[] {
+ const collected: SwarmFeedPost[] = [];
+ const seen = new Set();
+
+ const visit = (post: SwarmFeedPost | null | undefined) => {
+ if (!post) return;
+ const key = `${post.nodeDomain}:${post.id}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ collected.push(post);
+ visit(post.repostOf);
+ visit(post.replyTo);
+ };
+
+ posts.forEach(visit);
+ return collected;
+}
+
+function applyInteractionFlags(
+ posts: SwarmFeedPost[],
+ likedIds: Set,
+ repostedIds: Set
+): SwarmFeedPost[] {
+ return posts.map((post) => {
+ const normalizedId = `swarm:${post.nodeDomain}:${post.id}`;
+ return {
+ ...post,
+ isLiked: likedIds.has(normalizedId),
+ isReposted: repostedIds.has(normalizedId),
+ repostOf: post.repostOf ? applyInteractionFlags([post.repostOf], likedIds, repostedIds)[0] : post.repostOf,
+ replyTo: post.replyTo ? applyInteractionFlags([post.replyTo], likedIds, repostedIds)[0] : post.replyTo,
+ };
+ });
+}
/**
* GET /api/posts/swarm
@@ -35,9 +80,10 @@ export async function GET(request: NextRequest) {
const session = await getSession().catch(() => null);
const viewer = session?.user;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
+ const allTimelinePosts = collectNestedSwarmPosts(timeline.posts as SwarmFeedPost[]);
const likedPostIds = viewer
? await getViewerSwarmLikedPostIds(
- timeline.posts.map(post => ({
+ allTimelinePosts.map(post => ({
id: `swarm:${post.nodeDomain}:${post.id}`,
nodeDomain: post.nodeDomain,
originalPostId: post.id,
@@ -46,12 +92,19 @@ export async function GET(request: NextRequest) {
nodeDomain
)
: new Set();
+ const repostedPostIds = viewer
+ ? await getViewerSwarmRepostedPostIds(
+ allTimelinePosts.map(post => ({
+ id: `swarm:${post.nodeDomain}:${post.id}`,
+ nodeDomain: post.nodeDomain,
+ originalPostId: post.id,
+ })),
+ viewer.id
+ )
+ : new Set();
return NextResponse.json({
- posts: timeline.posts.map(post => ({
- ...post,
- isLiked: likedPostIds.has(`swarm:${post.nodeDomain}:${post.id}`),
- })),
+ posts: applyInteractionFlags(timeline.posts as SwarmFeedPost[], likedPostIds, repostedPostIds),
sources: timeline.sources,
cached: false,
fetchedAt: timeline.fetchedAt,
diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts
index bde85f8..34ebbcb 100644
--- a/src/app/api/search/route.ts
+++ b/src/app/api/search/route.ts
@@ -4,6 +4,26 @@ import { ilike, or, desc, and, notInArray, eq, inArray } from 'drizzle-orm';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery';
+const embeddedPostRelations = {
+ author: true,
+ bot: true,
+ media: true,
+ replyTo: {
+ with: {
+ author: true,
+ bot: true,
+ media: true,
+ },
+ },
+} as const;
+
+const searchPostRelations = {
+ ...embeddedPostRelations,
+ repostOf: {
+ with: embeddedPostRelations,
+ },
+} as const;
+
type SearchUser = {
id: string;
handle: string;
@@ -176,11 +196,7 @@ export async function GET(request: Request) {
}
const postResults = await db.query.posts.findMany({
where: and(...postConditions),
- with: {
- author: true,
- media: true,
- bot: true,
- },
+ with: searchPostRelations,
orderBy: [desc(posts.createdAt)],
limit,
});
diff --git a/src/app/api/swarm/chat/conversations/[id]/route.ts b/src/app/api/swarm/chat/conversations/[id]/route.ts
index 133e71a..d8ba110 100644
--- a/src/app/api/swarm/chat/conversations/[id]/route.ts
+++ b/src/app/api/swarm/chat/conversations/[id]/route.ts
@@ -8,6 +8,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { db, chatConversations, chatMessages, users } from '@/db';
import { eq, and } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
+import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
import { z } from 'zod';
// Schema for conversation ID parameter
@@ -73,10 +74,14 @@ export async function DELETE(
if (isRemote) {
// Extract domain from handle (format: handle@domain)
- const domain = participant2Handle.split('@')[1];
+ const domain = normalizeNodeDomain(participant2Handle.split('@')[1]);
const handle = participant2Handle.split('@')[0];
try {
+ if (await isNodeBlocked(domain)) {
+ return NextResponse.json({ success: true });
+ }
+
const protocol = domain.includes('localhost') ? 'http' : 'https';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
diff --git a/src/app/api/swarm/chat/delete/route.ts b/src/app/api/swarm/chat/delete/route.ts
index 25bd2a5..be3c2ce 100644
--- a/src/app/api/swarm/chat/delete/route.ts
+++ b/src/app/api/swarm/chat/delete/route.ts
@@ -12,6 +12,7 @@ import { db, users, chatConversations } from '@/db';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature';
+import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
const deletionSchema = z.object({
senderHandle: z.string(),
@@ -30,6 +31,11 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const data = deletionSchema.parse(body);
+ const senderNodeDomain = normalizeNodeDomain(data.senderNodeDomain);
+
+ if (await isNodeBlocked(senderNodeDomain)) {
+ return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
+ }
// SECURITY: Verify the signature
const { signature, ...payload } = data;
@@ -37,7 +43,7 @@ export async function POST(request: NextRequest) {
payload,
signature,
data.senderHandle,
- data.senderNodeDomain
+ senderNodeDomain
);
if (!isValid) {
@@ -55,7 +61,7 @@ export async function POST(request: NextRequest) {
}
// Find the conversation with the sender
- const senderFullHandle = `${data.senderHandle}@${data.senderNodeDomain}`;
+ const senderFullHandle = `${data.senderHandle}@${senderNodeDomain}`;
const conversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, recipient.id),
diff --git a/src/app/api/swarm/inbox/route.ts b/src/app/api/swarm/inbox/route.ts
index 0c7bee0..832d056 100644
--- a/src/app/api/swarm/inbox/route.ts
+++ b/src/app/api/swarm/inbox/route.ts
@@ -29,6 +29,14 @@ const swarmPostSchema = z.object({
linkPreviewTitle: z.string().optional(),
linkPreviewDescription: z.string().optional(),
linkPreviewImage: z.string().optional(),
+ linkPreviewType: z.enum(['card', 'image', 'gallery', 'video']).optional(),
+ linkPreviewVideoUrl: z.string().optional(),
+ linkPreviewMedia: z.array(z.object({
+ url: z.string(),
+ width: z.number().nullable().optional(),
+ height: z.number().nullable().optional(),
+ mimeType: z.string().nullable().optional(),
+ })).optional(),
}),
author: z.object({
handle: z.string(),
diff --git a/src/app/api/swarm/interactions/follow/route.ts b/src/app/api/swarm/interactions/follow/route.ts
index c440694..903c761 100644
--- a/src/app/api/swarm/interactions/follow/route.ts
+++ b/src/app/api/swarm/interactions/follow/route.ts
@@ -15,6 +15,7 @@ import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
+import { buildNotificationTarget } from '@/lib/notifications';
const swarmFollowSchema = z.object({
targetHandle: localHandleSchema,
@@ -107,6 +108,7 @@ export async function POST(request: NextRequest) {
actorDisplayName: data.follow.followerDisplayName,
actorAvatarUrl: data.follow.followerAvatarUrl || null,
actorNodeDomain: data.follow.followerNodeDomain,
+ ...(targetUser.isBot ? buildNotificationTarget(targetUser) : {}),
type: 'follow',
});
console.log(`[Swarm] Created follow notification for @${data.targetHandle} from ${data.follow.followerHandle}@${data.follow.followerNodeDomain}`);
@@ -125,6 +127,7 @@ export async function POST(request: NextRequest) {
actorDisplayName: data.follow.followerDisplayName,
actorAvatarUrl: data.follow.followerAvatarUrl || null,
actorNodeDomain: data.follow.followerNodeDomain,
+ ...buildNotificationTarget(targetUser),
type: 'follow',
});
} catch (err) {
diff --git a/src/app/api/swarm/interactions/like/route.ts b/src/app/api/swarm/interactions/like/route.ts
index e498958..221f8bd 100644
--- a/src/app/api/swarm/interactions/like/route.ts
+++ b/src/app/api/swarm/interactions/like/route.ts
@@ -12,6 +12,7 @@ import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
+import { buildNotificationTarget } from '@/lib/notifications';
const swarmLikeSchema = z.object({
postId: z.string().uuid(),
@@ -88,6 +89,8 @@ export async function POST(request: NextRequest) {
.set({ likesCount: post.likesCount + 1 })
.where(eq(posts.id, data.postId));
+ const author = post.author as { isBot?: boolean; botOwnerId?: string; handle?: string; displayName?: string | null; avatarUrl?: string | null } | null;
+
// Create notification with actor info stored directly
try {
await db.insert(notifications).values({
@@ -98,6 +101,7 @@ export async function POST(request: NextRequest) {
actorNodeDomain: data.like.actorNodeDomain,
postId: data.postId,
postContent: post.content?.slice(0, 200) || null,
+ ...(author?.isBot ? buildNotificationTarget(author as any) : {}),
type: 'like',
});
console.log(`[Swarm] Created like notification for post ${data.postId} from ${data.like.actorHandle}@${data.like.actorNodeDomain}`);
@@ -108,7 +112,6 @@ export async function POST(request: NextRequest) {
}
// Also notify bot owner if this is a bot's post
- const author = post.author as { isBot?: boolean; botOwnerId?: string } | null;
if (author?.isBot && author.botOwnerId) {
try {
await db.insert(notifications).values({
@@ -119,6 +122,7 @@ export async function POST(request: NextRequest) {
actorNodeDomain: data.like.actorNodeDomain,
postId: data.postId,
postContent: post.content?.slice(0, 200) || null,
+ ...buildNotificationTarget(author as any),
type: 'like',
});
} catch (err) {
diff --git a/src/app/api/swarm/interactions/mention/route.ts b/src/app/api/swarm/interactions/mention/route.ts
index 1c2faa2..1f1e68e 100644
--- a/src/app/api/swarm/interactions/mention/route.ts
+++ b/src/app/api/swarm/interactions/mention/route.ts
@@ -12,6 +12,7 @@ import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
+import { buildNotificationTarget } from '@/lib/notifications';
const swarmMentionSchema = z.object({
mentionedHandle: localHandleSchema,
@@ -73,6 +74,7 @@ export async function POST(request: NextRequest) {
actorAvatarUrl: data.mention.actorAvatarUrl || null,
actorNodeDomain: data.mention.actorNodeDomain,
postContent: data.mention.postContent.slice(0, 200),
+ ...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}),
type: 'mention',
});
console.log(`[Swarm] Created mention notification for @${data.mentionedHandle} from ${data.mention.actorHandle}@${data.mention.actorNodeDomain}`);
@@ -90,6 +92,7 @@ export async function POST(request: NextRequest) {
actorAvatarUrl: data.mention.actorAvatarUrl || null,
actorNodeDomain: data.mention.actorNodeDomain,
postContent: data.mention.postContent.slice(0, 200),
+ ...buildNotificationTarget(mentionedUser),
type: 'mention',
});
} catch (err) {
diff --git a/src/app/api/swarm/interactions/repost/route.ts b/src/app/api/swarm/interactions/repost/route.ts
index fb480a6..e3f4fcd 100644
--- a/src/app/api/swarm/interactions/repost/route.ts
+++ b/src/app/api/swarm/interactions/repost/route.ts
@@ -7,11 +7,12 @@
*/
import { NextRequest, NextResponse } from 'next/server';
-import { db, posts, users, notifications } from '@/db';
-import { eq, sql } from 'drizzle-orm';
+import { db, posts, users, notifications, remoteReposts } from '@/db';
+import { eq, sql, and } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
+import { buildNotificationTarget } from '@/lib/notifications';
const swarmRepostSchema = z.object({
postId: z.string().uuid(),
@@ -64,11 +65,34 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
+ const existingRepost = await db.query.remoteReposts.findFirst({
+ where: and(
+ eq(remoteReposts.postId, data.postId),
+ eq(remoteReposts.actorHandle, data.repost.actorHandle),
+ eq(remoteReposts.actorNodeDomain, data.repost.actorNodeDomain),
+ ),
+ });
+
+ if (existingRepost) {
+ return NextResponse.json({
+ success: true,
+ message: 'Repost already recorded',
+ });
+ }
+
// Increment repost count
await db.update(posts)
.set({ repostsCount: sql`${posts.repostsCount} + 1` })
.where(eq(posts.id, data.postId));
+ await db.insert(remoteReposts).values({
+ postId: data.postId,
+ actorHandle: data.repost.actorHandle,
+ actorNodeDomain: data.repost.actorNodeDomain,
+ });
+
+ const author = post.author as { isBot?: boolean; botOwnerId?: string; handle?: string; displayName?: string | null; avatarUrl?: string | null } | null;
+
// Create notification with actor info stored directly
try {
await db.insert(notifications).values({
@@ -79,6 +103,7 @@ export async function POST(request: NextRequest) {
actorNodeDomain: data.repost.actorNodeDomain,
postId: data.postId,
postContent: post.content?.slice(0, 200) || null,
+ ...(author?.isBot ? buildNotificationTarget(author as any) : {}),
type: 'repost',
});
console.log(`[Swarm] Created repost notification for post ${data.postId} from ${data.repost.actorHandle}@${data.repost.actorNodeDomain}`);
@@ -87,7 +112,6 @@ export async function POST(request: NextRequest) {
}
// Also notify bot owner if this is a bot's post
- const author = post.author as { isBot?: boolean; botOwnerId?: string } | null;
if (author?.isBot && author.botOwnerId) {
try {
await db.insert(notifications).values({
@@ -98,6 +122,7 @@ export async function POST(request: NextRequest) {
actorNodeDomain: data.repost.actorNodeDomain,
postId: data.postId,
postContent: post.content?.slice(0, 200) || null,
+ ...buildNotificationTarget(author as any),
type: 'repost',
});
} catch (err) {
diff --git a/src/app/api/swarm/interactions/unrepost/route.ts b/src/app/api/swarm/interactions/unrepost/route.ts
index ae0c915..5e199eb 100644
--- a/src/app/api/swarm/interactions/unrepost/route.ts
+++ b/src/app/api/swarm/interactions/unrepost/route.ts
@@ -7,8 +7,8 @@
*/
import { NextRequest, NextResponse } from 'next/server';
-import { db, posts } from '@/db';
-import { eq, sql } from 'drizzle-orm';
+import { db, posts, remoteReposts } from '@/db';
+import { eq, sql, and } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
@@ -60,11 +60,28 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
+ const existingRepost = await db.query.remoteReposts.findFirst({
+ where: and(
+ eq(remoteReposts.postId, data.postId),
+ eq(remoteReposts.actorHandle, data.unrepost.actorHandle),
+ eq(remoteReposts.actorNodeDomain, data.unrepost.actorNodeDomain),
+ ),
+ });
+
+ if (!existingRepost) {
+ return NextResponse.json({
+ success: true,
+ message: 'Repost already removed',
+ });
+ }
+
// Decrement repost count
await db.update(posts)
.set({ repostsCount: sql`GREATEST(0, ${posts.repostsCount} - 1)` })
.where(eq(posts.id, data.postId));
+ await db.delete(remoteReposts).where(eq(remoteReposts.id, existingRepost.id));
+
console.log(`[Swarm] Received unrepost from ${data.unrepost.actorHandle}@${data.unrepost.actorNodeDomain} on post ${data.postId}`);
return NextResponse.json({
diff --git a/src/app/api/swarm/posts/[id]/route.ts b/src/app/api/swarm/posts/[id]/route.ts
index d816432..9795e54 100644
--- a/src/app/api/swarm/posts/[id]/route.ts
+++ b/src/app/api/swarm/posts/[id]/route.ts
@@ -5,9 +5,10 @@
*/
import { NextRequest, NextResponse } from 'next/server';
-import { db, posts } from '@/db';
+import { db, posts, userSwarmReposts, users } from '@/db';
import { eq, desc, and } from 'drizzle-orm';
import { z } from 'zod';
+import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
type RouteContext = { params: Promise<{ id: string }> };
@@ -43,7 +44,62 @@ export async function GET(request: NextRequest, context: RouteContext) {
});
if (!post || post.isRemoved) {
- return NextResponse.json({ error: 'Post not found' }, { status: 404 });
+ const remoteRepost = await db.query.userSwarmReposts.findFirst({
+ where: eq(userSwarmReposts.id, postId),
+ });
+
+ if (!remoteRepost) {
+ return NextResponse.json({ error: 'Post not found' }, { status: 404 });
+ }
+
+ const repostAuthor = await db.query.users.findFirst({
+ where: eq(users.id, remoteRepost.userId),
+ });
+
+ return NextResponse.json({
+ post: {
+ id: remoteRepost.id,
+ apId: null,
+ content: '',
+ createdAt: remoteRepost.repostedAt.toISOString(),
+ likesCount: 0,
+ repostsCount: 0,
+ repliesCount: 0,
+ author: repostAuthor ? {
+ handle: repostAuthor.handle,
+ displayName: repostAuthor.displayName,
+ avatarUrl: repostAuthor.avatarUrl,
+ } : null,
+ media: [],
+ repostOfId: remoteRepost.originalPostId,
+ repostOf: {
+ id: remoteRepost.originalPostId,
+ originalPostId: remoteRepost.originalPostId,
+ content: remoteRepost.content,
+ createdAt: remoteRepost.postCreatedAt.toISOString(),
+ likesCount: remoteRepost.likesCount,
+ repostsCount: remoteRepost.repostsCount,
+ repliesCount: remoteRepost.repliesCount,
+ nodeDomain: remoteRepost.nodeDomain,
+ author: {
+ handle: remoteRepost.authorHandle.includes('@')
+ ? remoteRepost.authorHandle
+ : `${remoteRepost.authorHandle}@${remoteRepost.nodeDomain}`,
+ displayName: remoteRepost.authorDisplayName,
+ avatarUrl: remoteRepost.authorAvatarUrl,
+ },
+ media: remoteRepost.mediaJson ? JSON.parse(remoteRepost.mediaJson) : [],
+ linkPreviewUrl: remoteRepost.linkPreviewUrl,
+ linkPreviewTitle: remoteRepost.linkPreviewTitle,
+ linkPreviewDescription: remoteRepost.linkPreviewDescription,
+ linkPreviewImage: remoteRepost.linkPreviewImage,
+ linkPreviewType: remoteRepost.linkPreviewType,
+ linkPreviewVideoUrl: remoteRepost.linkPreviewVideoUrl,
+ linkPreviewMedia: parseLinkPreviewMediaJson(remoteRepost.linkPreviewMediaJson) || [],
+ },
+ },
+ replies: [],
+ });
}
// Get replies
@@ -84,6 +140,9 @@ export async function GET(request: NextRequest, context: RouteContext) {
linkPreviewTitle: post.linkPreviewTitle,
linkPreviewDescription: post.linkPreviewDescription,
linkPreviewImage: post.linkPreviewImage,
+ linkPreviewType: post.linkPreviewType,
+ linkPreviewVideoUrl: post.linkPreviewVideoUrl,
+ linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson) || [],
},
replies: replies.map(r => {
const replyAuthor = r.author as any;
@@ -103,6 +162,13 @@ export async function GET(request: NextRequest, context: RouteContext) {
url: m.url,
altText: m.altText,
})) || [],
+ linkPreviewUrl: r.linkPreviewUrl,
+ linkPreviewTitle: r.linkPreviewTitle,
+ linkPreviewDescription: r.linkPreviewDescription,
+ linkPreviewImage: r.linkPreviewImage,
+ linkPreviewType: r.linkPreviewType,
+ linkPreviewVideoUrl: r.linkPreviewVideoUrl,
+ linkPreviewMedia: parseLinkPreviewMediaJson(r.linkPreviewMediaJson) || [],
};
}),
});
diff --git a/src/app/api/swarm/replies/route.ts b/src/app/api/swarm/replies/route.ts
index bd260e1..1c4a5ba 100644
--- a/src/app/api/swarm/replies/route.ts
+++ b/src/app/api/swarm/replies/route.ts
@@ -11,6 +11,7 @@ import { eq, desc, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { upsertRemoteUser } from '@/lib/swarm/user-cache';
+import { buildNotificationTarget } from '@/lib/notifications';
// Schema for incoming swarm reply
const swarmReplySchema = z.object({
@@ -145,6 +146,8 @@ export async function POST(request: NextRequest) {
await syncParentReplyCount(data.postId);
+ const parentAuthor = parentPost.author as { isBot?: boolean; botOwnerId?: string; handle?: string; displayName?: string | null; avatarUrl?: string | null } | null;
+
if (parentPost.userId !== remoteUser.id) {
await db.insert(notifications).values({
userId: parentPost.userId,
@@ -154,8 +157,23 @@ export async function POST(request: NextRequest) {
actorNodeDomain: sourceDomain,
postId: data.postId,
postContent: data.reply.content.slice(0, 200),
+ ...(parentAuthor?.isBot ? buildNotificationTarget(parentAuthor as any) : {}),
type: 'reply',
});
+
+ if (parentAuthor?.isBot && parentAuthor.botOwnerId) {
+ await db.insert(notifications).values({
+ userId: parentAuthor.botOwnerId,
+ actorHandle: data.reply.author.handle,
+ actorDisplayName: data.reply.author.displayName || data.reply.author.handle,
+ actorAvatarUrl: data.reply.author.avatarUrl || null,
+ actorNodeDomain: sourceDomain,
+ postId: data.postId,
+ postContent: data.reply.content.slice(0, 200),
+ ...buildNotificationTarget(parentAuthor as any),
+ type: 'reply',
+ });
+ }
}
return NextResponse.json({ success: true, message: 'Reply received' });
diff --git a/src/app/api/swarm/timeline/route.ts b/src/app/api/swarm/timeline/route.ts
index 7d7d887..b071a71 100644
--- a/src/app/api/swarm/timeline/route.ts
+++ b/src/app/api/swarm/timeline/route.ts
@@ -6,7 +6,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media, nodes } from '@/db';
-import { eq, desc, and, isNull, lt, sql } from 'drizzle-orm';
+import { eq, desc, and, isNull, lt, sql, inArray } from 'drizzle-orm';
+import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
export interface SwarmPost {
id: string;
@@ -15,6 +16,8 @@ export interface SwarmPost {
isReply?: boolean;
replyToId?: string | null;
swarmReplyToId?: string | null;
+ repostOfId?: string | null;
+ repostOf?: SwarmPost | null;
author: {
handle: string;
displayName: string;
@@ -34,6 +37,74 @@ export interface SwarmPost {
linkPreviewTitle?: string;
linkPreviewDescription?: string;
linkPreviewImage?: string;
+ linkPreviewType?: 'card' | 'image' | 'gallery' | 'video';
+ linkPreviewVideoUrl?: string;
+ linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }>;
+}
+
+interface TimelinePostRow {
+ id: string;
+ content: string;
+ createdAt: Date;
+ replyToId: string | null;
+ swarmReplyToId: string | null;
+ repostOfId: string | null;
+ isNsfw: boolean;
+ likesCount: number;
+ repostsCount: number;
+ repliesCount: number;
+ linkPreviewUrl: string | null;
+ linkPreviewTitle: string | null;
+ linkPreviewDescription: string | null;
+ linkPreviewImage: string | null;
+ linkPreviewType: string | null;
+ linkPreviewVideoUrl: string | null;
+ linkPreviewMediaJson: string | null;
+ authorHandle: string;
+ authorDisplayName: string | null;
+ authorAvatarUrl: string | null;
+ authorIsNsfw: boolean;
+ authorIsBot: boolean | null;
+}
+
+function buildSwarmPost(
+ post: TimelinePostRow,
+ mediaByPostId: Map>,
+ repostById: Map,
+ nodeDomain: string,
+ nodeIsNsfw: boolean
+): SwarmPost {
+ return {
+ id: post.id,
+ content: post.content,
+ createdAt: post.createdAt.toISOString(),
+ isReply: Boolean(post.replyToId || post.swarmReplyToId),
+ replyToId: post.replyToId,
+ swarmReplyToId: post.swarmReplyToId,
+ repostOfId: post.repostOfId,
+ repostOf: post.repostOfId ? repostById.get(post.repostOfId) || null : null,
+ author: {
+ handle: post.authorHandle,
+ displayName: post.authorDisplayName || post.authorHandle,
+ avatarUrl: post.authorAvatarUrl || undefined,
+ isNsfw: post.authorIsNsfw,
+ isBot: post.authorIsBot || undefined,
+ },
+ nodeDomain,
+ nodeIsNsfw,
+ isNsfw: post.isNsfw || post.authorIsNsfw,
+ likeCount: post.likesCount,
+ repostCount: post.repostsCount,
+ replyCount: post.repliesCount,
+ media: mediaByPostId.get(post.id),
+ linkPreviewUrl: post.linkPreviewUrl || undefined,
+ linkPreviewTitle: post.linkPreviewTitle || undefined,
+ linkPreviewDescription: post.linkPreviewDescription || undefined,
+ linkPreviewImage: post.linkPreviewImage || undefined,
+ linkPreviewType: (post.linkPreviewType as SwarmPost['linkPreviewType']) || undefined,
+ linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined,
+ linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson),
+ };
}
/**
@@ -89,6 +160,7 @@ export async function GET(request: NextRequest) {
createdAt: posts.createdAt,
replyToId: posts.replyToId,
swarmReplyToId: posts.swarmReplyToId,
+ repostOfId: posts.repostOfId,
isNsfw: posts.isNsfw,
likesCount: posts.likesCount,
repostsCount: posts.repostsCount,
@@ -97,6 +169,9 @@ export async function GET(request: NextRequest) {
linkPreviewTitle: posts.linkPreviewTitle,
linkPreviewDescription: posts.linkPreviewDescription,
linkPreviewImage: posts.linkPreviewImage,
+ linkPreviewType: posts.linkPreviewType,
+ linkPreviewVideoUrl: posts.linkPreviewVideoUrl,
+ linkPreviewMediaJson: posts.linkPreviewMediaJson,
authorHandle: users.handle,
authorDisplayName: users.displayName,
authorAvatarUrl: users.avatarUrl,
@@ -112,47 +187,84 @@ export async function GET(request: NextRequest) {
console.log(`[Swarm Timeline API] Found ${recentPosts.length} posts for ${nodeDomain}`);
- // Fetch media for each post
- const swarmPosts: SwarmPost[] = [];
+ const repostIds = Array.from(new Set(
+ recentPosts
+ .map(post => post.repostOfId)
+ .filter((id): id is string => Boolean(id))
+ ));
- for (const post of recentPosts) {
- const postMedia = await db
- .select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
- .from(media)
- .where(eq(media.postId, post.id));
+ const repostTargets = repostIds.length > 0
+ ? await db
+ .select({
+ id: posts.id,
+ content: posts.content,
+ createdAt: posts.createdAt,
+ replyToId: posts.replyToId,
+ swarmReplyToId: posts.swarmReplyToId,
+ repostOfId: posts.repostOfId,
+ isNsfw: posts.isNsfw,
+ likesCount: posts.likesCount,
+ repostsCount: posts.repostsCount,
+ repliesCount: posts.repliesCount,
+ linkPreviewUrl: posts.linkPreviewUrl,
+ linkPreviewTitle: posts.linkPreviewTitle,
+ linkPreviewDescription: posts.linkPreviewDescription,
+ linkPreviewImage: posts.linkPreviewImage,
+ linkPreviewType: posts.linkPreviewType,
+ linkPreviewVideoUrl: posts.linkPreviewVideoUrl,
+ linkPreviewMediaJson: posts.linkPreviewMediaJson,
+ authorHandle: users.handle,
+ authorDisplayName: users.displayName,
+ authorAvatarUrl: users.avatarUrl,
+ authorIsNsfw: users.isNsfw,
+ authorIsBot: users.isBot,
+ })
+ .from(posts)
+ .innerJoin(users, eq(posts.userId, users.id))
+ .where(and(
+ inArray(posts.id, repostIds),
+ eq(posts.isRemoved, false),
+ ))
+ : [];
- swarmPosts.push({
- id: post.id,
- content: post.content,
- createdAt: post.createdAt.toISOString(),
- isReply: Boolean(post.replyToId || post.swarmReplyToId),
- replyToId: post.replyToId,
- swarmReplyToId: post.swarmReplyToId,
- author: {
- handle: post.authorHandle,
- displayName: post.authorDisplayName || post.authorHandle,
- avatarUrl: post.authorAvatarUrl || undefined,
- isNsfw: post.authorIsNsfw,
- isBot: post.authorIsBot,
- },
- nodeDomain,
- nodeIsNsfw,
- isNsfw: post.isNsfw || post.authorIsNsfw, // Post-level NSFW (not node-level)
- likeCount: post.likesCount,
- repostCount: post.repostsCount,
- replyCount: post.repliesCount,
- media: postMedia.length > 0 ? postMedia.map(m => ({
- url: m.url,
- mimeType: m.mimeType || undefined,
- altText: m.altText || undefined,
- })) : undefined,
- linkPreviewUrl: post.linkPreviewUrl || undefined,
- linkPreviewTitle: post.linkPreviewTitle || undefined,
- linkPreviewDescription: post.linkPreviewDescription || undefined,
- linkPreviewImage: post.linkPreviewImage || undefined,
+ const mediaPostIds = Array.from(new Set([
+ ...recentPosts.map(post => post.id),
+ ...repostTargets.map(post => post.id),
+ ]));
+
+ const mediaRows = mediaPostIds.length > 0
+ ? await db
+ .select({
+ postId: media.postId,
+ url: media.url,
+ mimeType: media.mimeType,
+ altText: media.altText,
+ })
+ .from(media)
+ .where(inArray(media.postId, mediaPostIds))
+ : [];
+
+ const mediaByPostId = new Map>();
+ for (const item of mediaRows) {
+ if (!item.postId) continue;
+ const bucket = mediaByPostId.get(item.postId) || [];
+ bucket.push({
+ url: item.url,
+ mimeType: item.mimeType || undefined,
+ altText: item.altText || undefined,
});
+ mediaByPostId.set(item.postId, bucket);
}
+ const repostById = new Map();
+ for (const post of repostTargets) {
+ repostById.set(post.id, buildSwarmPost(post, mediaByPostId, repostById, nodeDomain, nodeIsNsfw));
+ }
+
+ const swarmPosts = recentPosts.map(post =>
+ buildSwarmPost(post, mediaByPostId, repostById, nodeDomain, nodeIsNsfw)
+ );
+
return NextResponse.json({
posts: swarmPosts,
nodeDomain,
diff --git a/src/app/api/swarm/users/[handle]/route.ts b/src/app/api/swarm/users/[handle]/route.ts
index b9375ab..a516c6e 100644
--- a/src/app/api/swarm/users/[handle]/route.ts
+++ b/src/app/api/swarm/users/[handle]/route.ts
@@ -5,8 +5,9 @@
*/
import { NextRequest, NextResponse } from 'next/server';
-import { db, posts, users, media, nodes } from '@/db';
+import { db, posts, users, userSwarmReposts } from '@/db';
import { eq, desc, and, isNull } from 'drizzle-orm';
+import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
export interface SwarmUserProfile {
handle: string;
@@ -28,21 +29,144 @@ export interface SwarmUserProfile {
export interface SwarmUserPost {
id: string;
+ originalPostId?: string;
content: string;
createdAt: string;
isNsfw: boolean;
likesCount: number;
repostsCount: number;
repliesCount: number;
+ nodeDomain?: string;
+ author?: {
+ handle: string;
+ displayName?: string;
+ avatarUrl?: string;
+ isBot?: boolean;
+ nodeDomain?: string;
+ };
media?: { url: string; mimeType?: string; altText?: string }[];
linkPreviewUrl?: string;
linkPreviewTitle?: string;
linkPreviewDescription?: string;
linkPreviewImage?: string;
+ linkPreviewType?: 'card' | 'image' | 'gallery' | 'video';
+ linkPreviewVideoUrl?: string;
+ linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }>;
+ repostOfId?: string;
+ repostOf?: SwarmUserPost | null;
}
type RouteContext = { params: Promise<{ handle: string }> };
+const profilePostRelations = {
+ author: true,
+ media: true,
+ repostOf: {
+ with: {
+ author: true,
+ media: true,
+ },
+ },
+} as const;
+
+function parseMediaJson(mediaJson: string | null) {
+ if (!mediaJson) {
+ return [];
+ }
+
+ try {
+ return JSON.parse(mediaJson);
+ } catch {
+ return [];
+ }
+}
+
+function mapLocalPostToSwarmPost(post: any, nodeDomain: string): SwarmUserPost {
+ return {
+ id: post.id,
+ originalPostId: post.id,
+ content: post.content,
+ createdAt: post.createdAt.toISOString(),
+ isNsfw: post.isNsfw,
+ likesCount: post.likesCount,
+ repostsCount: post.repostsCount,
+ repliesCount: post.repliesCount,
+ nodeDomain,
+ author: post.author ? {
+ handle: post.author.handle,
+ displayName: post.author.displayName || post.author.handle,
+ avatarUrl: post.author.avatarUrl || undefined,
+ isBot: post.author.isBot || undefined,
+ nodeDomain,
+ } : undefined,
+ media: (post.media || []).map((item: any) => ({
+ url: item.url,
+ mimeType: item.mimeType || undefined,
+ altText: item.altText || undefined,
+ })),
+ linkPreviewUrl: post.linkPreviewUrl || undefined,
+ linkPreviewTitle: post.linkPreviewTitle || undefined,
+ linkPreviewDescription: post.linkPreviewDescription || undefined,
+ linkPreviewImage: post.linkPreviewImage || undefined,
+ linkPreviewType: post.linkPreviewType || undefined,
+ linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined,
+ linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson),
+ repostOfId: post.repostOfId || undefined,
+ repostOf: post.repostOf ? mapLocalPostToSwarmPost(post.repostOf, nodeDomain) : undefined,
+ };
+}
+
+function mapUserSwarmRepostToSwarmPost(
+ row: typeof userSwarmReposts.$inferSelect,
+ author: typeof users.$inferSelect,
+ nodeDomain: string
+): SwarmUserPost {
+ return {
+ id: row.id,
+ originalPostId: row.id,
+ content: '',
+ createdAt: row.repostedAt.toISOString(),
+ isNsfw: false,
+ likesCount: 0,
+ repostsCount: 0,
+ repliesCount: 0,
+ nodeDomain,
+ author: {
+ handle: author.handle,
+ displayName: author.displayName || author.handle,
+ avatarUrl: author.avatarUrl || undefined,
+ isBot: author.isBot || undefined,
+ nodeDomain,
+ },
+ repostOfId: row.originalPostId,
+ repostOf: {
+ id: row.originalPostId,
+ originalPostId: row.originalPostId,
+ content: row.content,
+ createdAt: row.postCreatedAt.toISOString(),
+ isNsfw: false,
+ likesCount: row.likesCount,
+ repostsCount: row.repostsCount,
+ repliesCount: row.repliesCount,
+ nodeDomain: row.nodeDomain,
+ author: {
+ handle: row.authorHandle.includes('@') ? row.authorHandle : `${row.authorHandle}@${row.nodeDomain}`,
+ displayName: row.authorDisplayName || row.authorHandle,
+ avatarUrl: row.authorAvatarUrl || undefined,
+ nodeDomain: row.nodeDomain,
+ },
+ media: parseMediaJson(row.mediaJson),
+ linkPreviewUrl: row.linkPreviewUrl || undefined,
+ linkPreviewTitle: row.linkPreviewTitle || undefined,
+ linkPreviewDescription: row.linkPreviewDescription || undefined,
+ linkPreviewImage: row.linkPreviewImage || undefined,
+ linkPreviewType: (row.linkPreviewType as SwarmUserPost['linkPreviewType']) || undefined,
+ linkPreviewVideoUrl: row.linkPreviewVideoUrl || undefined,
+ linkPreviewMedia: parseLinkPreviewMediaJson(row.linkPreviewMediaJson),
+ },
+ };
+}
+
/**
* GET /api/swarm/users/[handle]
*
@@ -97,61 +221,30 @@ export async function GET(request: NextRequest, context: RouteContext) {
did: user.did || undefined,
};
- // Get user's recent posts
- const userPosts = await db
- .select({
- id: posts.id,
- content: posts.content,
- createdAt: posts.createdAt,
- isNsfw: posts.isNsfw,
- likesCount: posts.likesCount,
- repostsCount: posts.repostsCount,
- repliesCount: posts.repliesCount,
- linkPreviewUrl: posts.linkPreviewUrl,
- linkPreviewTitle: posts.linkPreviewTitle,
- linkPreviewDescription: posts.linkPreviewDescription,
- linkPreviewImage: posts.linkPreviewImage,
- })
- .from(posts)
- .where(
- and(
- eq(posts.userId, user.id),
- eq(posts.isRemoved, false),
- isNull(posts.replyToId),
- isNull(posts.swarmReplyToId)
- )
- )
- .orderBy(desc(posts.createdAt))
- .limit(limit);
+ const localPosts = await db.query.posts.findMany({
+ where: and(
+ eq(posts.userId, user.id),
+ eq(posts.isRemoved, false),
+ isNull(posts.replyToId),
+ isNull(posts.swarmReplyToId)
+ ),
+ with: profilePostRelations,
+ orderBy: [desc(posts.createdAt)],
+ limit: limit * 2,
+ });
- // Fetch media for each post
- const swarmPosts: SwarmUserPost[] = [];
+ const remoteRepostRows = await db.query.userSwarmReposts.findMany({
+ where: eq(userSwarmReposts.userId, user.id),
+ orderBy: [desc(userSwarmReposts.repostedAt)],
+ limit: limit * 2,
+ });
- for (const post of userPosts) {
- const postMedia = await db
- .select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
- .from(media)
- .where(eq(media.postId, post.id));
-
- swarmPosts.push({
- id: post.id,
- content: post.content,
- createdAt: post.createdAt.toISOString(),
- isNsfw: post.isNsfw,
- likesCount: post.likesCount,
- repostsCount: post.repostsCount,
- repliesCount: post.repliesCount,
- media: postMedia.length > 0 ? postMedia.map(m => ({
- url: m.url,
- mimeType: m.mimeType || undefined,
- altText: m.altText || undefined,
- })) : undefined,
- linkPreviewUrl: post.linkPreviewUrl || undefined,
- linkPreviewTitle: post.linkPreviewTitle || undefined,
- linkPreviewDescription: post.linkPreviewDescription || undefined,
- linkPreviewImage: post.linkPreviewImage || undefined,
- });
- }
+ const swarmPosts: SwarmUserPost[] = [
+ ...localPosts.map((post) => mapLocalPostToSwarmPost(post, nodeDomain)),
+ ...remoteRepostRows.map((row) => mapUserSwarmRepostToSwarmPost(row, user, nodeDomain)),
+ ]
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
+ .slice(0, limit);
return NextResponse.json({
profile,
diff --git a/src/app/api/users/[handle]/follow/route.ts b/src/app/api/users/[handle]/follow/route.ts
index a7d15d0..7d38a1d 100644
--- a/src/app/api/users/[handle]/follow/route.ts
+++ b/src/app/api/users/[handle]/follow/route.ts
@@ -6,6 +6,7 @@ import { requireAuth } from '@/lib/auth';
import { requireSignedAction } from '@/lib/auth/verify-signature';
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery';
+import { buildNotificationTarget } from '@/lib/notifications';
type RouteContext = { params: Promise<{ handle: string }> };
@@ -220,6 +221,7 @@ export async function POST(request: Request, context: RouteContext) {
actorDisplayName: currentUser.displayName,
actorAvatarUrl: currentUser.avatarUrl,
actorNodeDomain: null,
+ ...(targetUser.isBot ? buildNotificationTarget(targetUser) : {}),
type: 'follow',
});
@@ -232,6 +234,7 @@ export async function POST(request: Request, context: RouteContext) {
actorDisplayName: currentUser.displayName,
actorAvatarUrl: currentUser.avatarUrl,
actorNodeDomain: null,
+ ...buildNotificationTarget(targetUser),
type: 'follow',
});
}
diff --git a/src/app/api/users/[handle]/likes/route.ts b/src/app/api/users/[handle]/likes/route.ts
index 718cef6..762ee87 100644
--- a/src/app/api/users/[handle]/likes/route.ts
+++ b/src/app/api/users/[handle]/likes/route.ts
@@ -1,9 +1,34 @@
import { NextResponse } from 'next/server';
import { db, likes, posts, users, userSwarmLikes } from '@/db';
import { eq, desc, and, inArray } from 'drizzle-orm';
+import { discoverNode } from '@/lib/swarm/discovery';
+import { isSwarmNode } from '@/lib/swarm/interactions';
+import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts';
+import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
+import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
type RouteContext = { params: Promise<{ handle: string }> };
+const embeddedPostRelations = {
+ author: true,
+ bot: true,
+ media: true,
+ replyTo: {
+ with: {
+ author: true,
+ bot: true,
+ media: true,
+ },
+ },
+} as const;
+
+const likedPostRelations = {
+ ...embeddedPostRelations,
+ repostOf: {
+ with: embeddedPostRelations,
+ },
+} as const;
+
const parseMediaJson = (mediaJson: string | null) => {
if (!mediaJson) {
return [];
@@ -22,11 +47,88 @@ export async function GET(request: Request, context: RouteContext) {
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
+ const remote = parseRemoteHandle(handle);
+
+ const fetchRemoteLikesRoute = async () => {
+ if (!remote) {
+ return NextResponse.json({ posts: [], nextCursor: null });
+ }
+
+ const baseUrl = getRemoteBaseUrl(remote.domain);
+ const res = await fetch(`${baseUrl}/api/users/${remote.handle}/likes?limit=${limit}`, {
+ headers: { Accept: 'application/json' },
+ signal: AbortSignal.timeout(10000),
+ });
+
+ if (!res.ok) {
+ return NextResponse.json({ posts: [], nextCursor: null });
+ }
+
+ const data = await res.json();
+ const { getSession } = await import('@/lib/auth');
+ const session = await getSession();
+ const viewer = session?.user;
+ const mappedPosts = (data.posts || []).map((post: any) => mapRemoteProfilePost(post, remote.domain));
+ const repostedIds = viewer
+ ? await getViewerSwarmRepostedPostIds(
+ mappedPosts.map((post: any) => ({
+ id: post.id,
+ nodeDomain: remote.domain,
+ originalPostId: post.originalPostId || post.id.split(':').pop(),
+ })),
+ viewer.id
+ )
+ : new Set();
+ return NextResponse.json({
+ posts: mappedPosts.map((post: any) => ({
+ ...post,
+ isReposted: repostedIds.has(post.id),
+ })),
+ nextCursor: null,
+ });
+ };
+
+ if (!db) {
+ if (!remote) {
+ return NextResponse.json({ posts: [], nextCursor: null });
+ }
+
+ let swarm = await isSwarmNode(remote.domain);
+ if (!swarm) {
+ const discovery = await discoverNode(remote.domain);
+ swarm = discovery.success;
+ }
+
+ if (!swarm) {
+ return NextResponse.json({ posts: [], nextCursor: null });
+ }
+
+ return await fetchRemoteLikesRoute();
+ }
// Find the user
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
+ const isRemotePlaceholder = user && cleanHandle.includes('@');
+
+ if (!user || isRemotePlaceholder) {
+ if (!remote) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ let swarm = await isSwarmNode(remote.domain);
+ if (!swarm) {
+ const discovery = await discoverNode(remote.domain);
+ swarm = discovery.success;
+ }
+
+ if (!swarm) {
+ return NextResponse.json({ posts: [], nextCursor: null });
+ }
+
+ return await fetchRemoteLikesRoute();
+ }
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
@@ -42,11 +144,7 @@ export async function GET(request: Request, context: RouteContext) {
where: eq(likes.userId, user.id),
with: {
post: {
- with: {
- author: true,
- media: true,
- bot: true,
- },
+ with: likedPostRelations,
},
},
orderBy: [desc(likes.createdAt)],
@@ -82,6 +180,9 @@ export async function GET(request: Request, context: RouteContext) {
linkPreviewTitle: like.linkPreviewTitle,
linkPreviewDescription: like.linkPreviewDescription,
linkPreviewImage: like.linkPreviewImage,
+ linkPreviewType: like.linkPreviewType,
+ linkPreviewVideoUrl: like.linkPreviewVideoUrl,
+ linkPreviewMedia: parseLinkPreviewMediaJson(like.linkPreviewMediaJson) || null,
isSwarm: true,
nodeDomain: like.nodeDomain,
likedAt: like.likedAt.toISOString(),
@@ -110,6 +211,17 @@ export async function GET(request: Request, context: RouteContext) {
.filter((post: any) => !post.isSwarm)
.map((post: any) => post.id)
.filter(Boolean);
+ const swarmTargets = likedPosts
+ .filter((post: any) => post.isSwarm)
+ .map((post: any) => ({
+ id: post.id,
+ nodeDomain: post.nodeDomain,
+ originalPostId: post.originalPostId,
+ }))
+ .filter((post: any) => post.nodeDomain && post.originalPostId);
+ const swarmRepostedIds = swarmTargets.length > 0
+ ? await getViewerSwarmRepostedPostIds(swarmTargets as any, viewer.id)
+ : new Set();
if (localPostIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
@@ -132,12 +244,13 @@ export async function GET(request: Request, context: RouteContext) {
likedPosts = likedPosts.map(p => ({
...p!,
isLiked: p!.isSwarm ? isOwnLikesView : likedPostIds.has(p!.id),
- isReposted: repostedPostIds.has(p!.id),
+ isReposted: p!.isSwarm ? swarmRepostedIds.has(p!.id) : repostedPostIds.has(p!.id),
})) as any;
} else {
likedPosts = likedPosts.map(p => ({
...p!,
isLiked: p!.isSwarm ? isOwnLikesView : p!.isLiked,
+ isReposted: p!.isSwarm ? swarmRepostedIds.has(p!.id) : p!.isReposted,
})) as any;
}
}
diff --git a/src/app/api/users/[handle]/posts/route.ts b/src/app/api/users/[handle]/posts/route.ts
index 0381a80..ea0a6f0 100644
--- a/src/app/api/users/[handle]/posts/route.ts
+++ b/src/app/api/users/[handle]/posts/route.ts
@@ -1,24 +1,169 @@
import { NextResponse } from 'next/server';
-import { db, posts, users, likes } from '@/db';
+import { db, posts, users, likes, userSwarmReposts } from '@/db';
import { eq, desc, and, inArray, lt, sql, isNull } from 'drizzle-orm';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery';
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
+import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts';
+import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
+
+const embeddedPostRelations = {
+ author: true,
+ bot: true,
+ media: true,
+ replyTo: {
+ with: {
+ author: true,
+ bot: true,
+ media: true,
+ },
+ },
+} as const;
+
+const userPostRelations = {
+ ...embeddedPostRelations,
+ repostOf: {
+ with: embeddedPostRelations,
+ },
+} as const;
type RouteContext = { params: Promise<{ handle: string }> };
-const parseRemoteHandle = (handle: string) => {
- const clean = handle.toLowerCase().replace(/^@/, '');
- const parts = clean.split('@').filter(Boolean);
- if (parts.length === 2) {
- return { handle: parts[0], domain: parts[1] };
- }
- return null;
+type FeedPostWithChildren = {
+ id: string;
+ createdAt?: string | Date;
+ repostOf?: FeedPostWithChildren | null;
+ replyTo?: FeedPostWithChildren | null;
+ isLiked?: boolean;
+ isReposted?: boolean;
+ nodeDomain?: string | null;
+ originalPostId?: string | null;
};
+function parseMediaJson(mediaJson: string | null) {
+ if (!mediaJson) {
+ return [];
+ }
+
+ try {
+ return JSON.parse(mediaJson);
+ } catch {
+ return [];
+ }
+}
+
+function mapUserSwarmRepostToFeedPost(
+ row: typeof userSwarmReposts.$inferSelect,
+ author: Pick
+): FeedPostWithChildren {
+ const remoteAuthorHandle = row.authorHandle.includes('@')
+ ? row.authorHandle
+ : `${row.authorHandle}@${row.nodeDomain}`;
+ const remoteOriginalId = `swarm:${row.nodeDomain}:${row.originalPostId}`;
+
+ return {
+ id: `swarm-repost:${row.id}`,
+ content: '',
+ createdAt: row.repostedAt.toISOString(),
+ likesCount: 0,
+ repostsCount: 0,
+ repliesCount: 0,
+ author: {
+ id: author.id,
+ handle: author.handle,
+ displayName: author.displayName,
+ avatarUrl: author.avatarUrl,
+ isBot: author.isBot,
+ },
+ repostOfId: remoteOriginalId,
+ repostOf: {
+ id: remoteOriginalId,
+ originalPostId: row.originalPostId,
+ content: row.content,
+ createdAt: row.postCreatedAt.toISOString(),
+ likesCount: row.likesCount,
+ repostsCount: row.repostsCount,
+ repliesCount: row.repliesCount,
+ isSwarm: true,
+ nodeDomain: row.nodeDomain,
+ author: {
+ id: `swarm:${row.nodeDomain}:${row.authorHandle}`,
+ handle: remoteAuthorHandle,
+ displayName: row.authorDisplayName || row.authorHandle,
+ avatarUrl: row.authorAvatarUrl,
+ isRemote: true,
+ nodeDomain: row.nodeDomain,
+ },
+ media: parseMediaJson(row.mediaJson),
+ linkPreviewUrl: row.linkPreviewUrl,
+ linkPreviewTitle: row.linkPreviewTitle,
+ linkPreviewDescription: row.linkPreviewDescription,
+ linkPreviewImage: row.linkPreviewImage,
+ linkPreviewType: row.linkPreviewType,
+ linkPreviewVideoUrl: row.linkPreviewVideoUrl,
+ linkPreviewMedia: parseLinkPreviewMediaJson(row.linkPreviewMediaJson) || null,
+ },
+ } as any;
+}
+
+function collectNestedPosts(posts: FeedPostWithChildren[]): FeedPostWithChildren[] {
+ const collected: FeedPostWithChildren[] = [];
+ const seen = new Set();
+
+ const visit = (post: FeedPostWithChildren | null | undefined) => {
+ if (!post || seen.has(post.id)) return;
+ seen.add(post.id);
+ collected.push(post);
+ visit(post.repostOf);
+ visit(post.replyTo);
+ };
+
+ posts.forEach(visit);
+ return collected;
+}
+
+function applyInteractionFlags(
+ posts: FeedPostWithChildren[],
+ likedIds: Set,
+ repostedIds: Set
+): FeedPostWithChildren[] {
+ return posts.map((post) => ({
+ ...post,
+ isLiked: likedIds.has(post.id),
+ isReposted: repostedIds.has(post.id),
+ repostOf: post.repostOf ? applyInteractionFlags([post.repostOf], likedIds, repostedIds)[0] : post.repostOf,
+ replyTo: post.replyTo ? applyInteractionFlags([post.replyTo], likedIds, repostedIds)[0] : post.replyTo,
+ }));
+}
+
+function getPostTimestamp(post: { createdAt?: string | Date }) {
+ if (!post.createdAt) {
+ return 0;
+ }
+
+ return new Date(post.createdAt).getTime();
+}
+
+async function getMixedProfileCursorDate(cursor: string | null) {
+ if (!cursor) {
+ return null;
+ }
+
+ if (cursor.startsWith('swarm-repost:')) {
+ const repostRow = await db.query.userSwarmReposts.findFirst({
+ where: eq(userSwarmReposts.id, cursor.replace('swarm-repost:', '')),
+ });
+ return repostRow?.repostedAt ?? null;
+ }
+
+ const cursorPost = await db.query.posts.findFirst({
+ where: eq(posts.id, cursor),
+ });
+ return cursorPost?.createdAt ?? null;
+}
+
async function populateViewerLikeState(
- remotePosts: any[],
- domain: string
+ remotePosts: any[]
) {
if (!remotePosts.length) {
return remotePosts;
@@ -34,20 +179,32 @@ async function populateViewerLikeState(
return remotePosts;
}
+ const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts');
+
+ const allRemotePosts = collectNestedPosts(remotePosts as FeedPostWithChildren[]);
+ const swarmTargets = allRemotePosts
+ .filter((post) => post.id.startsWith('swarm:') && post.originalPostId && post.nodeDomain)
+ .map((post) => ({
+ id: post.id,
+ nodeDomain: post.nodeDomain!,
+ originalPostId: post.originalPostId!,
+ }));
+
const likedIds = await getViewerSwarmLikedPostIds(
- remotePosts.map((post) => ({
- id: `swarm:${domain}:${post.originalPostId}`,
- nodeDomain: domain,
- originalPostId: post.originalPostId,
- })),
+ swarmTargets,
viewer.handle,
nodeDomain
);
+ const repostedIds = await getViewerSwarmRepostedPostIds(
+ swarmTargets,
+ viewer.id
+ );
- return remotePosts.map((post) => ({
- ...post,
- isLiked: likedIds.has(`swarm:${domain}:${post.originalPostId}`),
- }));
+ return applyInteractionFlags(
+ remotePosts as FeedPostWithChildren[],
+ likedIds,
+ repostedIds
+ );
} catch {
return remotePosts;
}
@@ -62,6 +219,31 @@ export async function GET(request: Request, context: RouteContext) {
const cursor = searchParams.get('cursor');
const remote = parseRemoteHandle(handle);
+ const fetchRemotePostsRoute = async () => {
+ if (!remote) {
+ return NextResponse.json({ posts: [], nextCursor: null });
+ }
+
+ const baseUrl = getRemoteBaseUrl(remote.domain);
+ const res = await fetch(
+ `${baseUrl}/api/users/${remote.handle}/posts?limit=${limit}`,
+ {
+ headers: { Accept: 'application/json' },
+ signal: AbortSignal.timeout(10000),
+ }
+ );
+
+ if (!res.ok) {
+ return NextResponse.json({ posts: [], nextCursor: null });
+ }
+
+ const data = await res.json();
+ const mappedPosts = (data.posts || []).map((post: any) => mapRemoteProfilePost(post, remote.domain));
+ return NextResponse.json({
+ posts: await populateViewerLikeState(mappedPosts),
+ nextCursor: null,
+ });
+ };
if (!db) {
if (!remote) {
@@ -104,11 +286,14 @@ export async function GET(request: Request, context: RouteContext) {
linkPreviewTitle: post.linkPreviewTitle || null,
linkPreviewDescription: post.linkPreviewDescription || null,
linkPreviewImage: post.linkPreviewImage || null,
+ linkPreviewType: post.linkPreviewType || null,
+ linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
+ linkPreviewMedia: post.linkPreviewMedia || null,
isSwarm: true,
nodeDomain: remote.domain,
}));
- return NextResponse.json({ posts: await populateViewerLikeState(remotePosts, remote.domain), nextCursor: null });
+ return NextResponse.json({ posts: await populateViewerLikeState(remotePosts), nextCursor: null });
}
return NextResponse.json({ posts: [] });
@@ -118,8 +303,9 @@ export async function GET(request: Request, context: RouteContext) {
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
+ const isRemotePlaceholder = user && cleanHandle.includes('@');
- if (!user) {
+ if (!user || isRemotePlaceholder) {
if (!remote) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
@@ -135,39 +321,7 @@ export async function GET(request: Request, context: RouteContext) {
return NextResponse.json({ posts: [], message: 'Only Synapsis swarm nodes are supported' });
}
- const profileData = await fetchSwarmUserProfile(remote.handle, remote.domain, limit);
- if (profileData?.posts) {
- const profile = profileData.profile;
- const authorHandle = `${profile.handle}@${remote.domain}`;
- const author = {
- id: `swarm:${remote.domain}:${profile.handle}`,
- handle: authorHandle,
- displayName: profile.displayName || profile.handle,
- avatarUrl: profile.avatarUrl,
- };
-
- const remotePosts = profileData.posts.map((post: any) => ({
- id: post.id,
- originalPostId: post.id,
- content: post.content,
- createdAt: post.createdAt,
- likesCount: post.likesCount || 0,
- repostsCount: post.repostsCount || 0,
- repliesCount: post.repliesCount || 0,
- author,
- media: post.media || [],
- linkPreviewUrl: post.linkPreviewUrl || null,
- linkPreviewTitle: post.linkPreviewTitle || null,
- linkPreviewDescription: post.linkPreviewDescription || null,
- linkPreviewImage: post.linkPreviewImage || null,
- isSwarm: true,
- nodeDomain: remote.domain,
- }));
-
- return NextResponse.json({ posts: await populateViewerLikeState(remotePosts, remote.domain), nextCursor: null });
- }
-
- return NextResponse.json({ posts: [] });
+ return await fetchRemotePostsRoute();
}
if (user.isSuspended) {
@@ -175,43 +329,49 @@ export async function GET(request: Request, context: RouteContext) {
}
// Get user's posts with cursor-based pagination
+ const cursorDate = await getMixedProfileCursorDate(cursor);
let whereConditions = and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false),
isNull(posts.replyToId),
isNull(posts.swarmReplyToId)
);
-
- // If cursor provided, get posts older than the cursor
- if (cursor) {
- const cursorPost = await db.query.posts.findFirst({
- where: eq(posts.id, cursor),
- });
- if (cursorPost) {
- whereConditions = and(
- eq(posts.userId, user.id),
- eq(posts.isRemoved, false),
- isNull(posts.replyToId),
- isNull(posts.swarmReplyToId),
- lt(posts.createdAt, cursorPost.createdAt)
- );
- }
+
+ if (cursorDate) {
+ whereConditions = and(
+ eq(posts.userId, user.id),
+ eq(posts.isRemoved, false),
+ isNull(posts.replyToId),
+ isNull(posts.swarmReplyToId),
+ lt(posts.createdAt, cursorDate)
+ );
}
-
- let userPosts: any[] = await db.query.posts.findMany({
+
+ const localPosts = await db.query.posts.findMany({
where: whereConditions,
- with: {
- author: true,
- media: true,
- bot: true,
- replyTo: {
- with: { author: true },
- },
- },
+ with: userPostRelations,
orderBy: [desc(posts.createdAt)],
- limit,
+ limit: cursor ? limit : limit * 2,
});
+ const swarmRepostWhere = cursorDate
+ ? and(
+ eq(userSwarmReposts.userId, user.id),
+ lt(userSwarmReposts.repostedAt, cursorDate)
+ )
+ : eq(userSwarmReposts.userId, user.id);
+ const swarmRepostRows = await db.query.userSwarmReposts.findMany({
+ where: swarmRepostWhere,
+ orderBy: [desc(userSwarmReposts.repostedAt)],
+ limit: cursor ? limit : limit * 2,
+ });
+ let userPosts: any[] = [
+ ...localPosts,
+ ...swarmRepostRows.map((row) => mapUserSwarmRepostToFeedPost(row, user)),
+ ]
+ .sort((a, b) => getPostTimestamp(b) - getPostTimestamp(a))
+ .slice(0, limit);
+
// Populate isLiked and isReposted for authenticated users
try {
const { getSession } = await import('@/lib/auth');
@@ -219,32 +379,71 @@ export async function GET(request: Request, context: RouteContext) {
if (session?.user && userPosts.length > 0) {
const viewer = session.user;
- const postIds = userPosts.map(p => p.id).filter(Boolean);
+ const allProfilePosts = collectNestedPosts(userPosts as FeedPostWithChildren[]);
+ const localPostIds: string[] = [];
+ const swarmTargets: Array<{ id: string; nodeDomain: string; originalPostId: string }> = [];
- if (postIds.length > 0) {
+ for (const post of allProfilePosts) {
+ if (post.id.startsWith('swarm:') && post.nodeDomain && post.originalPostId) {
+ swarmTargets.push({
+ id: post.id,
+ nodeDomain: post.nodeDomain,
+ originalPostId: post.originalPostId,
+ });
+ } else if (!post.id.startsWith('swarm-repost:')) {
+ localPostIds.push(post.id);
+ }
+ }
+
+ const likedPostIds = new Set();
+ const repostedPostIds = new Set();
+
+ if (localPostIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
- inArray(likes.postId, postIds)
+ inArray(likes.postId, localPostIds)
),
});
- const likedPostIds = new Set(viewerLikes.map(l => l.postId));
+ viewerLikes.forEach((like) => likedPostIds.add(like.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
- inArray(posts.repostOfId, postIds),
+ inArray(posts.repostOfId, localPostIds),
eq(posts.isRemoved, false)
),
});
- const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
-
- userPosts = userPosts.map(p => ({
- ...p,
- isLiked: likedPostIds.has(p.id),
- isReposted: repostedPostIds.has(p.id),
- }));
+ viewerReposts.forEach((repost) => {
+ if (repost.repostOfId) {
+ repostedPostIds.add(repost.repostOfId);
+ }
+ });
}
+
+ if (swarmTargets.length > 0) {
+ const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
+ const likedIds = await getViewerSwarmLikedPostIds(
+ swarmTargets.map((post) => ({
+ id: post.id,
+ nodeDomain: post.nodeDomain,
+ originalPostId: post.originalPostId,
+ })),
+ viewer.handle,
+ nodeDomain
+ );
+ likedIds.forEach((id) => likedPostIds.add(id));
+
+ const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts');
+ const repostedIds = await getViewerSwarmRepostedPostIds(swarmTargets, viewer.id);
+ repostedIds.forEach((id) => repostedPostIds.add(id));
+ }
+
+ userPosts = applyInteractionFlags(
+ userPosts as FeedPostWithChildren[],
+ likedPostIds,
+ repostedPostIds
+ ) as any;
}
} catch (error) {
console.error('Error populating interaction flags:', error);
diff --git a/src/app/api/users/[handle]/replies/route.ts b/src/app/api/users/[handle]/replies/route.ts
new file mode 100644
index 0000000..65e8c19
--- /dev/null
+++ b/src/app/api/users/[handle]/replies/route.ts
@@ -0,0 +1,197 @@
+import { NextResponse } from 'next/server';
+import { db, likes, posts, users } from '@/db';
+import { and, desc, eq, inArray, lt, not, or, isNotNull } from 'drizzle-orm';
+import { discoverNode } from '@/lib/swarm/discovery';
+import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts';
+import { isSwarmNode } from '@/lib/swarm/interactions';
+import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
+
+const embeddedPostRelations = {
+ author: true,
+ bot: true,
+ media: true,
+ replyTo: {
+ with: {
+ author: true,
+ bot: true,
+ media: true,
+ },
+ },
+} as const;
+
+const replyRelations = {
+ ...embeddedPostRelations,
+ repostOf: {
+ with: embeddedPostRelations,
+ },
+} as const;
+
+type RouteContext = { params: Promise<{ handle: string }> };
+
+export async function GET(request: Request, context: RouteContext) {
+ try {
+ const { handle } = await context.params;
+ const cleanHandle = handle.toLowerCase().replace(/^@/, '');
+ const { searchParams } = new URL(request.url);
+ const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
+ const cursor = searchParams.get('cursor');
+ const remote = parseRemoteHandle(handle);
+
+ const fetchRemoteReplies = async () => {
+ if (!remote) {
+ return NextResponse.json({ posts: [], nextCursor: null });
+ }
+
+ const baseUrl = getRemoteBaseUrl(remote.domain);
+ const res = await fetch(`${baseUrl}/api/users/${remote.handle}/replies?limit=${limit}`, {
+ headers: { Accept: 'application/json' },
+ signal: AbortSignal.timeout(10000),
+ });
+
+ if (!res.ok) {
+ return NextResponse.json({ posts: [], nextCursor: null });
+ }
+
+ const data = await res.json();
+ const { getSession } = await import('@/lib/auth');
+ const session = await getSession();
+ const viewer = session?.user;
+ const mappedPosts = (data.posts || []).map((post: any) => mapRemoteProfilePost(post, remote.domain));
+ const repostedIds = viewer
+ ? await getViewerSwarmRepostedPostIds(
+ mappedPosts.map((post: any) => ({
+ id: post.id,
+ nodeDomain: remote.domain,
+ originalPostId: post.originalPostId || post.id.split(':').pop(),
+ })),
+ viewer.id
+ )
+ : new Set();
+ return NextResponse.json({
+ posts: mappedPosts.map((post: any) => ({
+ ...post,
+ isReposted: repostedIds.has(post.id),
+ })),
+ nextCursor: null,
+ });
+ };
+
+ if (!db) {
+ if (!remote) {
+ return NextResponse.json({ posts: [], nextCursor: null });
+ }
+
+ let swarm = await isSwarmNode(remote.domain);
+ if (!swarm) {
+ const discovery = await discoverNode(remote.domain);
+ swarm = discovery.success;
+ }
+
+ if (!swarm) {
+ return NextResponse.json({ posts: [], nextCursor: null });
+ }
+
+ return await fetchRemoteReplies();
+ }
+
+ const user = await db.query.users.findFirst({
+ where: eq(users.handle, cleanHandle),
+ });
+ const isRemotePlaceholder = user && cleanHandle.includes('@');
+
+ if (!user || isRemotePlaceholder) {
+ if (!remote) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ let swarm = await isSwarmNode(remote.domain);
+ if (!swarm) {
+ const discovery = await discoverNode(remote.domain);
+ swarm = discovery.success;
+ }
+
+ if (!swarm) {
+ return NextResponse.json({ posts: [], nextCursor: null });
+ }
+
+ return await fetchRemoteReplies();
+ }
+
+ if (user.isSuspended) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ let whereConditions = and(
+ eq(posts.userId, user.id),
+ eq(posts.isRemoved, false),
+ or(isNotNull(posts.replyToId), isNotNull(posts.swarmReplyToId)),
+ );
+
+ if (cursor) {
+ const cursorPost = await db.query.posts.findFirst({
+ where: eq(posts.id, cursor),
+ });
+ if (cursorPost) {
+ whereConditions = and(
+ eq(posts.userId, user.id),
+ eq(posts.isRemoved, false),
+ or(isNotNull(posts.replyToId), isNotNull(posts.swarmReplyToId)),
+ lt(posts.createdAt, cursorPost.createdAt),
+ );
+ }
+ }
+
+ let replyPosts: any[] = await db.query.posts.findMany({
+ where: whereConditions,
+ with: replyRelations,
+ orderBy: [desc(posts.createdAt)],
+ limit,
+ });
+
+ try {
+ const { getSession } = await import('@/lib/auth');
+ const session = await getSession();
+
+ if (session?.user && replyPosts.length > 0) {
+ const viewer = session.user;
+ const postIds = replyPosts.map((post) => post.id).filter(Boolean);
+
+ const viewerLikes = postIds.length > 0
+ ? await db.query.likes.findMany({
+ where: and(
+ eq(likes.userId, viewer.id),
+ inArray(likes.postId, postIds),
+ ),
+ })
+ : [];
+ const likedPostIds = new Set(viewerLikes.map((like) => like.postId));
+
+ const viewerReposts = postIds.length > 0
+ ? await db.query.posts.findMany({
+ where: and(
+ eq(posts.userId, viewer.id),
+ inArray(posts.repostOfId, postIds),
+ eq(posts.isRemoved, false),
+ ),
+ })
+ : [];
+ const repostedPostIds = new Set(viewerReposts.map((post) => post.repostOfId));
+
+ replyPosts = replyPosts.map((post) => ({
+ ...post,
+ isLiked: likedPostIds.has(post.id),
+ isReposted: repostedPostIds.has(post.id),
+ }));
+ }
+ } catch {
+ }
+
+ return NextResponse.json({
+ posts: replyPosts,
+ nextCursor: replyPosts.length === limit ? replyPosts[replyPosts.length - 1]?.id : null,
+ });
+ } catch (error) {
+ console.error('Get user replies error:', error);
+ return NextResponse.json({ error: 'Failed to get replies' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/users/[handle]/route.ts b/src/app/api/users/[handle]/route.ts
index 5221ce0..f62ad55 100644
--- a/src/app/api/users/[handle]/route.ts
+++ b/src/app/api/users/[handle]/route.ts
@@ -51,6 +51,15 @@ export async function GET(request: Request, context: RouteContext) {
const profileData = await fetchSwarmUserProfile(remoteHandle, remoteDomain, 0);
if (profileData?.profile) {
const profile = profileData.profile;
+ const rawBotOwnerHandle = profile.botOwnerHandle?.toLowerCase().replace(/^@/, '') || null;
+ const normalizedBotOwnerHandle = rawBotOwnerHandle
+ ? rawBotOwnerHandle.includes('@')
+ ? rawBotOwnerHandle
+ : `${rawBotOwnerHandle}@${remoteDomain}`
+ : null;
+ const botOwnerLocalHandle = rawBotOwnerHandle
+ ? rawBotOwnerHandle.split('@')[0]
+ : null;
// CACHE: Upsert the remote user into our local database
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
@@ -80,6 +89,12 @@ export async function GET(request: Request, context: RouteContext) {
isSwarm: true,
nodeDomain: remoteDomain,
isBot: profile.isBot || false,
+ botOwner: normalizedBotOwnerHandle && botOwnerLocalHandle ? {
+ id: `swarm:${remoteDomain}:${botOwnerLocalHandle}`,
+ handle: normalizedBotOwnerHandle,
+ displayName: botOwnerLocalHandle,
+ avatarUrl: null,
+ } : null,
did: profile.did,
}
});
diff --git a/src/app/bots/[id]/edit/page.tsx b/src/app/bots/[id]/edit/page.tsx
index 3ddc27b..89e96e1 100644
--- a/src/app/bots/[id]/edit/page.tsx
+++ b/src/app/bots/[id]/edit/page.tsx
@@ -59,6 +59,7 @@ export default function EditBotPage() {
systemPrompt: '',
llmProvider: 'openai',
llmModel: 'gpt-4',
+ llmEndpoint: '',
llmApiKey: '',
autonomousMode: false,
@@ -137,6 +138,7 @@ export default function EditBotPage() {
systemPrompt: personalityConfig.systemPrompt || '',
llmProvider: bot.llmProvider || 'openai',
llmModel: bot.llmModel || 'gpt-4',
+ llmEndpoint: bot.llmEndpoint || '',
llmApiKey: '', // Don't pre-fill API key for security
autonomousMode: bot.autonomousMode || false,
postingFrequency,
@@ -288,6 +290,7 @@ export default function EditBotPage() {
},
llmProvider: formData.llmProvider,
llmModel: formData.llmModel,
+ llmEndpoint: formData.llmProvider === 'custom' ? formData.llmEndpoint : null,
autonomousMode: formData.autonomousMode,
schedule: formData.autonomousMode ? {
type: 'interval',
@@ -482,9 +485,29 @@ export default function EditBotPage() {
OpenAI
Anthropic
OpenRouter
+ Custom (OpenAI Compatible)
+ {formData.llmProvider === 'custom' && (
+
+
+ OpenAI-Compatible Endpoint
+
+
setFormData({ ...formData, llmEndpoint: e.target.value })}
+ className="input"
+ placeholder="https://api.example.com/v1/chat/completions"
+ required
+ />
+
+ Enter the full chat completions endpoint for a public OpenAI-compatible API.
+
+
+ )}
+
Model
@@ -850,22 +873,23 @@ export default function EditBotPage() {
-
-
- Posting Frequency
-
- setFormData({ ...formData, postingFrequency: e.target.value })}
- className="input"
- disabled={!formData.autonomousMode}
- >
- Every 2 Hours
- Every 4 Hours
- Every 6 Hours
- Once Daily
-
-
+ {formData.autonomousMode && (
+
+
+ Posting Frequency
+
+ setFormData({ ...formData, postingFrequency: e.target.value })}
+ className="input"
+ >
+ Every 2 Hours
+ Every 4 Hours
+ Every 6 Hours
+ Once Daily
+
+
+ )}
diff --git a/src/app/bots/new/page.tsx b/src/app/bots/new/page.tsx
index 18a5ba3..d8ef038 100644
--- a/src/app/bots/new/page.tsx
+++ b/src/app/bots/new/page.tsx
@@ -52,6 +52,7 @@ export default function NewBotPage() {
systemPrompt: '',
llmProvider: 'openai',
llmModel: 'gpt-4',
+ llmEndpoint: '',
llmApiKey: '',
autonomousMode: false,
postingFrequency: 'every_4_hours',
@@ -88,6 +89,7 @@ export default function NewBotPage() {
...prev,
llmProvider: lastBot.llmProvider || 'openai',
llmModel: lastBot.llmModel || 'gpt-4',
+ llmEndpoint: lastBot.llmEndpoint || '',
}));
}
}
@@ -237,6 +239,7 @@ export default function NewBotPage() {
},
llmProvider: formData.llmProvider,
llmModel: formData.llmModel,
+ llmEndpoint: formData.llmProvider === 'custom' ? formData.llmEndpoint : undefined,
llmApiKey: formData.llmApiKey,
autonomousMode: formData.autonomousMode,
schedule: formData.autonomousMode ? {
@@ -430,9 +433,28 @@ export default function NewBotPage() {
OpenAI
Anthropic
OpenRouter
+ Custom (OpenAI Compatible)
+ {formData.llmProvider === 'custom' && (
+
+
+ OpenAI-Compatible Endpoint
+
+
setFormData({ ...formData, llmEndpoint: e.target.value })}
+ className="input"
+ placeholder="https://api.example.com/v1/chat/completions"
+ />
+
+ Enter the full chat completions endpoint for a public OpenAI-compatible API.
+
+
+ )}
+
Model
@@ -445,7 +467,7 @@ export default function NewBotPage() {
placeholder="gpt-4"
/>
- e.g., gpt-4, claude-3-opus, etc.
+ e.g., gpt-4, claude-3-opus, mistral-large, etc.
@@ -782,22 +804,23 @@ export default function NewBotPage() {
-
-
- Posting Frequency
-
- setFormData({ ...formData, postingFrequency: e.target.value })}
- className="input"
- disabled={!formData.autonomousMode}
- >
- Every 2 Hours
- Every 4 Hours
- Every 6 Hours
- Once Daily
-
-
+ {formData.autonomousMode && (
+
+
+ Posting Frequency
+
+ setFormData({ ...formData, postingFrequency: e.target.value })}
+ className="input"
+ >
+ Every 2 Hours
+ Every 4 Hours
+ Every 6 Hours
+ Once Daily
+
+
+ )}
@@ -895,7 +918,7 @@ export default function NewBotPage() {
className="btn btn-primary"
disabled={
(step === 'identity' && (!formData.name || !formData.handle)) ||
- (step === 'personality' && (!formData.systemPrompt || !formData.llmApiKey))
+ (step === 'personality' && (!formData.systemPrompt || !formData.llmModel || !formData.llmApiKey || (formData.llmProvider === 'custom' && !formData.llmEndpoint)))
}
>
Next
diff --git a/src/app/globals.css b/src/app/globals.css
index a0bfd64..86741bb 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -246,10 +246,18 @@ a.btn-primary:visited {
flex-shrink: 0;
border-right: 1px solid var(--border);
padding: 16px;
+ display: flex;
+ flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
+ z-index: 20;
+}
+
+.sidebar nav {
+ display: flex;
+ flex-direction: column;
}
.main {
@@ -258,6 +266,8 @@ a.btn-primary:visited {
min-width: 0;
min-height: 100vh;
border-right: 1px solid var(--border);
+ position: relative;
+ z-index: 1;
}
.layout.hide-right-sidebar .main {
@@ -320,6 +330,22 @@ a.btn-primary:visited {
padding-top: 8px;
}
+.post.embedded {
+ margin-top: 0;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--background);
+}
+
+.post.embedded:hover {
+ background: var(--background-secondary);
+}
+
+.post.embedded .post-link-overlay {
+ border-radius: inherit;
+}
+
/* Post */
.post {
padding: 16px;
@@ -359,6 +385,34 @@ a.btn-primary:visited {
word-wrap: break-word;
}
+.repost-event-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+ color: var(--accent);
+ font-size: 13px;
+}
+
+.repost-event-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.repost-event-text a {
+ color: inherit;
+ font-weight: 600;
+}
+
+.repost-event-copy {
+ color: var(--foreground-secondary);
+}
+
+.repost-embed {
+ margin-top: 4px;
+}
+
.post-reasons {
display: flex;
flex-wrap: wrap;
@@ -2402,6 +2456,54 @@ a.btn-primary:visited {
border-bottom: 1px solid var(--border);
}
+.link-preview-video video {
+ width: 100%;
+ max-height: 480px;
+ display: block;
+ background: #000;
+ border-bottom: 1px solid var(--border);
+}
+
+.link-preview-gallery {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 2px;
+ background: var(--border);
+}
+
+.link-preview-gallery.compact {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.link-preview-gallery-item {
+ position: relative;
+ background: var(--background-tertiary);
+ min-height: 140px;
+}
+
+.link-preview-gallery.compact .link-preview-gallery-item {
+ min-height: 80px;
+}
+
+.link-preview-gallery-item img {
+ width: 100%;
+ height: 100%;
+ display: block;
+ object-fit: cover;
+}
+
+.link-preview-gallery-more {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.45);
+ color: #fff;
+ font-size: 20px;
+ font-weight: 700;
+}
+
.link-preview-info {
padding: 12px;
}
@@ -2455,22 +2557,43 @@ a.btn-primary:visited {
.link-preview-card.mini {
display: flex;
- max-height: 80px;
+ max-height: none;
}
.link-preview-card.mini .link-preview-image {
width: 80px;
min-width: 80px;
height: 80px;
+ overflow: hidden;
}
-.link-preview-card.mini .link-preview-image img {
+.link-preview-card.mini .link-preview-image img,
+.link-preview-card.mini .link-preview-image video {
height: 80px;
+ width: 80px;
object-fit: cover;
border-bottom: none;
border-right: 1px solid var(--border);
}
+.link-preview-card.mini .link-preview-video {
+ width: 120px;
+ min-width: 120px;
+}
+
+.link-preview-card.mini .link-preview-video video {
+ height: 100%;
+ min-height: 80px;
+ border-bottom: none;
+ border-right: 1px solid var(--border);
+}
+
+.link-preview-card.mini .link-preview-gallery {
+ width: 120px;
+ min-width: 120px;
+ border-right: 1px solid var(--border);
+}
+
.link-preview-card.mini .link-preview-info {
padding: 8px 12px;
display: flex;
@@ -2589,4 +2712,4 @@ a.btn-primary:visited {
.layout.hide-right-sidebar .main {
max-width: none;
border-right: none;
-}
\ No newline at end of file
+}
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index 5423b0e..e81774d 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
-import { TriangleAlert } from 'lucide-react';
+import { TriangleAlert, X } from 'lucide-react';
import { decryptPrivateKey } from '@/lib/crypto/private-key-client';
import { keyStore, importPrivateKey } from '@/lib/crypto/user-signing';
import { useAuth } from '@/lib/contexts/AuthContext';
@@ -24,7 +24,13 @@ declare global {
}
}
-export default function LoginPage() {
+interface AuthScreenProps {
+ modal?: boolean;
+ onClose?: () => void;
+ onSuccess?: () => void;
+}
+
+export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProps) {
const router = useRouter();
const [mode, setMode] = useState<'login' | 'register' | 'import'>('login');
const [email, setEmail] = useState('');
@@ -204,7 +210,12 @@ export default function LoginPage() {
setImportSuccess(data.message);
// Soft navigation to preserve AuthContext/KeyStore state
setTimeout(() => {
- router.push('/');
+ router.refresh();
+ if (onSuccess) {
+ onSuccess();
+ } else {
+ router.push('/');
+ }
}, 2000);
} catch (err) {
@@ -319,7 +330,12 @@ export default function LoginPage() {
}
// Soft navigation to preserve AuthContext/KeyStore state
- router.push('/');
+ router.refresh();
+ if (onSuccess) {
+ onSuccess();
+ } else {
+ router.push('/');
+ }
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
// Reset Turnstile on error
@@ -332,15 +348,17 @@ export default function LoginPage() {
}
};
- return (
+ const content = (
-
+
{/* Logo */}
{nodeInfoLoaded && (
@@ -984,10 +1002,86 @@ export default function LoginPage() {
)}
-
- โ Back to home
-
+ {!modal && (
+
+ โ Back to home
+
+ )}
);
+
+ if (modal) {
+ return (
+
+
event.stopPropagation()}
+ >
+ {onClose && (
+
+
+
+ )}
+
+ {content}
+
+
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+}
+
+export default function LoginPage() {
+ return
;
}
diff --git a/src/app/moderation/page.tsx b/src/app/moderation/page.tsx
index 6bb9f99..6ad6ee4 100644
--- a/src/app/moderation/page.tsx
+++ b/src/app/moderation/page.tsx
@@ -45,6 +45,20 @@ type Report = {
target?: AdminPost | AdminUser | null;
};
+type AdminNode = {
+ id: string;
+ domain: string;
+ name?: string | null;
+ description?: string | null;
+ isActive: boolean;
+ isBlocked: boolean;
+ blockReason?: string | null;
+ blockedAt?: string | null;
+ lastSeenAt?: string | null;
+ trustScore?: number | null;
+ isNsfw?: boolean;
+};
+
const formatDate = (value: string) => {
const date = new Date(value);
return date.toLocaleString();
@@ -52,12 +66,15 @@ const formatDate = (value: string) => {
export default function ModerationPage() {
const [isAdmin, setIsAdmin] = useState
(null);
- const [tab, setTab] = useState<'reports' | 'posts' | 'users'>('reports');
+ const [tab, setTab] = useState<'reports' | 'posts' | 'users' | 'nodes'>('reports');
const [reports, setReports] = useState([]);
const [posts, setPosts] = useState([]);
const [users, setUsers] = useState([]);
+ const [nodes, setNodes] = useState([]);
const [loading, setLoading] = useState(false);
const [reportStatus, setReportStatus] = useState<'open' | 'resolved' | 'all'>('open');
+ const [nodeDomain, setNodeDomain] = useState('');
+ const [nodeReason, setNodeReason] = useState('');
useEffect(() => {
fetch('/api/admin/me')
@@ -105,11 +122,25 @@ export default function ModerationPage() {
}
};
+ const loadNodes = async () => {
+ setLoading(true);
+ try {
+ const res = await fetch('/api/admin/nodes');
+ const data = await res.json();
+ setNodes(data.nodes || []);
+ } catch {
+ setNodes([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
useEffect(() => {
if (!isAdmin) return;
if (tab === 'reports') loadReports();
if (tab === 'posts') loadPosts();
if (tab === 'users') loadUsers();
+ if (tab === 'nodes') loadNodes();
}, [tab, isAdmin, reportStatus]);
const handleReportResolve = async (id: string, status: 'open' | 'resolved') => {
@@ -147,6 +178,17 @@ export default function ModerationPage() {
loadUsers();
};
+ const handleNodeAction = async (action: 'block' | 'unblock', domain: string, reason?: string) => {
+ await fetch('/api/admin/nodes', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action, domain, reason }),
+ });
+ setNodeDomain('');
+ setNodeReason('');
+ loadNodes();
+ };
+
const reportCounts = useMemo(() => {
return {
open: reports.filter((r) => r.status === 'open').length,
@@ -209,6 +251,12 @@ export default function ModerationPage() {
>
Users
+ setTab('nodes')}
+ >
+ Nodes
+
{tab === 'reports' && (
@@ -470,6 +518,114 @@ export default function ModerationPage() {
)}
)}
+ {tab === 'nodes' && (
+
+
+
Block node
+
+ Blocked nodes cannot deliver swarm interactions, appear in swarm feeds, or exchange chat with this node.
+
+
+
+
+ {loading ? (
+
Loading nodes...
+ ) : nodes.length === 0 ? (
+
No known nodes.
+ ) : (
+
+ {nodes.map((node) => (
+
+
+
+
+
+ {node.isBlocked ? 'blocked' : 'allowed'}
+
+ {!node.isBlocked && !node.isActive && (
+
+ inactive
+
+ )}
+ {node.isNsfw && (
+
+ NSFW
+
+ )}
+
+
+ {node.name || node.domain}
+
+
+ {node.domain}
+
+ {node.description && (
+
+ {node.description}
+
+ )}
+
+ {node.blockedAt ? `Blocked ${formatDate(node.blockedAt)}` : node.lastSeenAt ? `Last seen ${formatDate(node.lastSeenAt)}` : 'Never seen'}
+ {typeof node.trustScore === 'number' && โข Trust {node.trustScore} }
+
+ {node.blockReason && (
+
+ Reason: {node.blockReason}
+
+ )}
+
+
+ {node.isBlocked ? (
+ handleNodeAction('unblock', node.domain)}>
+ Unblock
+
+ ) : (
+ handleNodeAction('block', node.domain, window.prompt('Reason (optional):') || '')}
+ >
+ Block
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ )}
>
);
}
diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx
index 0b401f3..7ed071d 100644
--- a/src/app/notifications/page.tsx
+++ b/src/app/notifications/page.tsx
@@ -13,6 +13,14 @@ interface NotificationActor {
avatarUrl: string | null;
}
+interface NotificationTarget {
+ handle: string;
+ displayName: string | null;
+ avatarUrl: string | null;
+ nodeDomain?: string | null;
+ isBot?: boolean | null;
+}
+
interface NotificationPost {
id: string;
content: string;
@@ -24,6 +32,7 @@ interface Notification {
createdAt: string;
readAt: string | null;
actor: NotificationActor | null;
+ target: NotificationTarget | null;
post: NotificationPost | null;
}
@@ -77,17 +86,28 @@ export default function NotificationsPage() {
};
const getNotificationText = (notification: Notification) => {
+ const targetName = notification.target?.displayName || notification.target?.handle;
switch (notification.type) {
case 'follow':
- return 'followed you';
+ return notification.target?.isBot && targetName
+ ? `followed your bot ${targetName}`
+ : 'followed you';
case 'like':
- return 'liked your post';
+ return notification.target?.isBot && targetName
+ ? `liked a post from ${targetName}`
+ : 'liked your post';
case 'repost':
- return 'reposted your post';
+ return notification.target?.isBot && targetName
+ ? `reposted a post from ${targetName}`
+ : 'reposted your post';
case 'mention':
- return 'mentioned you';
+ return notification.target?.isBot && targetName
+ ? `mentioned your bot ${targetName}`
+ : 'mentioned you';
case 'reply':
- return 'replied to your post';
+ return notification.target?.isBot && targetName
+ ? `replied to a post from ${targetName}`
+ : 'replied to your post';
default:
return 'interacted with you';
}
diff --git a/src/app/u/[handle]/page.tsx b/src/app/u/[handle]/page.tsx
index e6b805c..0f693f9 100644
--- a/src/app/u/[handle]/page.tsx
+++ b/src/app/u/[handle]/page.tsx
@@ -175,7 +175,7 @@ export default function ProfilePage() {
if (!repliesCursor || repliesLoadingMore || !user) return;
setRepliesLoadingMore(true);
try {
- const res = await fetch(`/api/posts?type=replies&userId=${user.id}&cursor=${repliesCursor}`);
+ const res = await fetch(`/api/users/${handle}/replies?cursor=${repliesCursor}`);
const data = await res.json();
setRepliesPosts(prev => [...prev, ...(data.posts || [])]);
setRepliesCursor(data.nextCursor || null);
@@ -266,7 +266,10 @@ export default function ProfilePage() {
}, [user, currentUser]);
useEffect(() => {
- if (!currentUser || !user || currentUser.handle === user.handle) {
+ const ownerHandle = user?.botOwner?.handle?.replace(/^@/, '').split('@')[0];
+ const isOwnedBot = Boolean(user?.isBot && currentUser && ownerHandle === currentUser.handle);
+
+ if (!currentUser || !user || currentUser.handle === user.handle || isOwnedBot) {
setIsFollowing(false);
setIsBlocked(false);
return;
@@ -314,7 +317,7 @@ export default function ProfilePage() {
if (activeTab === 'replies' && user) {
setRepliesLoading(true);
setRepliesCursor(null);
- fetch(`/api/posts?type=replies&userId=${user.id}`)
+ fetch(`/api/users/${handle}/replies`)
.then(res => res.json())
.then(data => {
setRepliesPosts(data.posts || []);
@@ -323,7 +326,13 @@ export default function ProfilePage() {
.catch(() => setRepliesPosts([]))
.finally(() => setRepliesLoading(false));
}
- }, [activeTab, handle, user]);
+ }, [activeTab, handle, user]);
+
+ useEffect(() => {
+ if (user?.isBot && activeTab === 'following') {
+ setActiveTab('posts');
+ }
+ }, [user?.isBot, activeTab]);
const handleFollow = async () => {
if (!currentUser) return;
@@ -447,6 +456,19 @@ export default function ProfilePage() {
}
const isOwnProfile = currentUser?.handle === user.handle;
+ const botOwner = user.botOwner as BotOwner | null | undefined;
+ const botOwnerLocalHandle = botOwner?.handle?.replace(/^@/, '').split('@')[0] || null;
+ const isOwnedBotProfile = Boolean(
+ user.isBot &&
+ currentUser &&
+ botOwner &&
+ (botOwner.id === currentUser.id || botOwnerLocalHandle === currentUser.handle)
+ );
+ const showFollowingUi = !user.isBot;
+ const visibleTabs = (user.isBot
+ ? ['posts', 'replies', 'followers'] as const
+ : ['posts', 'replies', 'likes', 'followers', 'following'] as const
+ );
return (
@@ -518,7 +540,8 @@ export default function ProfilePage() {
{/* Banner */}
- {!isOwnProfile && currentUser && (
+ {!isOwnProfile && !isOwnedBotProfile && currentUser && (
<>
{!isBlocked && (
{user.followersCount}{' '}
Followers
- setActiveTab('following')}
- style={{
- background: 'none',
- border: 'none',
- color: 'var(--foreground)',
- cursor: 'pointer',
- }}
- >
- {user.followingCount} {' '}
- Following
-
+ {showFollowingUi && (
+ setActiveTab('following')}
+ style={{
+ background: 'none',
+ border: 'none',
+ color: 'var(--foreground)',
+ cursor: 'pointer',
+ }}
+ >
+ {user.followingCount} {' '}
+ Following
+
+ )}
@@ -807,10 +832,7 @@ export default function ProfilePage() {
{/* Tabs */}
- {(user?.isBot
- ? ['posts', 'replies', 'followers', 'following'] as const
- : ['posts', 'replies', 'likes', 'followers', 'following'] as const
- ).map(tab => (
+ {visibleTabs.map(tab => (
setActiveTab(tab)}
diff --git a/src/components/Compose.tsx b/src/components/Compose.tsx
index 615ffc3..1e19c93 100644
--- a/src/components/Compose.tsx
+++ b/src/components/Compose.tsx
@@ -36,6 +36,13 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
const [isNsfwNode, setIsNsfwNode] = useState(false);
const maxLength = 600;
const remaining = maxLength - content.length;
+ const previewMedia = linkPreview?.media?.length
+ ? linkPreview.media
+ : linkPreview?.image
+ ? [{ url: linkPreview.image }]
+ : [];
+ const previewImage = previewMedia[0]?.url || linkPreview?.image || null;
+ const isEmbeddedVideo = Boolean(linkPreview?.url?.match(/(youtube\.com|youtu\.be|vimeo\.com)/));
// Check if user can post NSFW content and if node is NSFW
useEffect(() => {
@@ -210,11 +217,32 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
x
- {!linkPreview.url.match(/(youtube\.com|youtu\.be|vimeo\.com)/) && (
+ {!isEmbeddedVideo && (
- {linkPreview.image && (
+ {linkPreview.type === 'video' && linkPreview.videoUrl ? (
-
+
+
+ ) : linkPreview.type === 'gallery' && previewMedia.length > 0 ? (
+
+ {previewMedia.slice(0, 3).map((item: { url: string }, index: number) => (
+
+
+ {index === Math.min(previewMedia.length, 3) - 1 && previewMedia.length > 3 && (
+
+{previewMedia.length - 3}
+ )}
+
+ ))}
+
+ ) : previewImage && (
+
+
)}
diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx
index d898a1b..d57ea23 100644
--- a/src/components/PostCard.tsx
+++ b/src/components/PostCard.tsx
@@ -5,7 +5,7 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/components/Icons';
import { Bot, MoreHorizontal, UserX, VolumeX, Globe } from 'lucide-react';
-import { Post } from '@/lib/types';
+import { Post, LinkPreviewMediaItem } from '@/lib/types';
import { useAuth } from '@/lib/contexts/AuthContext';
import { useToast } from '@/lib/contexts/ToastContext';
import { VideoEmbed } from '@/components/VideoEmbed';
@@ -13,6 +13,7 @@ import BlurredVideo from '@/components/BlurredVideo';
import { useFormattedHandle } from '@/lib/utils/handle';
import { useDomain } from '@/lib/contexts/ConfigContext';
import { signedAPI } from '@/lib/api/signed-fetch';
+import type { LinkPreviewData } from '@/lib/media/linkPreview';
// Component for link preview image that hides on error
function LinkPreviewImage({ src, alt }: { src: string; alt: string }) {
@@ -31,6 +32,65 @@ function LinkPreviewImage({ src, alt }: { src: string; alt: string }) {
);
}
+const EMBED_VIDEO_REGEX = /(youtube\.com|youtu\.be|vimeo\.com)/;
+
+function isPlaceholderPreview(post: Post): boolean {
+ if (!post.linkPreviewUrl) {
+ return false;
+ }
+
+ try {
+ const hostname = new URL(
+ post.linkPreviewUrl.startsWith('http') ? post.linkPreviewUrl : `https://${post.linkPreviewUrl}`
+ ).hostname.replace(/^www\./, '').toLowerCase();
+ const title = post.linkPreviewTitle?.trim().toLowerCase() || '';
+ const hasRichData = Boolean(
+ post.linkPreviewDescription ||
+ post.linkPreviewImage ||
+ post.linkPreviewVideoUrl ||
+ (post.linkPreviewMedia && post.linkPreviewMedia.length > 0)
+ );
+
+ if (hasRichData) {
+ return false;
+ }
+
+ return (
+ !title ||
+ title === 'reddit' ||
+ title === hostname ||
+ title === `www.${hostname}`
+ );
+ } catch {
+ return false;
+ }
+}
+
+function LinkPreviewGallery({
+ media,
+ alt,
+ compact = false,
+}: {
+ media: LinkPreviewMediaItem[];
+ alt: string;
+ compact?: boolean;
+}) {
+ const visibleMedia = media.slice(0, compact ? 3 : 4);
+
+ return (
+
+ {visibleMedia.map((item, index) => (
+
+
+ {index === visibleMedia.length - 1 && media.length > visibleMedia.length && (
+
+{media.length - visibleMedia.length}
+ )}
+
+ ))}
+
+ );
+}
+
interface PostCardProps {
post: Post;
onLike?: (id: string, currentLiked: boolean) => void;
@@ -41,10 +101,11 @@ interface PostCardProps {
isDetail?: boolean;
showThread?: boolean; // Show parent post inline as a thread
isThreadParent?: boolean; // This post is being shown as a parent in a thread
+ isEmbedded?: boolean;
parentPostAuthorId?: string; // ID of the parent post's author (for allowing deletion of replies)
}
-export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail, showThread = true, isThreadParent, parentPostAuthorId }: PostCardProps) {
+export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail, showThread = true, isThreadParent, isEmbedded = false, parentPostAuthorId }: PostCardProps) {
const { user: currentUser, did, handle: currentUserHandle, isIdentityUnlocked } = useAuth();
const { showToast } = useToast();
const router = useRouter();
@@ -57,8 +118,25 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
const [reporting, setReporting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [showMenu, setShowMenu] = useState(false);
+ const [hydratedPreview, setHydratedPreview] = useState
(null);
const domain = useDomain();
const authorHandle = useFormattedHandle(post.author.handle, post.nodeDomain);
+ const isOwnOrOwnedBotPost = Boolean(
+ currentUser && (
+ currentUser.id === post.author.id ||
+ (post.bot && currentUser.id === post.bot.ownerId) ||
+ (post.author.id.startsWith('swarm:') && (
+ post.author.handle === currentUser.handle ||
+ post.author.handle === `${currentUser.handle}@${domain}`
+ ))
+ )
+ );
+ const canDeletePost = Boolean(
+ currentUser && (
+ isOwnOrOwnedBotPost ||
+ (parentPostAuthorId && currentUser.id === parentPostAuthorId)
+ )
+ );
// Sync state if post changes (e.g. after a re-render from parent)
useEffect(() => {
setLiked(post.isLiked || false);
@@ -67,6 +145,47 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
setRepostsCount(post.repostsCount || 0);
}, [post.isLiked, post.likesCount, post.isReposted, post.repostsCount, post.id]);
+ useEffect(() => {
+ let cancelled = false;
+ const missingPreviewData = Boolean(
+ post.linkPreviewUrl &&
+ !post.linkPreviewTitle &&
+ !post.linkPreviewDescription &&
+ !post.linkPreviewImage &&
+ !post.linkPreviewVideoUrl &&
+ (!post.linkPreviewMedia || post.linkPreviewMedia.length === 0)
+ );
+ const placeholderPreviewData = isPlaceholderPreview(post);
+
+ if ((!missingPreviewData && !placeholderPreviewData) || !post.linkPreviewUrl) {
+ setHydratedPreview(null);
+ return;
+ }
+
+ (async () => {
+ try {
+ const res = await fetch(`/api/media/preview?url=${encodeURIComponent(post.linkPreviewUrl!)}`);
+ if (!res.ok) return;
+ const data = await res.json();
+ if (!cancelled) {
+ setHydratedPreview(data);
+ }
+ } catch {
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ post.linkPreviewUrl,
+ post.linkPreviewTitle,
+ post.linkPreviewDescription,
+ post.linkPreviewImage,
+ post.linkPreviewVideoUrl,
+ post.linkPreviewMedia,
+ ]);
+
const formatTime = (dateStr: string | Date) => {
const date = new Date(dateStr);
@@ -431,11 +550,91 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
: post.swarmReplyToAuthor)?.nodeDomain,
} as Post : null);
const replyToHandle = effectiveReplyTo?.author?.handle ? useFormattedHandle(effectiveReplyTo.author.handle, effectiveReplyTo.nodeDomain) : '';
+ const repostHandle = useFormattedHandle(post.author.handle, post.nodeDomain);
+ const hasOwnContent = decodeHtmlEntities(post.content).trim().length > 0;
+ const isRepostEvent = Boolean(post.repostOf);
+ const effectivePreview = {
+ url: hydratedPreview?.url || post.linkPreviewUrl || null,
+ title: hydratedPreview?.title || post.linkPreviewTitle || null,
+ description: hydratedPreview?.description || post.linkPreviewDescription || null,
+ image: hydratedPreview?.image || post.linkPreviewImage || null,
+ type: hydratedPreview?.type || post.linkPreviewType || null,
+ videoUrl: hydratedPreview?.videoUrl || post.linkPreviewVideoUrl || null,
+ media: hydratedPreview?.media || post.linkPreviewMedia || null,
+ };
+ const rawPreviewMedia = (() => {
+ const mediaJson = (post as Post & { linkPreviewMediaJson?: string | null }).linkPreviewMediaJson;
+ if (!mediaJson) {
+ return [];
+ }
+ try {
+ const parsed = JSON.parse(mediaJson);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+ })();
+ const previewMedia = (effectivePreview.media && effectivePreview.media.length > 0)
+ ? effectivePreview.media
+ : rawPreviewMedia.length > 0
+ ? rawPreviewMedia
+ : effectivePreview.image
+ ? [{ url: effectivePreview.image }]
+ : [];
+ const previewImage = previewMedia[0]?.url || effectivePreview.image || null;
+ const isEmbeddedVideo = Boolean(effectivePreview.url && effectivePreview.url.match(EMBED_VIDEO_REGEX));
+ const isRichVideoPreview = effectivePreview.type === 'video' && Boolean(effectivePreview.videoUrl);
+ const isGalleryPreview = effectivePreview.type === 'gallery' && previewMedia.length > 1;
+
+ const renderLinkPreviewCard = (compact = false) => {
+ if (!effectivePreview.url || isEmbeddedVideo) {
+ return null;
+ }
+
+ return (
+ e.stopPropagation()}
+ >
+ {isRichVideoPreview && effectivePreview.videoUrl ? (
+
+
+
+ ) : isGalleryPreview ? (
+
+ ) : previewImage ? (
+
+ ) : null}
+
+
{effectivePreview.title ? decodeHtmlEntities(effectivePreview.title) : ''}
+ {effectivePreview.description && (
+
{decodeHtmlEntities(effectivePreview.description)}
+ )}
+
+ {new URL(effectivePreview.url.startsWith('http') ? effectivePreview.url : `https://${effectivePreview.url}`).hostname}
+
+
+
+ );
+ };
// If this is a thread parent being rendered, just render the article
if (isThreadParent) {
return (
-
+
e.stopPropagation()}>
@@ -458,6 +657,46 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
);
}
+ if (isRepostEvent && post.repostOf) {
+ return (
+ <>
+
+
+
+
+
+
+ e.stopPropagation()}>
+ {post.author.displayName || post.author.handle}
+
+ reposted
+ {repostHandle} ยท {formatTime(post.createdAt)}
+
+
+
+ {hasOwnContent && (
+ {renderContent(post.content, post.linkPreviewUrl ?? undefined)}
+ )}
+
+
+
+ >
+ );
+ }
+
return (
<>
{/* Show parent post as part of thread - only on detail page */}
@@ -475,7 +714,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
/>
)}
-
+
{!isDetail && }
@@ -515,7 +754,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
{authorHandle} ยท {formatTime(post.createdAt)}
- {currentUser && currentUser.id !== post.author.id && (
+ {currentUser && !isOwnOrOwnedBotPost && (
)}
- {post.linkPreviewUrl && !post.linkPreviewUrl.match(/(youtube\.com|youtu\.be|vimeo\.com)/) && (
-
e.stopPropagation()}
- >
- {post.linkPreviewImage && (
-
- )}
-
-
{post.linkPreviewTitle ? decodeHtmlEntities(post.linkPreviewTitle) : ''}
- {post.linkPreviewDescription && (
-
{decodeHtmlEntities(post.linkPreviewDescription)}
- )}
-
- {new URL(post.linkPreviewUrl.startsWith('http') ? post.linkPreviewUrl : `https://${post.linkPreviewUrl}`).hostname}
-
-
-
- )}
+ {renderLinkPreviewCard()}
@@ -707,20 +925,13 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
{likesCount || ''}
-
-
- {reporting ? '...' : ''}
-
- {(currentUser && (
- currentUser.id === post.author.id ||
- (post.bot && currentUser.id === post.bot.ownerId) ||
- (parentPostAuthorId && currentUser.id === parentPostAuthorId) ||
- // Allow deleting own remote posts where handle might be username@node_domain
- (post.author.id.startsWith('swarm:') && (
- post.author.handle === currentUser.handle ||
- post.author.handle === `${currentUser.handle}@${domain}`
- ))
- )) && (
+ {!isOwnOrOwnedBotPost && (
+
+
+ {reporting ? '...' : ''}
+
+ )}
+ {canDeletePost && (
{deleting ? '...' : ''}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 8cadb6c..a4faae1 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -1,24 +1,38 @@
'use client';
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { createPortal } from 'react-dom';
import Link from 'next/link';
import Image from 'next/image';
import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/lib/contexts/AuthContext';
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons';
import { useFormattedHandle } from '@/lib/utils/handle';
-import { LogOut, Settings2 } from 'lucide-react';
+import { Check, ChevronDown, LogOut, Plus, Settings2 } from 'lucide-react';
+import { AuthScreen } from '@/app/login/page';
// import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper
+function shortHandle(handle: string) {
+ const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle;
+ return `@${cleanHandle.split('@')[0]}`;
+}
+
export function Sidebar() {
- const { user, isAdmin, logout } = useAuth();
+ const { user, accounts, isAdmin, logout, switchAccount } = useAuth();
const pathname = usePathname();
const router = useRouter();
const [customLogoUrl, setCustomLogoUrl] = useState(undefined);
const [unreadCount, setUnreadCount] = useState(0);
const [unreadChatCount, setUnreadChatCount] = useState(0);
const [loggingOut, setLoggingOut] = useState(false);
- const formattedHandle = user ? useFormattedHandle(user.handle) : '';
+ const [switchingAccountId, setSwitchingAccountId] = useState(null);
+ const [accountMenuOpen, setAccountMenuOpen] = useState(false);
+ const [showAuthModal, setShowAuthModal] = useState(false);
+ const accountMenuRef = useRef(null);
+ const accountTriggerRef = useRef(null);
+ const accountPopupRef = useRef(null);
+ const [accountPopupStyle, setAccountPopupStyle] = useState(null);
+ const formattedHandle = user ? shortHandle(user.handle) : '';
useEffect(() => {
fetch('/api/node')
@@ -91,18 +105,101 @@ export function Sidebar() {
const isHome = pathname === '/';
const handleLogout = async () => {
- if (loggingOut) return;
+ if (loggingOut || !user) return;
setLoggingOut(true);
try {
- await logout();
- window.location.href = '/explore';
+ const isLastAccount = accounts.length <= 1;
+ await logout(user.id);
+ setAccountMenuOpen(false);
+
+ if (isLastAccount) {
+ window.location.href = '/explore';
+ } else {
+ router.refresh();
+ }
} catch (error) {
console.error('Logout failed:', error);
setLoggingOut(false);
}
};
+ const handleSwitchAccount = async (userId: string) => {
+ if (switchingAccountId || userId === user?.id) {
+ setAccountMenuOpen(false);
+ return;
+ }
+
+ setSwitchingAccountId(userId);
+ try {
+ await switchAccount(userId);
+ setAccountMenuOpen(false);
+ router.refresh();
+ } catch (error) {
+ console.error('Account switch failed:', error);
+ } finally {
+ setSwitchingAccountId(null);
+ }
+ };
+
+ const updateAccountPopupPosition = useCallback(() => {
+ if (!accountTriggerRef.current) return;
+
+ const rect = accountTriggerRef.current.getBoundingClientRect();
+ const popupWidth = 320;
+ const viewportPadding = 16;
+ const left = Math.min(
+ rect.left,
+ window.innerWidth - popupWidth - viewportPadding
+ );
+ const bottom = window.innerHeight - rect.top + 12;
+
+ setAccountPopupStyle({
+ position: 'fixed',
+ left: `${Math.max(viewportPadding, left)}px`,
+ bottom: `${bottom}px`,
+ width: `${popupWidth}px`,
+ maxWidth: `min(${popupWidth}px, calc(100vw - 32px))`,
+ background: 'rgba(0, 0, 0, 0.96)',
+ border: '1px solid var(--border)',
+ borderRadius: '18px',
+ boxShadow: '0 20px 60px rgba(0, 0, 0, 0.58)',
+ overflow: 'hidden',
+ zIndex: 10000,
+ backdropFilter: 'blur(18px)',
+ });
+ }, []);
+
+ useEffect(() => {
+ if (!accountMenuOpen) return;
+
+ updateAccountPopupPosition();
+
+ const handlePointerDown = (event: MouseEvent) => {
+ const target = event.target as Node;
+ const clickedTrigger = accountMenuRef.current?.contains(target);
+ const clickedPopup = accountPopupRef.current?.contains(target);
+
+ if (!clickedTrigger && !clickedPopup) {
+ setAccountMenuOpen(false);
+ }
+ };
+
+ const handleWindowChange = () => {
+ updateAccountPopupPosition();
+ };
+
+ window.addEventListener('mousedown', handlePointerDown);
+ window.addEventListener('resize', handleWindowChange);
+ window.addEventListener('scroll', handleWindowChange, true);
+
+ return () => {
+ window.removeEventListener('mousedown', handlePointerDown);
+ window.removeEventListener('resize', handleWindowChange);
+ window.removeEventListener('scroll', handleWindowChange, true);
+ };
+ }, [accountMenuOpen, updateAccountPopupPosition]);
+
return (
@@ -196,8 +293,130 @@ export function Sidebar() {
)}
{user && (
-
-
+
+ {accountMenuOpen && accountPopupStyle && typeof document !== 'undefined' && createPortal(
+
+
+
+ {accounts.map((account) => {
+ const isActive = account.id === user.id;
+ const isSwitching = switchingAccountId === account.id;
+
+ return (
+
void handleSwitchAccount(account.id)}
+ disabled={isSwitching || loggingOut}
+ style={{
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '14px',
+ padding: '14px 18px',
+ background: 'transparent',
+ color: 'var(--foreground)',
+ border: 'none',
+ cursor: 'pointer',
+ textAlign: 'left',
+ }}
+ >
+
+ {account.avatarUrl ? (
+
+ ) : (
+ (account.displayName?.charAt(0) || account.handle.charAt(0)).toUpperCase()
+ )}
+
+
+
+ {account.displayName || account.handle}
+
+
+ {shortHandle(account.handle)}
+
+
+
+ {isActive ? : isSwitching ? ... : null}
+
+
+ );
+ })}
+
+
+
+
{
+ setAccountMenuOpen(false);
+ setShowAuthModal(true);
+ }}
+ style={{
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '14px',
+ padding: '14px 18px',
+ background: 'transparent',
+ color: 'var(--foreground)',
+ border: 'none',
+ cursor: 'pointer',
+ textAlign: 'left',
+ fontWeight: 600,
+ whiteSpace: 'nowrap',
+ }}
+ >
+
+ Add an existing account
+
+
+
+
+ {loggingOut ? 'Signing out...' : `Sign out ${formattedHandle}`}
+
+
+
+
,
+ document.body
+ )}
+
+
setAccountMenuOpen(open => !open)}
+ className="btn btn-ghost"
+ style={{
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '12px',
+ minWidth: 0,
+ padding: '10px 12px',
+ borderRadius: '16px',
+ border: '1px solid var(--border)',
+ background: accountMenuOpen ? 'var(--background-secondary)' : 'transparent',
+ }}
+ >
{user.avatarUrl ? (
@@ -205,31 +424,30 @@ export function Sidebar() {
(user.displayName?.charAt(0) || user.handle.charAt(0)).toUpperCase()
)}
-
+
{user.displayName}
{formattedHandle}
-
-
-
-
- {loggingOut ? 'Signing out...' : 'Sign out'}
+
)}
{/* Identity Unlock Prompt Modal is now handled in LayoutWrapper */}
+ {showAuthModal && (
+
setShowAuthModal(false)}
+ onSuccess={() => {
+ setShowAuthModal(false);
+ router.refresh();
+ }}
+ />
+ )}
);
}
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 004f5dc..01dd357 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -143,6 +143,9 @@ export const posts = pgTable('posts', {
linkPreviewTitle: text('link_preview_title'),
linkPreviewDescription: text('link_preview_description'),
linkPreviewImage: text('link_preview_image'),
+ linkPreviewType: text('link_preview_type'),
+ linkPreviewVideoUrl: text('link_preview_video_url'),
+ linkPreviewMediaJson: text('link_preview_media_json'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => [
@@ -301,6 +304,9 @@ export const remotePosts = pgTable('remote_posts', {
linkPreviewTitle: text('link_preview_title'),
linkPreviewDescription: text('link_preview_description'),
linkPreviewImage: text('link_preview_image'),
+ linkPreviewType: text('link_preview_type'),
+ linkPreviewVideoUrl: text('link_preview_video_url'),
+ linkPreviewMediaJson: text('link_preview_media_json'),
// Media attachments stored as JSON
mediaJson: text('media_json'), // JSON array of {url, altText}
// Metadata
@@ -374,6 +380,9 @@ export const userSwarmLikes = pgTable('user_swarm_likes', {
linkPreviewTitle: text('link_preview_title'),
linkPreviewDescription: text('link_preview_description'),
linkPreviewImage: text('link_preview_image'),
+ linkPreviewType: text('link_preview_type'),
+ linkPreviewVideoUrl: text('link_preview_video_url'),
+ linkPreviewMediaJson: text('link_preview_media_json'),
mediaJson: text('media_json'),
likedAt: timestamp('liked_at').defaultNow().notNull(),
}, (table) => [
@@ -382,6 +391,38 @@ export const userSwarmLikes = pgTable('user_swarm_likes', {
uniqueIndex('user_swarm_likes_unique').on(table.userId, table.nodeDomain, table.originalPostId),
]);
+// ============================================
+// USER SWARM REPOSTS (local users reposting remote swarm posts)
+// ============================================
+
+export const userSwarmReposts = pgTable('user_swarm_reposts', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
+ nodeDomain: text('node_domain').notNull(),
+ originalPostId: text('original_post_id').notNull(),
+ authorHandle: text('author_handle').notNull(),
+ authorDisplayName: text('author_display_name'),
+ authorAvatarUrl: text('author_avatar_url'),
+ content: text('content').notNull(),
+ postCreatedAt: timestamp('post_created_at').notNull(),
+ likesCount: integer('likes_count').default(0).notNull(),
+ repostsCount: integer('reposts_count').default(0).notNull(),
+ repliesCount: integer('replies_count').default(0).notNull(),
+ linkPreviewUrl: text('link_preview_url'),
+ linkPreviewTitle: text('link_preview_title'),
+ linkPreviewDescription: text('link_preview_description'),
+ linkPreviewImage: text('link_preview_image'),
+ linkPreviewType: text('link_preview_type'),
+ linkPreviewVideoUrl: text('link_preview_video_url'),
+ linkPreviewMediaJson: text('link_preview_media_json'),
+ mediaJson: text('media_json'),
+ repostedAt: timestamp('reposted_at').defaultNow().notNull(),
+}, (table) => [
+ index('user_swarm_reposts_user_idx').on(table.userId, table.repostedAt),
+ index('user_swarm_reposts_post_idx').on(table.nodeDomain, table.originalPostId),
+ uniqueIndex('user_swarm_reposts_unique').on(table.userId, table.nodeDomain, table.originalPostId),
+]);
+
// ============================================
// REMOTE REPOSTS (reposts from federated users on local posts)
// ============================================
@@ -411,6 +452,12 @@ export const notifications = pgTable('notifications', {
actorDisplayName: text('actor_display_name'),
actorAvatarUrl: text('actor_avatar_url'),
actorNodeDomain: text('actor_node_domain'), // null for local actors
+ // Target info - used for owner-shadow notifications like interactions with owned bots
+ targetHandle: text('target_handle'),
+ targetDisplayName: text('target_display_name'),
+ targetAvatarUrl: text('target_avatar_url'),
+ targetNodeDomain: text('target_node_domain'),
+ targetIsBot: boolean('target_is_bot'),
// Post reference
postId: uuid('post_id').references(() => posts.id, { onDelete: 'cascade' }),
postContent: text('post_content'), // Cached content for display
@@ -586,8 +633,9 @@ export const bots = pgTable('bots', {
personalityConfig: text('personality_config').notNull(), // JSON
// LLM configuration
- llmProvider: text('llm_provider').notNull(), // openrouter, openai, anthropic
+ llmProvider: text('llm_provider').notNull(), // openrouter, openai, anthropic, custom
llmModel: text('llm_model').notNull(),
+ llmEndpoint: text('llm_endpoint'),
llmApiKeyEncrypted: text('llm_api_key_encrypted').notNull(),
// Scheduling
@@ -840,6 +888,11 @@ export const swarmNodes = pgTable('swarm_nodes', {
// Trust/reputation (for future spam prevention)
trustScore: integer('trust_score').default(50).notNull(), // 0-100
+ // Admin moderation
+ isBlocked: boolean('is_blocked').default(false).notNull(),
+ blockReason: text('block_reason'),
+ blockedAt: timestamp('blocked_at'),
+
// Capabilities
capabilities: text('capabilities'), // JSON array: ["handles", "gossip", "relay"]
@@ -851,6 +904,7 @@ export const swarmNodes = pgTable('swarm_nodes', {
index('swarm_nodes_last_seen_idx').on(table.lastSeenAt),
index('swarm_nodes_trust_idx').on(table.trustScore),
index('swarm_nodes_nsfw_idx').on(table.isNsfw),
+ index('swarm_nodes_blocked_idx').on(table.isBlocked),
]);
/**
diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts
index 6529604..4fb9cfb 100644
--- a/src/lib/auth/index.ts
+++ b/src/lib/auth/index.ts
@@ -3,7 +3,7 @@
*/
import { db, users, sessions } from '@/db';
-import { eq } from 'drizzle-orm';
+import { eq, inArray } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import { v4 as uuid } from 'uuid';
import { generateKeyPair } from '@/lib/crypto/keys';
@@ -13,8 +13,107 @@ import { cookies } from 'next/headers';
import { upsertHandleEntries } from '@/lib/federation/handles';
import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
-const SESSION_COOKIE_NAME = 'synapsis_session';
-const SESSION_EXPIRY_DAYS = 30;
+const ACTIVE_SESSION_COOKIE_NAME = 'synapsis_session';
+const SESSION_COOKIE_NAME = 'synapsis_sessions';
+const SESSION_EXPIRY_DAYS = 3650;
+
+type SessionRecord = typeof sessions.$inferSelect & {
+ user: typeof users.$inferSelect;
+};
+
+export interface AuthAccount {
+ id: string;
+ handle: string;
+ displayName: string | null;
+ avatarUrl: string | null;
+ did: string;
+ publicKey: string;
+ privateKeyEncrypted: string | null;
+ email: string | null;
+ isActive: boolean;
+}
+
+function parseSessionCookie(value: string | undefined): string[] {
+ if (!value) {
+ return [];
+ }
+
+ return value
+ .split(',')
+ .map(token => token.trim())
+ .filter(Boolean);
+}
+
+async function readSessionState() {
+ const cookieStore = await cookies();
+ return {
+ cookieStore,
+ activeToken: cookieStore.get(ACTIVE_SESSION_COOKIE_NAME)?.value ?? null,
+ tokens: parseSessionCookie(cookieStore.get(SESSION_COOKIE_NAME)?.value),
+ };
+}
+
+async function writeSessionState(tokens: string[], activeToken?: string | null) {
+ const cookieStore = await cookies();
+ const dedupedTokens = [...new Set(tokens.filter(Boolean))];
+
+ if (dedupedTokens.length === 0) {
+ cookieStore.delete(ACTIVE_SESSION_COOKIE_NAME);
+ cookieStore.delete(SESSION_COOKIE_NAME);
+ return;
+ }
+
+ const expiresAt = new Date();
+ expiresAt.setDate(expiresAt.getDate() + SESSION_EXPIRY_DAYS);
+
+ const nextActiveToken = activeToken && dedupedTokens.includes(activeToken)
+ ? activeToken
+ : dedupedTokens[0];
+
+ const cookieOptions = {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax' as const,
+ expires: expiresAt,
+ path: '/',
+ };
+
+ cookieStore.set(ACTIVE_SESSION_COOKIE_NAME, nextActiveToken, cookieOptions);
+ cookieStore.set(SESSION_COOKIE_NAME, dedupedTokens.join(','), cookieOptions);
+}
+
+async function loadSessionsByTokens(tokens: string[]): Promise {
+ const uniqueTokens = [...new Set(tokens.filter(Boolean))];
+ if (uniqueTokens.length === 0) {
+ return [];
+ }
+
+ const sessionRecords = await db.query.sessions.findMany({
+ where: inArray(sessions.token, uniqueTokens),
+ with: {
+ user: true,
+ },
+ });
+
+ const sessionMap = new Map(sessionRecords.map(session => [session.token, session]));
+ return uniqueTokens
+ .map(token => sessionMap.get(token))
+ .filter((session): session is SessionRecord => Boolean(session));
+}
+
+function toAuthAccount(session: SessionRecord, activeToken: string | null): AuthAccount {
+ return {
+ id: session.user.id,
+ handle: session.user.handle,
+ displayName: session.user.displayName,
+ avatarUrl: session.user.avatarUrl,
+ did: session.user.did,
+ publicKey: session.user.publicKey,
+ privateKeyEncrypted: session.user.privateKeyEncrypted,
+ email: session.user.email,
+ isActive: session.token === activeToken,
+ };
+}
/**
* Hash a password
@@ -55,6 +154,16 @@ export function generateLegacyDID(): string {
* Create a new session for a user
*/
export async function createSession(userId: string): Promise {
+ const { tokens } = await readSessionState();
+ const existingSessions = await loadSessionsByTokens(tokens);
+ const existingUserTokens = existingSessions
+ .filter(session => session.userId === userId)
+ .map(session => session.token);
+
+ if (existingUserTokens.length > 0) {
+ await db.delete(sessions).where(inArray(sessions.token, existingUserTokens));
+ }
+
const token = uuid();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + SESSION_EXPIRY_DAYS);
@@ -65,15 +174,8 @@ export async function createSession(userId: string): Promise {
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: '/',
- });
+ const filteredTokens = tokens.filter(existingToken => !existingUserTokens.includes(existingToken));
+ await writeSessionState([token, ...filteredTokens], token);
return token;
}
@@ -82,25 +184,50 @@ export async function createSession(userId: string): Promise {
* 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;
+ const { activeToken, tokens } = await readSessionState();
+ const sessionRecords = await loadSessionsByTokens(tokens);
- if (!token) {
+ if (sessionRecords.length === 0) {
+ await writeSessionState([], null);
return null;
}
- const session = await db.query.sessions.findFirst({
- where: eq(sessions.token, token),
- with: {
- user: true,
- },
- });
+ const activeSession = sessionRecords.find(session => session.token === activeToken) ?? sessionRecords[0];
+ await writeSessionState(sessionRecords.map(session => session.token), activeSession.token);
- if (!session || session.expiresAt < new Date()) {
- return null;
+ return { user: activeSession.user };
+}
+
+export async function getSessionAccounts(): Promise {
+ const { activeToken, tokens } = await readSessionState();
+ const sessionRecords = await loadSessionsByTokens(tokens);
+
+ if (sessionRecords.length === 0) {
+ await writeSessionState([], null);
+ return [];
}
- return { user: session.user };
+ const resolvedActiveToken = sessionRecords.some(session => session.token === activeToken)
+ ? activeToken
+ : sessionRecords[0].token;
+
+ await writeSessionState(sessionRecords.map(session => session.token), resolvedActiveToken);
+
+ return sessionRecords.map(session => toAuthAccount(session, resolvedActiveToken));
+}
+
+export async function switchSession(userId: string): Promise<{ user: typeof users.$inferSelect }> {
+ const { tokens } = await readSessionState();
+ const sessionRecords = await loadSessionsByTokens(tokens);
+ const matchingSession = sessionRecords.find(session => session.user.id === userId);
+
+ if (!matchingSession) {
+ throw new Error('Session not found');
+ }
+
+ await writeSessionState(sessionRecords.map(session => session.token), matchingSession.token);
+
+ return { user: matchingSession.user };
}
/**
@@ -119,14 +246,31 @@ export async function requireAuth(): Promise {
/**
* Destroy the current session
*/
-export async function destroySession(): Promise {
- const cookieStore = await cookies();
- const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
+export async function destroySession(userId?: string): Promise {
+ const { activeToken, tokens } = await readSessionState();
+ const sessionRecords = await loadSessionsByTokens(tokens);
- if (token) {
- await db.delete(sessions).where(eq(sessions.token, token));
- cookieStore.delete(SESSION_COOKIE_NAME);
+ if (sessionRecords.length === 0) {
+ await writeSessionState([], null);
+ return;
}
+
+ const targetSession = userId
+ ? sessionRecords.find(session => session.user.id === userId)
+ : sessionRecords.find(session => session.token === activeToken) ?? sessionRecords[0];
+
+ if (!targetSession) {
+ return;
+ }
+
+ await db.delete(sessions).where(eq(sessions.token, targetSession.token));
+
+ const remainingSessions = sessionRecords.filter(session => session.token !== targetSession.token);
+ const nextActiveToken = targetSession.token === activeToken
+ ? remainingSessions[0]?.token ?? null
+ : activeToken;
+
+ await writeSessionState(remainingSessions.map(session => session.token), nextActiveToken);
}
/**
diff --git a/src/lib/bots/autonomous.ts b/src/lib/bots/autonomous.ts
index 33fb430..1c7da20 100644
--- a/src/lib/bots/autonomous.ts
+++ b/src/lib/bots/autonomous.ts
@@ -12,7 +12,7 @@ import { db, botContentItems, bots } from '@/db';
import { eq, and } from 'drizzle-orm';
import { ContentGenerator, type Bot as ContentGeneratorBot, type ContentItem } from './contentGenerator';
import { canPost } from './rateLimiter';
-import { decryptApiKey, deserializeEncryptedData } from './encryption';
+import { decryptApiKey, deserializeEncryptedData, type LLMProvider } from './encryption';
import { parseScheduleConfig, isDue } from './scheduler';
import { triggerPost } from './posting';
@@ -213,8 +213,9 @@ function toContentGeneratorBot(bot: typeof bots.$inferSelect, handle: string): C
name: bot.name,
handle: handle,
personalityConfig: JSON.parse(bot.personalityConfig),
- llmProvider: bot.llmProvider as 'openrouter' | 'openai' | 'anthropic',
+ llmProvider: bot.llmProvider as LLMProvider,
llmModel: bot.llmModel,
+ llmEndpoint: bot.llmEndpoint,
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
};
}
diff --git a/src/lib/bots/botManager.ts b/src/lib/bots/botManager.ts
index 8e1a647..365ade6 100644
--- a/src/lib/bots/botManager.ts
+++ b/src/lib/bots/botManager.ts
@@ -15,6 +15,7 @@ import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
import { decryptPrivateKey, deserializeEncryptedKey } from '@/lib/crypto/private-key';
import { eq, and, count } from 'drizzle-orm';
import { generateKeyPair } from '@/lib/crypto/keys';
+import { isIP } from 'net';
import {
encryptApiKey,
decryptApiKey,
@@ -52,6 +53,7 @@ export interface BotCreateInput {
personality: PersonalityConfig;
llmProvider: LLMProvider;
llmModel: string;
+ llmEndpoint?: string;
llmApiKey: string;
schedule?: ScheduleConfig;
autonomousMode?: boolean;
@@ -65,6 +67,7 @@ export interface BotUpdateInput {
personality?: PersonalityConfig;
llmProvider?: LLMProvider;
llmModel?: string;
+ llmEndpoint?: string | null;
llmApiKey?: string;
schedule?: ScheduleConfig | null;
autonomousMode?: boolean;
@@ -83,6 +86,7 @@ export interface Bot {
personalityConfig: PersonalityConfig;
llmProvider: LLMProvider;
llmModel: string;
+ llmEndpoint?: string | null;
scheduleConfig: ScheduleConfig | null;
autonomousMode: boolean;
isActive: boolean;
@@ -248,6 +252,65 @@ export function validateScheduleConfig(config: ScheduleConfig): void {
}
}
+function isPrivateHostname(hostname: string): boolean {
+ const normalized = hostname.toLowerCase();
+
+ if (
+ normalized === 'localhost' ||
+ normalized.endsWith('.localhost') ||
+ normalized.endsWith('.local')
+ ) {
+ return true;
+ }
+
+ const ipVersion = isIP(normalized);
+ if (ipVersion === 4) {
+ const octets = normalized.split('.').map(Number);
+ const [first, second] = octets;
+
+ return (
+ first === 10 ||
+ first === 127 ||
+ (first === 169 && second === 254) ||
+ (first === 172 && second >= 16 && second <= 31) ||
+ (first === 192 && second === 168)
+ );
+ }
+
+ if (ipVersion === 6) {
+ return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:');
+ }
+
+ return false;
+}
+
+export function normalizeAndValidateLlmEndpoint(endpoint: string): string {
+ if (!endpoint || typeof endpoint !== 'string') {
+ throw new BotValidationError('Custom endpoint is required');
+ }
+
+ let parsed: URL;
+ try {
+ parsed = new URL(endpoint.trim());
+ } catch {
+ throw new BotValidationError('Custom endpoint must be a valid URL');
+ }
+
+ if (parsed.protocol !== 'https:') {
+ throw new BotValidationError('Custom endpoint must use HTTPS');
+ }
+
+ if (parsed.username || parsed.password) {
+ throw new BotValidationError('Custom endpoint cannot include embedded credentials');
+ }
+
+ if (isPrivateHostname(parsed.hostname)) {
+ throw new BotValidationError('Custom endpoint must be publicly reachable and cannot target localhost or a private network');
+ }
+
+ return parsed.toString();
+}
+
// ============================================
// HELPER FUNCTIONS
// ============================================
@@ -269,6 +332,7 @@ function dbBotToBot(dbBot: typeof bots.$inferSelect, botUser: typeof users.$infe
personalityConfig: JSON.parse(dbBot.personalityConfig) as PersonalityConfig,
llmProvider: dbBot.llmProvider as LLMProvider,
llmModel: dbBot.llmModel,
+ llmEndpoint: dbBot.llmEndpoint,
scheduleConfig: dbBot.scheduleConfig ? JSON.parse(dbBot.scheduleConfig) as ScheduleConfig : null,
autonomousMode: dbBot.autonomousMode,
isActive: dbBot.isActive,
@@ -308,6 +372,10 @@ export async function createBot(ownerId: string, config: BotCreateInput): Promis
if (config.schedule) {
validateScheduleConfig(config.schedule);
}
+
+ const normalizedEndpoint = config.llmProvider === 'custom'
+ ? normalizeAndValidateLlmEndpoint(config.llmEndpoint || '')
+ : null;
// Validate API key format
const apiKeyValidation = validateApiKeyFormat(config.llmApiKey, config.llmProvider);
@@ -391,6 +459,7 @@ export async function createBot(ownerId: string, config: BotCreateInput): Promis
personalityConfig: JSON.stringify(config.personality),
llmProvider: config.llmProvider,
llmModel: config.llmModel,
+ llmEndpoint: normalizedEndpoint,
llmApiKeyEncrypted: serializeEncryptedData(encryptedApiKey),
scheduleConfig: config.schedule ? JSON.stringify(config.schedule) : null,
autonomousMode: config.autonomousMode ?? false,
@@ -443,15 +512,7 @@ export async function updateBot(botId: string, config: BotUpdateInput): Promise<
if (config.schedule !== undefined && config.schedule !== null) {
validateScheduleConfig(config.schedule);
}
-
- // Validate API key if provided
- if (config.llmApiKey !== undefined && config.llmProvider !== undefined) {
- const apiKeyValidation = validateApiKeyFormat(config.llmApiKey, config.llmProvider);
- if (!apiKeyValidation.valid) {
- throw new BotValidationError(apiKeyValidation.error || 'Invalid API key');
- }
- }
-
+
// Check if bot exists and get its user account
const existingBot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
@@ -469,6 +530,25 @@ export async function updateBot(botId: string, config: BotUpdateInput): Promise<
if (!botUser) {
throw new BotNotFoundError(botId);
}
+
+ const targetProvider = (config.llmProvider ?? existingBot.llmProvider) as LLMProvider;
+
+ if (targetProvider === 'custom') {
+ const endpointToValidate = config.llmEndpoint !== undefined ? config.llmEndpoint : existingBot.llmEndpoint;
+ if (!endpointToValidate) {
+ throw new BotValidationError('Custom endpoint is required');
+ }
+ config.llmEndpoint = normalizeAndValidateLlmEndpoint(endpointToValidate);
+ } else if (config.llmEndpoint !== undefined || config.llmProvider !== undefined) {
+ config.llmEndpoint = null;
+ }
+
+ if (config.llmApiKey !== undefined) {
+ const apiKeyValidation = validateApiKeyFormat(config.llmApiKey, targetProvider);
+ if (!apiKeyValidation.valid) {
+ throw new BotValidationError(apiKeyValidation.error || 'Invalid API key');
+ }
+ }
// Build update object for bot config
const botUpdateData: Partial = {
@@ -508,6 +588,12 @@ export async function updateBot(botId: string, config: BotUpdateInput): Promise<
if (config.llmModel !== undefined) {
botUpdateData.llmModel = config.llmModel;
}
+
+ if (config.llmEndpoint !== undefined) {
+ botUpdateData.llmEndpoint = config.llmEndpoint;
+ } else if (config.llmProvider !== undefined && config.llmProvider !== 'custom') {
+ botUpdateData.llmEndpoint = null;
+ }
if (config.llmApiKey !== undefined) {
const encryptedApiKey = encryptApiKey(config.llmApiKey);
diff --git a/src/lib/bots/contentFetcher.test.ts b/src/lib/bots/contentFetcher.test.ts
index cd349ed..25d9d81 100644
--- a/src/lib/bots/contentFetcher.test.ts
+++ b/src/lib/bots/contentFetcher.test.ts
@@ -12,6 +12,8 @@ import {
calculateBackoffDelay,
shouldRetrySource,
isSourceDueForFetch,
+ shouldExcludeRedditPost,
+ fetchRedditPosts,
BASE_BACKOFF_DELAY_MS,
MAX_BACKOFF_DELAY_MS,
MAX_CONSECUTIVE_ERRORS,
@@ -195,6 +197,88 @@ describe('isSourceDueForFetch', () => {
});
+describe('Reddit filtering', () => {
+ const originalFetch = global.fetch;
+
+ afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+ });
+
+ it('excludes stickied and pinned reddit posts', () => {
+ expect(shouldExcludeRedditPost({
+ id: 't3_stickied',
+ title: 'Community thread',
+ permalink: '/r/test/comments/stickied/community_thread/',
+ url: 'https://reddit.com/r/test/comments/stickied/community_thread/',
+ created_utc: 1710000000,
+ stickied: true,
+ })).toBe(true);
+
+ expect(shouldExcludeRedditPost({
+ id: 't3_pinned',
+ title: 'Pinned thread',
+ permalink: '/r/test/comments/pinned/pinned_thread/',
+ url: 'https://reddit.com/r/test/comments/pinned/pinned_thread/',
+ created_utc: 1710000000,
+ pinned: true,
+ })).toBe(true);
+
+ expect(shouldExcludeRedditPost({
+ id: 't3_normal',
+ title: 'Normal post',
+ permalink: '/r/test/comments/normal/normal_post/',
+ url: 'https://reddit.com/r/test/comments/normal/normal_post/',
+ created_utc: 1710000000,
+ })).toBe(false);
+ });
+
+ it('fetchRedditPosts removes community highlights before returning items', async () => {
+ const payload = {
+ data: {
+ children: [
+ {
+ data: {
+ id: 'abc123',
+ name: 't3_abc123',
+ title: 'Community Highlights',
+ selftext: 'Pinned moderator thread',
+ permalink: '/r/test/comments/abc123/community_highlights/',
+ url: 'https://www.reddit.com/r/test/comments/abc123/community_highlights/',
+ created_utc: 1710000000,
+ stickied: true,
+ },
+ },
+ {
+ data: {
+ id: 'def456',
+ name: 't3_def456',
+ title: 'Actual content post',
+ selftext: 'Useful content',
+ permalink: '/r/test/comments/def456/actual_content_post/',
+ url: 'https://example.com/article',
+ created_utc: 1710000100,
+ stickied: false,
+ pinned: false,
+ },
+ },
+ ],
+ },
+ };
+
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ text: async () => JSON.stringify(payload),
+ } as Response);
+
+ const items = await fetchRedditPosts('test', { maxItems: 10 });
+
+ expect(items).toHaveLength(1);
+ expect(items[0]?.title).toBe('Actual content post');
+ expect(items[0]?.id).toBe('t3_def456');
+ });
+});
+
// ============================================
// BACKOFF DELAY PROPERTY TESTS
// ============================================
diff --git a/src/lib/bots/contentFetcher.ts b/src/lib/bots/contentFetcher.ts
index bb0b0bc..f8595e6 100644
--- a/src/lib/bots/contentFetcher.ts
+++ b/src/lib/bots/contentFetcher.ts
@@ -41,6 +41,7 @@ export interface ContentItemInput {
title: string;
content: string | null;
url: string;
+ url_overridden_by_dest?: string;
publishedAt: Date;
}
@@ -325,16 +326,39 @@ export async function fetchRedditPosts(
subreddit: string,
options: FetchOptions = {}
): Promise {
- // Use RSS feed instead of JSON API - more reliable and doesn't require auth
- const rssUrl = `https://www.reddit.com/r/${subreddit}/hot.rss`;
-
+ const requestedMaxItems = options.maxItems ?? DEFAULT_MAX_ITEMS;
+ const fetchLimit = Math.min(Math.max(requestedMaxItems + 10, requestedMaxItems), 100);
+ const url = new URL(`${REDDIT_API_BASE}/r/${subreddit}/hot.json`);
+ url.searchParams.set('raw_json', '1');
+ url.searchParams.set('limit', String(fetchLimit));
+
+ const responseText = await fetchUrl(url.toString(), {
+ timeout: options.timeout,
+ headers: {
+ 'Accept': 'application/json',
+ },
+ });
+
+ let response: RedditListingResponse;
try {
- return await fetchRSSFeed(rssUrl, options);
- } catch (error) {
- // If RSS fails, try the old.reddit.com RSS which sometimes works better
- const oldRedditUrl = `https://old.reddit.com/r/${subreddit}/hot.rss`;
- return await fetchRSSFeed(oldRedditUrl, options);
+ response = JSON.parse(responseText) as RedditListingResponse;
+ } catch {
+ throw new ParseError('Failed to parse Reddit JSON response');
}
+
+ const posts = response.data?.children
+ ?.map((child) => child.data)
+ .filter((post): post is RedditListingChildData => Boolean(post)) || [];
+
+ const filteredPosts = posts.filter((post) => !shouldExcludeRedditPost(post));
+
+ return filteredPosts.slice(0, requestedMaxItems).map((post): FeedItem => ({
+ id: post.name || post.id,
+ title: post.title,
+ content: post.selftext || '',
+ url: `${REDDIT_API_BASE}${post.permalink}`,
+ publishedAt: new Date(post.created_utc * 1000),
+ }));
}
// ============================================
@@ -384,6 +408,31 @@ interface BraveNewsResponse {
results?: BraveNewsResult[];
}
+interface RedditListingChildData {
+ id: string;
+ name?: string;
+ title: string;
+ selftext?: string;
+ permalink: string;
+ url: string;
+ url_overridden_by_dest?: string;
+ created_utc: number;
+ stickied?: boolean;
+ pinned?: boolean;
+}
+
+interface RedditListingResponse {
+ data?: {
+ children?: Array<{
+ data?: RedditListingChildData;
+ }>;
+ };
+}
+
+export function shouldExcludeRedditPost(post: RedditListingChildData): boolean {
+ return Boolean(post.stickied || post.pinned);
+}
+
/**
* Fetch articles from a news API.
*
diff --git a/src/lib/bots/contentGenerator.ts b/src/lib/bots/contentGenerator.ts
index 6d53176..47af7af 100644
--- a/src/lib/bots/contentGenerator.ts
+++ b/src/lib/bots/contentGenerator.ts
@@ -26,6 +26,7 @@ export interface Bot {
personalityConfig: PersonalityConfig;
llmProvider: LLMProvider;
llmModel: string;
+ llmEndpoint?: string | null;
llmApiKeyEncrypted: string;
}
@@ -35,6 +36,7 @@ export interface Bot {
export interface ContentItem {
id: string;
sourceId: string;
+ externalId?: string;
title: string;
content: string | null;
url: string;
@@ -467,6 +469,7 @@ export class ContentGenerator {
provider: bot.llmProvider,
apiKey: bot.llmApiKeyEncrypted,
model: bot.llmModel,
+ endpoint: bot.llmEndpoint,
});
}
diff --git a/src/lib/bots/encryption.ts b/src/lib/bots/encryption.ts
index e10245c..1542f9b 100644
--- a/src/lib/bots/encryption.ts
+++ b/src/lib/bots/encryption.ts
@@ -13,7 +13,7 @@ import * as crypto from 'crypto';
// TYPES
// ============================================
-export type LLMProvider = 'openrouter' | 'openai' | 'anthropic';
+export type LLMProvider = 'openrouter' | 'openai' | 'anthropic' | 'custom';
export interface EncryptedData {
encrypted: string; // Base64 encoded ciphertext + auth tag
@@ -40,6 +40,7 @@ const API_KEY_PATTERNS: Record = {
openrouter: /^sk-or-[a-zA-Z0-9_-]+$/,
anthropic: /^sk-ant-[a-zA-Z0-9_-]+$/,
openai: /^sk-[a-zA-Z0-9_-]+$/,
+ custom: /^[\s\S]+$/,
};
/**
@@ -330,7 +331,7 @@ export function validateAndDetectApiKey(apiKey: string): ApiKeyValidationResult
* @returns True if the provider is supported
*/
export function isSupportedProvider(provider: string): provider is LLMProvider {
- return provider === 'openrouter' || provider === 'openai' || provider === 'anthropic';
+ return provider === 'openrouter' || provider === 'openai' || provider === 'anthropic' || provider === 'custom';
}
/**
@@ -339,5 +340,5 @@ export function isSupportedProvider(provider: string): provider is LLMProvider {
* @returns Array of supported provider names
*/
export function getSupportedProviders(): LLMProvider[] {
- return ['openrouter', 'openai', 'anthropic'];
+ return ['openrouter', 'openai', 'anthropic', 'custom'];
}
diff --git a/src/lib/bots/llmClient.ts b/src/lib/bots/llmClient.ts
index 7b2f510..4484e52 100644
--- a/src/lib/bots/llmClient.ts
+++ b/src/lib/bots/llmClient.ts
@@ -24,6 +24,7 @@ export interface LLMConfig {
provider: LLMProvider;
apiKey: string; // Encrypted before storage
model: string;
+ endpoint?: string | null;
}
/**
@@ -123,6 +124,7 @@ export const PROVIDER_ENDPOINTS: Record = {
openrouter: 'https://openrouter.ai/api/v1/chat/completions',
openai: 'https://api.openai.com/v1/chat/completions',
anthropic: 'https://api.anthropic.com/v1/messages',
+ custom: '',
};
/**
@@ -132,6 +134,7 @@ export const DEFAULT_MODELS: Record = {
openrouter: 'openai/gpt-3.5-turbo',
openai: 'gpt-3.5-turbo',
anthropic: 'claude-3-haiku-20240307',
+ custom: 'gpt-4o-mini',
};
// ============================================
@@ -299,7 +302,8 @@ export function parseOpenRouterResponse(
*/
export function parseOpenAIResponse(
data: Record,
- model: string
+ model: string,
+ provider: LLMProvider = 'openai'
): LLMCompletionResponse {
const choices = data.choices as Array<{ message: { content: string } }>;
const usage = data.usage as { prompt_tokens: number; completion_tokens: number; total_tokens: number };
@@ -312,7 +316,7 @@ export function parseOpenAIResponse(
total: usage?.total_tokens ?? 0,
},
model: (data.model as string) ?? model,
- provider: 'openai',
+ provider,
};
}
@@ -359,6 +363,7 @@ export function buildHeaders(provider: LLMProvider, apiKey: string): Record {
- const endpoint = PROVIDER_ENDPOINTS[this.provider];
+ const endpoint = this.provider === 'custom' ? this.endpoint : PROVIDER_ENDPOINTS[this.provider];
+ if (!endpoint) {
+ throw new LLMClientError(
+ 'Custom provider requires an endpoint',
+ 'INVALID_REQUEST',
+ this.provider,
+ undefined,
+ false
+ );
+ }
const headers = buildHeaders(this.provider, this.apiKey);
const body = this.buildRequestBody(request);
@@ -566,6 +582,7 @@ export class LLMClient {
case 'openrouter':
return buildOpenRouterRequest(request, this.model);
case 'openai':
+ case 'custom':
return buildOpenAIRequest(request, this.model);
case 'anthropic':
return buildAnthropicRequest(request, this.model);
@@ -580,7 +597,9 @@ export class LLMClient {
case 'openrouter':
return parseOpenRouterResponse(data, this.model);
case 'openai':
- return parseOpenAIResponse(data, this.model);
+ return parseOpenAIResponse(data, this.model, 'openai');
+ case 'custom':
+ return parseOpenAIResponse(data, this.model, 'custom');
case 'anthropic':
return parseAnthropicResponse(data, this.model);
}
@@ -618,12 +637,14 @@ export function createLLMClient(
export function createLLMClientFromBot(
provider: LLMProvider,
encryptedApiKey: string,
- model: string
+ model: string,
+ endpoint?: string | null
): LLMClient {
return new LLMClient({
provider,
apiKey: encryptedApiKey,
model,
+ endpoint,
});
}
@@ -643,7 +664,7 @@ export function validateLLMConfig(config: unknown): { valid: boolean; errors: st
const configObj = config as Record;
// Validate provider
- const validProviders: LLMProvider[] = ['openrouter', 'openai', 'anthropic'];
+ const validProviders: LLMProvider[] = ['openrouter', 'openai', 'anthropic', 'custom'];
if (!configObj.provider || !validProviders.includes(configObj.provider as LLMProvider)) {
errors.push(`Provider must be one of: ${validProviders.join(', ')}`);
}
@@ -657,6 +678,10 @@ export function validateLLMConfig(config: unknown): { valid: boolean; errors: st
if (configObj.model !== undefined && typeof configObj.model !== 'string') {
errors.push('Model must be a string');
}
+
+ if (configObj.provider === 'custom' && (!configObj.endpoint || typeof configObj.endpoint !== 'string')) {
+ errors.push('Endpoint is required for custom provider');
+ }
return { valid: errors.length === 0, errors };
}
diff --git a/src/lib/bots/mentionHandler.ts b/src/lib/bots/mentionHandler.ts
index 16317f9..28b466e 100644
--- a/src/lib/bots/mentionHandler.ts
+++ b/src/lib/bots/mentionHandler.ts
@@ -446,6 +446,7 @@ export async function processMention(mentionId: string): Promise {
- try {
- // Reddit's oEmbed endpoint
- const oembedUrl = `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}`;
-
- const response = await fetch(oembedUrl, {
- headers: {
- 'Accept': 'application/json',
- },
- signal: AbortSignal.timeout(5000),
- });
-
- if (!response.ok) {
- console.log(`Reddit oEmbed returned ${response.status} for ${url}`);
- return null;
- }
-
- const data = await response.json();
-
- // Extract title - try title field first, then parse from HTML
- let title = data.title || null;
- if (!title && data.html) {
- // Try to extract title from the embed HTML
- const titleMatch = data.html.match(/href="[^"]+">([^<]+)<\/a>/);
- if (titleMatch && titleMatch[1] && titleMatch[1] !== 'Comment') {
- title = titleMatch[1];
- }
- }
-
- // Build description from subreddit info if available
- let description = null;
- if (data.author_name) {
- description = `Posted by ${data.author_name}`;
- } else if (data.html) {
- // Try to extract subreddit from HTML
- const subredditMatch = data.html.match(/r\/([a-zA-Z0-9_]+)/);
- if (subredditMatch) {
- description = `r/${subredditMatch[1]}`;
- }
- }
-
- return {
- url,
- title,
- description,
- image: data.thumbnail_url || null,
- };
- } catch (error) {
- console.error('Failed to fetch Reddit oEmbed preview:', error);
- return null;
- }
+async function fetchRedditPreview(url: string): Promise {
+ return fetchRedditRichPreview(url);
}
/**
@@ -695,12 +647,7 @@ async function fetchRedditPreview(url: string): Promise<{
* @param url - The URL to fetch preview for
* @returns Link preview data or null
*/
-async function fetchLinkPreview(url: string): Promise<{
- url: string;
- title: string | null;
- description: string | null;
- image: string | null;
-} | null> {
+async function fetchLinkPreview(url: string): Promise {
// Use Reddit-specific handler
if (isRedditUrl(url)) {
return fetchRedditPreview(url);
@@ -708,48 +655,61 @@ async function fetchLinkPreview(url: string): Promise<{
// Generic OG tag scraping for other sites
try {
- const response = await fetch(url, {
- headers: {
- 'User-Agent': 'Mozilla/5.0 (compatible; SynapsisBot/1.0; +https://synapsis.social)',
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
- 'Accept-Language': 'en-US,en;q=0.5',
- },
- signal: AbortSignal.timeout(5000),
- });
-
- if (!response.ok) {
- return null;
- }
-
- const html = await response.text();
-
- // Simple regex extraction for OG tags
- const getMeta = (property: string): string | null => {
- const regex = new RegExp(` ]+(?:property|name)=["'](?:og:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i');
- const match = html.match(regex);
- if (match) return match[1];
-
- const regexRev = new RegExp(` ]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:)?${property}["']`, 'i');
- const matchRev = html.match(regexRev);
- return matchRev ? matchRev[1] : null;
- };
-
- const title = getMeta('title') || html.match(/([^<]+)<\/title>/i)?.[1];
- const description = getMeta('description');
- const image = getMeta('image');
-
- return {
- url,
- title: title?.trim() || null,
- description: description?.trim() || null,
- image: image?.trim() || null,
- };
+ return await fetchGenericLinkPreview(url);
} catch (error) {
console.error('Failed to fetch link preview:', error);
return null;
}
}
+function extractRedditPostId(contentItem: ContentItem): string | null {
+ if (contentItem.externalId) {
+ const normalizedExternalId = contentItem.externalId.replace(/^t3_/, '');
+ if (/^[a-z0-9]+$/i.test(normalizedExternalId)) {
+ return normalizedExternalId;
+ }
+ }
+
+ if (contentItem.url) {
+ try {
+ const pathnameParts = new URL(contentItem.url).pathname.split('/').filter(Boolean);
+ const commentsIndex = pathnameParts.indexOf('comments');
+ if (commentsIndex >= 0 && pathnameParts[commentsIndex + 1]) {
+ return pathnameParts[commentsIndex + 1];
+ }
+ } catch {
+ // Fall back to returning null below.
+ }
+ }
+
+ return null;
+}
+
+async function resolveContentItemSourceUrl(contentItem?: ContentItem | null): Promise {
+ if (!contentItem?.url) {
+ return contentItem?.url;
+ }
+
+ const source = await db.query.botContentSources.findFirst({
+ where: eq(botContentSources.id, contentItem.sourceId),
+ columns: {
+ type: true,
+ subreddit: true,
+ },
+ });
+
+ if (source?.type !== 'reddit' || !source.subreddit) {
+ return contentItem.url;
+ }
+
+ const postId = extractRedditPostId(contentItem);
+ if (!postId) {
+ return contentItem.url;
+ }
+
+ return `https://www.reddit.com/r/${source.subreddit}/comments/${postId}/`;
+}
+
/**
* Create a post in the database.
* Posts are created under the bot's own user account.
@@ -762,7 +722,7 @@ async function fetchLinkPreview(url: string): Promise<{
async function createPostInDatabase(
botId: string,
content: string,
- sourceUrl?: string
+ contentItem?: ContentItem | null
): Promise {
// Get bot config
const bot = await db.query.bots.findFirst({
@@ -789,6 +749,7 @@ async function createPostInDatabase(
}
// Fetch link preview if source URL provided
+ const sourceUrl = await resolveContentItemSourceUrl(contentItem);
let linkPreview: Awaited> = null;
if (sourceUrl) {
linkPreview = await fetchLinkPreview(sourceUrl);
@@ -809,6 +770,9 @@ async function createPostInDatabase(
linkPreviewTitle: linkPreview?.title || null,
linkPreviewDescription: linkPreview?.description || null,
linkPreviewImage: linkPreview?.image || null,
+ linkPreviewType: linkPreview?.type || null,
+ linkPreviewVideoUrl: linkPreview?.videoUrl || null,
+ linkPreviewMediaJson: linkPreview?.media ? JSON.stringify(linkPreview.media) : null,
}).returning();
// Update bot user's post count
@@ -1079,7 +1043,7 @@ export async function triggerPost(
}
// Create post in database with source URL for link preview (if content item exists)
- const post = await createPostInDatabase(botId, postContent, contentItem?.url);
+ const post = await createPostInDatabase(botId, postContent, contentItem);
// Record post for rate limiting
if (!skipRateLimitCheck) {
diff --git a/src/lib/contexts/AuthContext.tsx b/src/lib/contexts/AuthContext.tsx
index eea4156..f3d6786 100644
--- a/src/lib/contexts/AuthContext.tsx
+++ b/src/lib/contexts/AuthContext.tsx
@@ -14,8 +14,14 @@ export interface User {
privateKeyEncrypted?: string;
}
+export interface AuthAccount extends User {
+ isActive: boolean;
+}
+
interface AuthContextType {
user: User | null;
+ accounts: AuthAccount[];
+ activeAccountId: string | null;
isAdmin: boolean;
loading: boolean;
isIdentityUnlocked: boolean;
@@ -24,8 +30,10 @@ interface AuthContextType {
handle: string | null;
checkAdmin: () => Promise;
unlockIdentity: (password: string, explicitUser?: User) => Promise;
- login: (user: User) => void;
- logout: () => Promise;
+ login: (user?: User) => Promise;
+ logout: (userId?: string) => Promise;
+ switchAccount: (userId: string) => Promise;
+ refreshAuth: () => Promise;
lockIdentity: () => Promise; // New: manual lock
signUserAction: (action: string, data: any) => Promise;
requiresUnlock: boolean; // True if user has encrypted key but not unlocked
@@ -35,6 +43,8 @@ interface AuthContextType {
const AuthContext = createContext({
user: null,
+ accounts: [],
+ activeAccountId: null,
isAdmin: false,
loading: true,
isIdentityUnlocked: false,
@@ -43,8 +53,10 @@ const AuthContext = createContext({
handle: null,
checkAdmin: async () => { },
unlockIdentity: async () => { },
- login: () => { },
+ login: async () => { },
logout: async () => { },
+ switchAccount: async () => { },
+ refreshAuth: async () => { },
lockIdentity: async () => { },
signUserAction: async () => Promise.reject('Not initialized'),
requiresUnlock: false,
@@ -54,6 +66,7 @@ const AuthContext = createContext({
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState(null);
+ const [accounts, setAccounts] = useState([]);
const [isAdmin, setIsAdmin] = useState(false);
const [loading, setLoading] = useState(true);
const [showUnlockPrompt, setShowUnlockPrompt] = useState(false);
@@ -80,6 +93,41 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}, []);
+ const applyAuthState = useCallback(async (data: { user: User | null; accounts?: AuthAccount[] | null }) => {
+ const nextAccounts = data.accounts ?? [];
+ setAccounts(nextAccounts);
+ setUser(data.user);
+
+ if (data.user?.did && data.user?.publicKey) {
+ await initializeIdentity({
+ did: data.user.did,
+ handle: data.user.handle,
+ publicKey: data.user.publicKey,
+ privateKeyEncrypted: data.user.privateKeyEncrypted,
+ });
+ await checkAdmin();
+ } else {
+ await clearIdentity();
+ setIsAdmin(false);
+ }
+ }, [checkAdmin, clearIdentity, initializeIdentity]);
+
+ const refreshAuth = useCallback(async () => {
+ setLoading(true);
+ try {
+ const res = await fetch('/api/auth/me', { cache: 'no-store' });
+ const data = await res.json();
+ await applyAuthState({
+ user: res.ok ? data.user ?? null : null,
+ accounts: res.ok ? data.accounts ?? [] : [],
+ });
+ } catch {
+ await applyAuthState({ user: null, accounts: [] });
+ } finally {
+ setLoading(false);
+ }
+ }, [applyAuthState]);
+
/**
* Unlock the user's identity with their password
* Persists the key for auto-unlock on refresh
@@ -112,81 +160,65 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
/**
* Manually set the user state (called after successful login)
*/
- const login = useCallback((userData: User) => {
- setUser(userData);
- checkAdmin();
-
- // Initialize identity - will try to auto-restore if possible
- if (userData.did && userData.publicKey) {
- initializeIdentity({
- did: userData.did,
- handle: userData.handle,
- publicKey: userData.publicKey,
- privateKeyEncrypted: userData.privateKeyEncrypted,
- });
- }
- }, [checkAdmin, initializeIdentity]);
+ const login = useCallback(async (_userData?: User) => {
+ await refreshAuth();
+ }, [refreshAuth]);
/**
* Logout the user and clear their identity
*/
- const logout = useCallback(async () => {
+ const logout = useCallback(async (userId?: string) => {
try {
- await fetch('/api/auth/logout', { method: 'POST' });
- await clearIdentity();
+ await fetch('/api/auth/logout', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(userId ? { userId } : {}),
+ });
setShowUnlockPrompt(false);
- setUser(null);
- setIsAdmin(false);
+ await refreshAuth();
} catch (error) {
console.error('[Auth] Logout failed:', error);
throw error;
}
- }, [clearIdentity]);
+ }, [refreshAuth]);
+
+ const switchAccount = useCallback(async (userId: string) => {
+ try {
+ setLoading(true);
+ const res = await fetch('/api/auth/switch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userId }),
+ });
+
+ const data = await res.json();
+ if (!res.ok) {
+ throw new Error(data.error || 'Failed to switch account');
+ }
+
+ await refreshAuth();
+ } catch (error) {
+ console.error('[Auth] Switch account failed:', error);
+ throw error;
+ } finally {
+ setLoading(false);
+ }
+ }, [refreshAuth]);
// Load auth state on mount
useEffect(() => {
- const loadAuth = async () => {
- setLoading(true);
- try {
- const res = await fetch('/api/auth/me');
- if (res.ok) {
- const data = await res.json();
- setUser(data.user);
-
- // Initialize identity - will auto-restore if persisted
- if (data.user?.did && data.user?.publicKey) {
- await initializeIdentity({
- did: data.user.did,
- handle: data.user.handle,
- publicKey: data.user.publicKey,
- privateKeyEncrypted: data.user.privateKeyEncrypted,
- });
- }
-
- if (data.user) {
- await checkAdmin();
- }
- } else {
- setUser(null);
- await clearIdentity();
- }
- } catch {
- setUser(null);
- await clearIdentity();
- } finally {
- setLoading(false);
- }
- };
-
- loadAuth();
- }, [checkAdmin, initializeIdentity, clearIdentity]);
+ refreshAuth();
+ }, [refreshAuth]);
// Determine if unlock is required (has encrypted key but not unlocked)
const requiresUnlock = !!user?.privateKeyEncrypted && !isUnlocked && !isRestoring;
+ const activeAccountId = user?.id ?? null;
return (
{
try {
// Derive session key from password
@@ -228,7 +237,7 @@ export async function persistUnlockedKey(
const wrapped = await wrapRawPrivateKey(privateKeyBase64, sessionKey);
// Store wrapped key in IndexedDB
- await storeInDB(WRAPPED_KEY_ITEM, wrapped);
+ await storeInDB(getWrappedKeyItem(identifier), wrapped);
// Store session key in localStorage (so it survives refreshes)
const sessionKeyData = await exportSessionKey(sessionKey);
@@ -236,7 +245,7 @@ export async function persistUnlockedKey(
key: sessionKeyData,
createdAt: Date.now(),
};
- localStorage.setItem(SESSION_KEY_ITEM, JSON.stringify(sessionData));
+ localStorage.setItem(getSessionKeyItem(identifier), JSON.stringify(sessionData));
console.log('[KeyPersistence] Key persisted successfully');
} catch (error) {
@@ -250,10 +259,10 @@ export async function persistUnlockedKey(
* Returns the raw key bytes if available, null otherwise
* The caller must then import these bytes as a non-extractable CryptoKey
*/
-export async function tryRestoreKey(): Promise {
+export async function tryRestoreKey(identifier: string): Promise {
try {
// Get session key from localStorage
- const sessionDataRaw = localStorage.getItem(SESSION_KEY_ITEM);
+ const sessionDataRaw = localStorage.getItem(getSessionKeyItem(identifier));
if (!sessionDataRaw) {
console.log('[KeyPersistence] No session key found');
return null;
@@ -262,15 +271,15 @@ export async function tryRestoreKey(): Promise {
const sessionData: SessionData = JSON.parse(sessionDataRaw);
// Check expiry (24 hours)
- const MAX_AGE = 24 * 60 * 60 * 1000;
+ const MAX_AGE = 3650 * 24 * 60 * 60 * 1000;
if (Date.now() - sessionData.createdAt > MAX_AGE) {
console.log('[KeyPersistence] Session expired');
- await clearPersistentKey();
+ await clearPersistentKey(identifier);
return null;
}
// Get wrapped key from IndexedDB
- const wrapped = await getFromDB(WRAPPED_KEY_ITEM);
+ const wrapped = await getFromDB(getWrappedKeyItem(identifier));
if (!wrapped) {
console.log('[KeyPersistence] No wrapped key found');
return null;
@@ -293,10 +302,24 @@ export async function tryRestoreKey(): Promise {
/**
* Clear the persisted key (logout)
*/
-export async function clearPersistentKey(): Promise {
+export async function clearPersistentKey(identifier?: string): Promise {
try {
- localStorage.removeItem(SESSION_KEY_ITEM);
- await removeFromDB(WRAPPED_KEY_ITEM);
+ if (identifier) {
+ localStorage.removeItem(getSessionKeyItem(identifier));
+ await removeFromDB(getWrappedKeyItem(identifier));
+ return;
+ }
+
+ const keysToRemove: string[] = [];
+ for (let i = 0; i < localStorage.length; i += 1) {
+ const key = localStorage.key(i);
+ if (key?.startsWith(SESSION_KEY_PREFIX)) {
+ keysToRemove.push(key);
+ }
+ }
+
+ keysToRemove.forEach(key => localStorage.removeItem(key));
+
console.log('[KeyPersistence] Key cleared');
} catch (error) {
console.error('[KeyPersistence] Error clearing key:', error);
@@ -306,13 +329,13 @@ export async function clearPersistentKey(): Promise {
/**
* Check if a persisted key is available
*/
-export async function hasPersistentKey(): Promise {
- const sessionData = localStorage.getItem(SESSION_KEY_ITEM);
+export async function hasPersistentKey(identifier: string): Promise {
+ const sessionData = localStorage.getItem(getSessionKeyItem(identifier));
if (!sessionData) return false;
try {
const parsed: SessionData = JSON.parse(sessionData);
- const MAX_AGE = 24 * 60 * 60 * 1000;
+ const MAX_AGE = 3650 * 24 * 60 * 60 * 1000;
return Date.now() - parsed.createdAt <= MAX_AGE;
} catch {
return false;
diff --git a/src/lib/hooks/useUserIdentity.ts b/src/lib/hooks/useUserIdentity.ts
index d00f290..67f4faa 100644
--- a/src/lib/hooks/useUserIdentity.ts
+++ b/src/lib/hooks/useUserIdentity.ts
@@ -51,10 +51,14 @@ export function useUserIdentity() {
setIsRestoring(false);
return;
}
-
+
+ if (!globalIdentity?.did) {
+ return;
+ }
+
// Try to restore from persistent storage
- const restoredKeyBytes = await tryRestoreKey();
- if (restoredKeyBytes && globalIdentity) {
+ const restoredKeyBytes = await tryRestoreKey(globalIdentity.did);
+ if (restoredKeyBytes) {
// Import the restored key as non-extractable
const cryptoKey = await importPrivateKey(restoredKeyBytes);
keyStore.setPrivateKey(cryptoKey);
@@ -86,6 +90,8 @@ export function useUserIdentity() {
publicKey: string;
privateKeyEncrypted?: string;
}) => {
+ keyStore.clear();
+
const coreIdentity = {
did: userData.did,
handle: userData.handle,
@@ -94,7 +100,7 @@ export function useUserIdentity() {
keyStore.setIdentity(coreIdentity);
// Try to auto-restore if we have persisted key
- const restoredKeyBytes = await tryRestoreKey();
+ const restoredKeyBytes = await tryRestoreKey(userData.did);
if (restoredKeyBytes) {
const cryptoKey = await importPrivateKey(restoredKeyBytes);
keyStore.setPrivateKey(cryptoKey);
@@ -150,7 +156,11 @@ export function useUserIdentity() {
// PERSIST: Save raw key bytes for auto-restore on refresh
// We pass the raw bytes because the CryptoKey is non-extractable
- await persistUnlockedKey(privateKeyBase64, password);
+ if (!userDid) {
+ throw new Error('User DID is required to persist the unlocked identity');
+ }
+
+ await persistUnlockedKey(privateKeyBase64, password, userDid);
console.log('[Identity] Private key stored in memory and persisted');
@@ -171,8 +181,9 @@ export function useUserIdentity() {
* Lock the identity (manual lock, keeps identity info)
*/
const lockIdentity = useCallback(async () => {
+ const identifier = keyStore.getIdentity()?.did;
keyStore.clear();
- await clearPersistentKey();
+ await clearPersistentKey(identifier);
setIsUnlocked(false);
setIdentity(prev => prev ? { ...prev, isUnlocked: false } : null);
}, []);
@@ -181,8 +192,9 @@ export function useUserIdentity() {
* Clear the identity (logout)
*/
const clearIdentity = useCallback(async () => {
+ const identifier = keyStore.getIdentity()?.did;
keyStore.clear();
- await clearPersistentKey();
+ await clearPersistentKey(identifier);
setIdentity(null);
setIsUnlocked(false);
}, []);
diff --git a/src/lib/media/genericPreview.ts b/src/lib/media/genericPreview.ts
new file mode 100644
index 0000000..22ccb1e
--- /dev/null
+++ b/src/lib/media/genericPreview.ts
@@ -0,0 +1,62 @@
+import type { LinkPreviewData } from './linkPreview';
+
+const GENERIC_PREVIEW_USER_AGENT = 'Mozilla/5.0 (compatible; SynapsisBot/1.0; +https://synapsis.social)';
+
+function decodeHtmlEntities(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'");
+}
+
+function extractMeta(html: string, property: string): string | null {
+ const patterns = [
+ new RegExp(` ]+(?:property|name|itemprop)=["'](?:og:|twitter:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i'),
+ new RegExp(` ]+content=["']([^"']+)["'][^>]+(?:property|name|itemprop)=["'](?:og:|twitter:)?${property}["']`, 'i'),
+ ];
+
+ for (const pattern of patterns) {
+ const match = html.match(pattern);
+ if (match?.[1]) {
+ return decodeHtmlEntities(match[1]);
+ }
+ }
+
+ return null;
+}
+
+export async function fetchGenericLinkPreview(url: string): Promise {
+ try {
+ const response = await fetch(url, {
+ headers: {
+ 'User-Agent': GENERIC_PREVIEW_USER_AGENT,
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.5',
+ },
+ signal: AbortSignal.timeout(5000),
+ });
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const html = await response.text();
+ const title = extractMeta(html, 'title') || html.match(/([^<]+)<\/title>/i)?.[1] || null;
+ const description = extractMeta(html, 'description');
+ const image = extractMeta(html, 'image');
+
+ return {
+ url,
+ title: title?.trim() || null,
+ description: description?.trim() || null,
+ image: image?.trim() || null,
+ type: image?.trim() ? 'image' : 'card',
+ videoUrl: null,
+ media: image?.trim() ? [{ url: image.trim() }] : null,
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/src/lib/media/linkPreview.ts b/src/lib/media/linkPreview.ts
new file mode 100644
index 0000000..6f19a92
--- /dev/null
+++ b/src/lib/media/linkPreview.ts
@@ -0,0 +1,44 @@
+export type LinkPreviewType = 'card' | 'image' | 'gallery' | 'video';
+
+export interface LinkPreviewMediaItem {
+ url: string;
+ width?: number | null;
+ height?: number | null;
+ mimeType?: string | null;
+}
+
+export interface LinkPreviewData {
+ url: string;
+ title: string | null;
+ description: string | null;
+ image: string | null;
+ type?: LinkPreviewType | null;
+ videoUrl?: string | null;
+ media?: LinkPreviewMediaItem[] | null;
+}
+
+export function parseLinkPreviewMediaJson(
+ value?: string | null
+): LinkPreviewMediaItem[] | undefined {
+ if (!value) return undefined;
+
+ try {
+ const parsed = JSON.parse(value);
+ if (!Array.isArray(parsed)) return undefined;
+
+ return parsed.filter((item): item is LinkPreviewMediaItem => (
+ item &&
+ typeof item === 'object' &&
+ typeof item.url === 'string'
+ ));
+ } catch {
+ return undefined;
+ }
+}
+
+export function serializeLinkPreviewMedia(
+ media?: LinkPreviewMediaItem[] | null
+): string | null {
+ if (!media || media.length === 0) return null;
+ return JSON.stringify(media);
+}
diff --git a/src/lib/media/redditPreview.ts b/src/lib/media/redditPreview.ts
new file mode 100644
index 0000000..17e84e2
--- /dev/null
+++ b/src/lib/media/redditPreview.ts
@@ -0,0 +1,62 @@
+import type { LinkPreviewData } from './linkPreview';
+
+interface RedditOEmbedResponse {
+ title?: string;
+ author_name?: string;
+ provider_name?: string;
+ thumbnail_url?: string;
+ html?: string;
+}
+
+function extractTitleFromHtml(html?: string): string | null {
+ if (!html) return null;
+ const titleMatch = html.match(/href="[^"]+">([^<]+)<\/a>/);
+ if (titleMatch?.[1] && titleMatch[1] !== 'Comment') {
+ return titleMatch[1];
+ }
+ return null;
+}
+
+function extractSubredditFromHtml(html?: string): string | null {
+ if (!html) return null;
+ const subredditMatch = html.match(/r\/([a-zA-Z0-9_]+)/);
+ return subredditMatch?.[1] || null;
+}
+
+export async function fetchRedditRichPreview(url: string): Promise {
+ try {
+ const oembedUrl = `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}`;
+ const response = await fetch(oembedUrl, {
+ headers: {
+ Accept: 'application/json',
+ 'User-Agent': 'Synapsis Link Preview/1.0',
+ },
+ signal: AbortSignal.timeout(5000),
+ });
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const data = await response.json() as RedditOEmbedResponse;
+ const title = data.title || extractTitleFromHtml(data.html) || 'Reddit';
+ const subreddit = extractSubredditFromHtml(data.html);
+ const description = data.author_name
+ ? `Posted by ${data.author_name}${subreddit ? ` in r/${subreddit}` : ''}`
+ : subreddit
+ ? `r/${subreddit}`
+ : (data.provider_name || 'Reddit');
+
+ return {
+ url,
+ title,
+ description,
+ image: null,
+ type: 'card',
+ videoUrl: null,
+ media: null,
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts
new file mode 100644
index 0000000..b004b4b
--- /dev/null
+++ b/src/lib/notifications.ts
@@ -0,0 +1,19 @@
+import { users } from '@/db';
+
+type NotificationTargetUser = Pick<
+ typeof users.$inferSelect,
+ 'handle' | 'displayName' | 'avatarUrl' | 'isBot'
+>;
+
+export function buildNotificationTarget(
+ user: NotificationTargetUser,
+ nodeDomain: string | null = null
+) {
+ return {
+ targetHandle: user.handle,
+ targetDisplayName: user.displayName || user.handle,
+ targetAvatarUrl: user.avatarUrl || null,
+ targetNodeDomain: nodeDomain,
+ targetIsBot: user.isBot,
+ };
+}
diff --git a/src/lib/storage/session.ts b/src/lib/storage/session.ts
index a869fdd..8bc7ef5 100644
--- a/src/lib/storage/session.ts
+++ b/src/lib/storage/session.ts
@@ -3,8 +3,8 @@ import { cookies } from 'next/headers';
import type { users } from '@/db';
import { decryptS3Credentials, type StorageProvider } from '@/lib/storage/s3';
-const STORAGE_SESSION_COOKIE = 'synapsis_storage_session';
-const STORAGE_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
+const STORAGE_SESSION_COOKIE = 'synapsis_storage_sessions';
+const STORAGE_SESSION_TTL_MS = 3650 * 24 * 60 * 60 * 1000;
interface StorageSessionPayload {
userId: string;
@@ -18,6 +18,8 @@ interface StorageSessionPayload {
expiresAt: number;
}
+type StorageSessionMap = Record;
+
function getEncryptionKey(): Buffer {
const secret = process.env.AUTH_SECRET;
@@ -63,6 +65,55 @@ function decryptPayload(token: string): StorageSessionPayload {
return JSON.parse(decrypted) as StorageSessionPayload;
}
+function encryptPayloadMap(payload: StorageSessionMap): string {
+ return encryptPayload(payload as unknown as StorageSessionPayload);
+}
+
+function decryptPayloadMap(token: string): StorageSessionMap {
+ return decryptPayload(token) as unknown as StorageSessionMap;
+}
+
+async function readStorageSessionMap(): Promise {
+ const cookieStore = await cookies();
+ const token = cookieStore.get(STORAGE_SESSION_COOKIE)?.value;
+
+ if (!token) {
+ return {};
+ }
+
+ try {
+ const payload = decryptPayloadMap(token);
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
+ await clearStorageSession();
+ return {};
+ }
+
+ return payload;
+ } catch {
+ await clearStorageSession();
+ return {};
+ }
+}
+
+async function writeStorageSessionMap(payload: StorageSessionMap): Promise {
+ const cookieStore = await cookies();
+
+ if (Object.keys(payload).length === 0) {
+ cookieStore.delete(STORAGE_SESSION_COOKIE);
+ return;
+ }
+
+ const maxExpiresAt = Math.max(...Object.values(payload).map(session => session.expiresAt));
+
+ cookieStore.set(STORAGE_SESSION_COOKIE, encryptPayloadMap(payload), {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ path: '/',
+ expires: new Date(maxExpiresAt),
+ });
+}
+
export async function createStorageSession(
user: typeof users.$inferSelect,
password: string
@@ -73,7 +124,7 @@ export async function createStorageSession(
!user.storageSecretKeyEncrypted ||
!user.storageBucket
) {
- await clearStorageSession();
+ await clearStorageSession(user.id);
return null;
}
@@ -95,42 +146,42 @@ export async function createStorageSession(
expiresAt: Date.now() + STORAGE_SESSION_TTL_MS,
};
- const cookieStore = await cookies();
- cookieStore.set(STORAGE_SESSION_COOKIE, encryptPayload(payload), {
- httpOnly: true,
- secure: process.env.NODE_ENV === 'production',
- sameSite: 'lax',
- path: '/',
- expires: new Date(payload.expiresAt),
- });
+ const existingSessions = await readStorageSessionMap();
+ existingSessions[user.id] = payload;
+ await writeStorageSessionMap(existingSessions);
return payload;
}
export async function getStorageSession(userId: string): Promise {
- const cookieStore = await cookies();
- const token = cookieStore.get(STORAGE_SESSION_COOKIE)?.value;
+ const sessions = await readStorageSessionMap();
+ const payload = sessions[userId];
- if (!token) {
+ if (!payload) {
return null;
}
- try {
- const payload = decryptPayload(token);
-
- if (payload.userId !== userId || payload.expiresAt <= Date.now()) {
- await clearStorageSession();
- return null;
- }
-
- return payload;
- } catch {
- await clearStorageSession();
+ if (payload.expiresAt <= Date.now()) {
+ delete sessions[userId];
+ await writeStorageSessionMap(sessions);
return null;
}
+
+ return payload;
}
-export async function clearStorageSession(): Promise {
- const cookieStore = await cookies();
- cookieStore.delete(STORAGE_SESSION_COOKIE);
+export async function clearStorageSession(userId?: string): Promise {
+ if (!userId) {
+ const cookieStore = await cookies();
+ cookieStore.delete(STORAGE_SESSION_COOKIE);
+ return;
+ }
+
+ const sessions = await readStorageSessionMap();
+ if (!(userId in sessions)) {
+ return;
+ }
+
+ delete sessions[userId];
+ await writeStorageSessionMap(sessions);
}
diff --git a/src/lib/swarm/interactions.ts b/src/lib/swarm/interactions.ts
index f345b71..48f7a7c 100644
--- a/src/lib/swarm/interactions.ts
+++ b/src/lib/swarm/interactions.ts
@@ -14,6 +14,8 @@
import { getActiveSwarmNodes } from './registry';
import type { SwarmNodeInfo } from './types';
+import { filterBlockedDomains, isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
+import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
// ============================================
// TYPES
@@ -141,16 +143,24 @@ export interface SwarmMentionPayload {
* Check if a domain is a known Synapsis swarm node
*/
export async function isSwarmNode(domain: string): Promise {
+ const normalizedDomain = normalizeNodeDomain(domain);
+ if (await isNodeBlocked(normalizedDomain)) {
+ return false;
+ }
const nodes = await getActiveSwarmNodes(500);
- return nodes.some(n => n.domain === domain);
+ return nodes.some(n => n.domain === normalizedDomain);
}
/**
* Get swarm node info if the domain is a swarm node
*/
export async function getSwarmNodeInfo(domain: string): Promise {
+ const normalizedDomain = normalizeNodeDomain(domain);
+ if (await isNodeBlocked(normalizedDomain)) {
+ return null;
+ }
const nodes = await getActiveSwarmNodes(500);
- return nodes.find(n => n.domain === domain) || null;
+ return nodes.find(n => n.domain === normalizedDomain) || null;
}
/**
@@ -257,11 +267,19 @@ async function deliverSwarmInteraction(
payload: unknown
): Promise {
try {
+ const normalizedTargetDomain = normalizeNodeDomain(targetDomain);
+ if (await isNodeBlocked(normalizedTargetDomain)) {
+ return {
+ success: false,
+ error: `Blocked node: ${normalizedTargetDomain}`,
+ };
+ }
+
const baseUrl = targetDomain.startsWith('http')
? targetDomain
- : targetDomain.startsWith('localhost') || targetDomain.startsWith('127.0.0.1')
- ? `http://${targetDomain}`
- : `https://${targetDomain}`;
+ : normalizedTargetDomain.startsWith('localhost') || normalizedTargetDomain.startsWith('127.0.0.1')
+ ? `http://${normalizedTargetDomain}`
+ : `https://${normalizedTargetDomain}`;
const url = `${baseUrl}${endpoint}`;
@@ -334,17 +352,31 @@ export interface SwarmUserProfile {
export interface SwarmUserPost {
id: string;
+ originalPostId?: string;
content: string;
createdAt: string;
isNsfw: boolean;
likesCount: number;
repostsCount: number;
repliesCount: number;
+ nodeDomain?: string;
+ author?: {
+ handle: string;
+ displayName?: string;
+ avatarUrl?: string;
+ isBot?: boolean;
+ nodeDomain?: string;
+ };
media?: { url: string; mimeType?: string; altText?: string }[];
linkPreviewUrl?: string;
linkPreviewTitle?: string;
linkPreviewDescription?: string;
linkPreviewImage?: string;
+ linkPreviewType?: 'card' | 'image' | 'gallery' | 'video';
+ linkPreviewVideoUrl?: string;
+ linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }>;
+ repostOfId?: string;
+ repostOf?: SwarmUserPost | null;
}
export interface SwarmProfileResponse {
@@ -363,11 +395,16 @@ export async function fetchSwarmUserProfile(
postsLimit: number = 25
): Promise {
try {
+ const normalizedDomain = normalizeNodeDomain(domain);
+ if (await isNodeBlocked(normalizedDomain)) {
+ return null;
+ }
+
const baseUrl = domain.startsWith('http')
? domain
- : domain.startsWith('localhost') || domain.startsWith('127.0.0.1')
- ? `http://${domain}`
- : `https://${domain}`;
+ : normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1')
+ ? `http://${normalizedDomain}`
+ : `https://${normalizedDomain}`;
const url = `${baseUrl}/api/swarm/users/${handle}?limit=${postsLimit}`;
@@ -449,6 +486,9 @@ export async function cacheSwarmUserPosts(
linkPreviewTitle: post.linkPreviewTitle || null,
linkPreviewDescription: post.linkPreviewDescription || null,
linkPreviewImage: post.linkPreviewImage || null,
+ linkPreviewType: post.linkPreviewType || null,
+ linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
+ linkPreviewMediaJson: serializeLinkPreviewMedia(post.linkPreviewMedia),
mediaJson: post.media ? JSON.stringify(post.media) : null,
});
@@ -470,11 +510,16 @@ export async function fetchSwarmPost(
domain: string
): Promise {
try {
+ const normalizedDomain = normalizeNodeDomain(domain);
+ if (await isNodeBlocked(normalizedDomain)) {
+ return null;
+ }
+
const baseUrl = domain.startsWith('http')
? domain
- : domain.startsWith('localhost') || domain.startsWith('127.0.0.1')
- ? `http://${domain}`
- : `https://${domain}`;
+ : normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1')
+ ? `http://${normalizedDomain}`
+ : `https://${normalizedDomain}`;
const url = `${baseUrl}/api/swarm/posts/${postId}`;
@@ -591,6 +636,9 @@ export interface SwarmPostDeliveryPayload {
linkPreviewTitle?: string;
linkPreviewDescription?: string;
linkPreviewImage?: string;
+ linkPreviewType?: 'card' | 'image' | 'gallery' | 'video';
+ linkPreviewVideoUrl?: string;
+ linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }>;
};
author: {
handle: string;
@@ -637,7 +685,7 @@ export async function getSwarmFollowerDomains(userId: string): Promise
return match ? match[1] : null;
}).filter((d): d is string => d !== null);
- return [...new Set(domains)];
+ return await filterBlockedDomains([...new Set(domains)]);
} catch (error) {
console.error('[Swarm] Error getting swarm follower domains:', error);
return [];
@@ -660,6 +708,9 @@ export async function deliverPostToSwarmFollowers(
linkPreviewTitle?: string | null;
linkPreviewDescription?: string | null;
linkPreviewImage?: string | null;
+ linkPreviewType?: string | null;
+ linkPreviewVideoUrl?: string | null;
+ linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }> | null;
},
author: {
handle: string;
@@ -693,6 +744,9 @@ export async function deliverPostToSwarmFollowers(
linkPreviewTitle: post.linkPreviewTitle || undefined,
linkPreviewDescription: post.linkPreviewDescription || undefined,
linkPreviewImage: post.linkPreviewImage || undefined,
+ linkPreviewType: (post.linkPreviewType as SwarmPostDeliveryPayload['post']['linkPreviewType']) || undefined,
+ linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined,
+ linkPreviewMedia: post.linkPreviewMedia || undefined,
},
author: {
handle: author.handle,
diff --git a/src/lib/swarm/node-blocklist.ts b/src/lib/swarm/node-blocklist.ts
new file mode 100644
index 0000000..20183ff
--- /dev/null
+++ b/src/lib/swarm/node-blocklist.ts
@@ -0,0 +1,124 @@
+import { db, swarmNodes } from '@/db';
+import { and, eq, inArray } from 'drizzle-orm';
+
+export function normalizeNodeDomain(domain: string): string {
+ return domain
+ .trim()
+ .toLowerCase()
+ .replace(/^https?:\/\//, '')
+ .replace(/\/.*$/, '')
+ .replace(/^@/, '');
+}
+
+export async function isNodeBlocked(domain: string | null | undefined): Promise {
+ if (!db || !domain) return false;
+
+ const normalized = normalizeNodeDomain(domain);
+ if (!normalized) return false;
+
+ const node = await db.query.swarmNodes.findFirst({
+ where: eq(swarmNodes.domain, normalized),
+ columns: {
+ isBlocked: true,
+ },
+ });
+
+ return Boolean(node?.isBlocked);
+}
+
+export async function getBlockedNodeDomains(): Promise> {
+ if (!db) return new Set();
+
+ const rows = await db.query.swarmNodes.findMany({
+ where: eq(swarmNodes.isBlocked, true),
+ columns: {
+ domain: true,
+ },
+ });
+
+ return new Set(rows.map((row) => row.domain));
+}
+
+export async function filterBlockedDomains(domains: string[]): Promise {
+ if (!db || domains.length === 0) return domains;
+
+ const normalized = Array.from(new Set(domains.map(normalizeNodeDomain).filter(Boolean)));
+ if (normalized.length === 0) return [];
+
+ const blocked = await db.query.swarmNodes.findMany({
+ where: and(
+ inArray(swarmNodes.domain, normalized),
+ eq(swarmNodes.isBlocked, true),
+ ),
+ columns: {
+ domain: true,
+ },
+ });
+
+ const blockedSet = new Set(blocked.map((row) => row.domain));
+ return normalized.filter((domain) => !blockedSet.has(domain));
+}
+
+export async function upsertBlockedNode(domain: string, reason?: string | null) {
+ if (!db) return null;
+
+ const normalized = normalizeNodeDomain(domain);
+ if (!normalized) return null;
+
+ const existing = await db.query.swarmNodes.findFirst({
+ where: eq(swarmNodes.domain, normalized),
+ });
+
+ if (existing) {
+ const [updated] = await db.update(swarmNodes)
+ .set({
+ isBlocked: true,
+ blockReason: reason || null,
+ blockedAt: new Date(),
+ isActive: false,
+ updatedAt: new Date(),
+ })
+ .where(eq(swarmNodes.id, existing.id))
+ .returning();
+
+ return updated;
+ }
+
+ const [created] = await db.insert(swarmNodes)
+ .values({
+ domain: normalized,
+ isBlocked: true,
+ blockReason: reason || null,
+ blockedAt: new Date(),
+ isActive: false,
+ trustScore: 0,
+ })
+ .returning();
+
+ return created;
+}
+
+export async function unblockNode(domain: string) {
+ if (!db) return null;
+
+ const normalized = normalizeNodeDomain(domain);
+ if (!normalized) return null;
+
+ const existing = await db.query.swarmNodes.findFirst({
+ where: eq(swarmNodes.domain, normalized),
+ });
+
+ if (!existing) return null;
+
+ const [updated] = await db.update(swarmNodes)
+ .set({
+ isBlocked: false,
+ blockReason: null,
+ blockedAt: null,
+ updatedAt: new Date(),
+ })
+ .where(eq(swarmNodes.id, existing.id))
+ .returning();
+
+ return updated;
+}
diff --git a/src/lib/swarm/registry.ts b/src/lib/swarm/registry.ts
index 5416321..1b93fd7 100644
--- a/src/lib/swarm/registry.ts
+++ b/src/lib/swarm/registry.ts
@@ -8,6 +8,7 @@ import { db, swarmNodes, swarmSeeds, swarmSyncLog } from '@/db';
import { eq, desc, and, gt, lt, sql } from 'drizzle-orm';
import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types';
import { SWARM_CONFIG, DEFAULT_SEED_NODES } from './types';
+import { normalizeNodeDomain } from './node-blocklist';
/**
* Get or create a swarm node entry
@@ -21,14 +22,16 @@ export async function upsertSwarmNode(
}
const existing = await db.query.swarmNodes.findFirst({
- where: eq(swarmNodes.domain, node.domain),
+ where: eq(swarmNodes.domain, normalizeNodeDomain(node.domain)),
});
+ const normalizedDomain = normalizeNodeDomain(node.domain);
+
const capabilities = node.capabilities ? JSON.stringify(node.capabilities) : null;
if (!existing) {
await db.insert(swarmNodes).values({
- domain: node.domain,
+ domain: normalizedDomain,
name: node.name,
description: node.description,
logoUrl: node.logoUrl,
@@ -58,10 +61,10 @@ export async function upsertSwarmNode(
capabilities: capabilities ?? existing.capabilities,
lastSeenAt: new Date(),
consecutiveFailures: 0,
- isActive: true,
+ isActive: existing.isBlocked ? false : true,
updatedAt: new Date(),
})
- .where(eq(swarmNodes.domain, node.domain));
+ .where(eq(swarmNodes.domain, normalizedDomain));
return { isNew: false };
}
@@ -83,8 +86,10 @@ export async function upsertSwarmNodes(
// Filter out our own domain
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
const filteredNodes = nodes.filter(n => n.domain !== ourDomain);
+ const normalizedOurDomain = ourDomain ? normalizeNodeDomain(ourDomain) : null;
+ const safeNodes = filteredNodes.filter(n => normalizeNodeDomain(n.domain) !== normalizedOurDomain);
- for (const node of filteredNodes) {
+ for (const node of safeNodes) {
const result = await upsertSwarmNode(node, discoveredVia);
if (result.isNew) {
added++;
@@ -105,7 +110,10 @@ export async function getActiveSwarmNodes(limit = 100): Promise
}
const nodes = await db.query.swarmNodes.findMany({
- where: eq(swarmNodes.isActive, true),
+ where: and(
+ eq(swarmNodes.isActive, true),
+ eq(swarmNodes.isBlocked, false),
+ ),
orderBy: [desc(swarmNodes.lastSeenAt)],
limit,
});
@@ -125,6 +133,7 @@ export async function getNodesForGossip(count: number): Promise
const nodes = await db.query.swarmNodes.findMany({
where: and(
eq(swarmNodes.isActive, true),
+ eq(swarmNodes.isBlocked, false),
gt(swarmNodes.trustScore, 20)
),
orderBy: sql`RANDOM()`,
@@ -143,7 +152,10 @@ export async function getNodesSince(since: Date, limit = 100): Promise {
.set({
consecutiveFailures: newFailures,
trustScore: newTrust,
- isActive,
+ isActive: node.isBlocked ? false : isActive,
updatedAt: new Date(),
})
.where(eq(swarmNodes.domain, domain));
@@ -211,7 +223,7 @@ export async function markNodeSuccess(domain: string): Promise {
.set({
consecutiveFailures: 0,
trustScore: newTrust,
- isActive: true,
+ isActive: node.isBlocked ? false : true,
lastSeenAt: new Date(),
lastSyncAt: new Date(),
updatedAt: new Date(),
diff --git a/src/lib/swarm/remote-profile-posts.ts b/src/lib/swarm/remote-profile-posts.ts
new file mode 100644
index 0000000..cb8ad96
--- /dev/null
+++ b/src/lib/swarm/remote-profile-posts.ts
@@ -0,0 +1,62 @@
+export const parseRemoteHandle = (handle: string) => {
+ const clean = handle.toLowerCase().replace(/^@/, '');
+ const parts = clean.split('@').filter(Boolean);
+ if (parts.length === 2) {
+ return { handle: parts[0], domain: parts[1] };
+ }
+ return null;
+};
+
+export const getRemoteBaseUrl = (domain: string) =>
+ domain.startsWith('http')
+ ? domain
+ : domain.startsWith('localhost') || domain.startsWith('127.0.0.1')
+ ? `http://${domain}`
+ : `https://${domain}`;
+
+type RemoteProfilePost = {
+ id: string;
+ originalPostId?: string;
+ author?: {
+ id?: string;
+ handle: string;
+ displayName?: string | null;
+ avatarUrl?: string | null;
+ isBot?: boolean;
+ };
+ nodeDomain?: string | null;
+ isSwarm?: boolean;
+ repostOf?: RemoteProfilePost | null;
+ replyTo?: RemoteProfilePost | null;
+ media?: Array<{ id?: string; url: string; altText?: string | null; mimeType?: string | null }>;
+ [key: string]: unknown;
+};
+
+export function mapRemoteProfilePost(post: RemoteProfilePost, remoteDomain: string): RemoteProfilePost {
+ const isAlreadySwarm = post.id.startsWith('swarm:');
+ const rawOriginalId = post.originalPostId || (isAlreadySwarm ? post.id.split(':').pop() || post.id : post.id);
+ const effectiveDomain = post.nodeDomain || remoteDomain;
+
+ return {
+ ...post,
+ id: isAlreadySwarm ? post.id : `swarm:${effectiveDomain}:${rawOriginalId}`,
+ originalPostId: rawOriginalId,
+ isSwarm: true,
+ nodeDomain: effectiveDomain,
+ author: post.author ? {
+ ...post.author,
+ id: post.author.id?.startsWith('swarm:')
+ ? post.author.id
+ : `swarm:${effectiveDomain}:${post.author.handle.includes('@') ? post.author.handle : post.author.handle}`,
+ handle: post.author.handle.includes('@')
+ ? post.author.handle
+ : `${post.author.handle}@${effectiveDomain}`,
+ } : post.author,
+ media: post.media?.map((item, index) => ({
+ ...item,
+ id: item.id || `swarm:${effectiveDomain}:${rawOriginalId}:media:${index}`,
+ })),
+ repostOf: post.repostOf ? mapRemoteProfilePost(post.repostOf, remoteDomain) : post.repostOf,
+ replyTo: post.replyTo ? mapRemoteProfilePost(post.replyTo, remoteDomain) : post.replyTo,
+ };
+}
diff --git a/src/lib/swarm/reposts.ts b/src/lib/swarm/reposts.ts
new file mode 100644
index 0000000..837d56f
--- /dev/null
+++ b/src/lib/swarm/reposts.ts
@@ -0,0 +1,40 @@
+export interface SwarmRepostTarget {
+ id: string;
+ nodeDomain: string;
+ originalPostId: string;
+}
+
+export async function getViewerSwarmRepostedPostIds(
+ targets: SwarmRepostTarget[],
+ viewerId: string
+): Promise> {
+ const repostedIds = new Set();
+
+ if (!targets.length || !viewerId) {
+ return repostedIds;
+ }
+
+ const { db, userSwarmReposts } = await import('@/db');
+ const { and, eq, inArray } = await import('drizzle-orm');
+
+ const domains = Array.from(new Set(targets.map((target) => target.nodeDomain)));
+ const originalPostIds = Array.from(new Set(targets.map((target) => target.originalPostId)));
+
+ const rows = await db.query.userSwarmReposts.findMany({
+ where: and(
+ eq(userSwarmReposts.userId, viewerId),
+ inArray(userSwarmReposts.nodeDomain, domains),
+ inArray(userSwarmReposts.originalPostId, originalPostIds),
+ ),
+ });
+
+ const rowKeys = new Set(rows.map((row) => `${row.nodeDomain}:${row.originalPostId}`));
+
+ for (const target of targets) {
+ if (rowKeys.has(`${target.nodeDomain}:${target.originalPostId}`)) {
+ repostedIds.add(target.id);
+ }
+ }
+
+ return repostedIds;
+}
diff --git a/src/lib/swarm/signature.ts b/src/lib/swarm/signature.ts
index 5cb8cb3..0a218c4 100644
--- a/src/lib/swarm/signature.ts
+++ b/src/lib/swarm/signature.ts
@@ -10,6 +10,7 @@ import crypto from 'crypto';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { canonicalize } from '@/lib/crypto/user-signing';
+import { isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
/**
* Sign a payload with the node's private key
@@ -56,14 +57,21 @@ export function verifySignature(payload: any, signature: string, publicKey: stri
*/
export async function getNodePublicKey(domain: string): Promise {
try {
+ const normalizedDomain = normalizeNodeDomain(domain);
+ if (await isNodeBlocked(normalizedDomain)) {
+ console.warn(`[Signature] Refusing public key fetch for blocked node ${normalizedDomain}`);
+ return null;
+ }
+
// Check if we have a cached node info
- const protocol = domain.includes('localhost') ? 'http' : 'https';
- const response = await fetch(`${protocol}://${domain}/api/node`, {
+ const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https';
+ const response = await fetch(`${protocol}://${normalizedDomain}/api/node`, {
headers: { 'Accept': 'application/json' },
+ signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
- console.error(`[Signature] Failed to fetch node info from ${domain}: ${response.status}`);
+ console.error(`[Signature] Failed to fetch node info from ${normalizedDomain}: ${response.status}`);
return null;
}
@@ -88,8 +96,14 @@ export async function verifySwarmRequest(
signature: string,
senderDomain: string
): Promise {
+ const normalizedDomain = normalizeNodeDomain(senderDomain);
+ if (await isNodeBlocked(normalizedDomain)) {
+ console.warn(`[Signature] Rejected blocked node ${normalizedDomain}`);
+ return false;
+ }
+
// Get the sender node's public key
- const publicKey = await getNodePublicKey(senderDomain);
+ const publicKey = await getNodePublicKey(normalizedDomain);
if (!publicKey) {
console.error(`[Signature] Could not get public key for ${senderDomain}`);
@@ -118,8 +132,14 @@ export async function verifyUserInteraction(
userDomain: string
): Promise {
try {
+ const normalizedDomain = normalizeNodeDomain(userDomain);
+ if (await isNodeBlocked(normalizedDomain)) {
+ console.warn(`[Signature] Rejected user interaction from blocked node ${normalizedDomain}`);
+ return false;
+ }
+
// Try to get cached user
- const fullHandle = `${userHandle}@${userDomain}`;
+ const fullHandle = `${userHandle}@${normalizedDomain}`;
let user = await db?.query.users.findFirst({
where: eq(users.handle, fullHandle),
});
@@ -130,11 +150,14 @@ export async function verifyUserInteraction(
publicKey = user.publicKey;
} else {
// Fetch from remote node
- const protocol = userDomain.includes('localhost') ? 'http' : 'https';
- const response = await fetch(`${protocol}://${userDomain}/api/users/${userHandle}`);
+ const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https';
+ const response = await fetch(`${protocol}://${normalizedDomain}/api/users/${userHandle}`, {
+ headers: { 'Accept': 'application/json' },
+ signal: AbortSignal.timeout(5000),
+ });
if (!response.ok) {
- console.error(`[Signature] Failed to fetch user ${userHandle}@${userDomain}: ${response.status}`);
+ console.error(`[Signature] Failed to fetch user ${userHandle}@${normalizedDomain}: ${response.status}`);
return false;
}
@@ -144,7 +167,7 @@ export async function verifyUserInteraction(
// Cache the user if we don't have them
if (!user && publicKey && db) {
await db.insert(users).values({
- did: userData.user?.did || `did:swarm:${userDomain}:${userHandle}`,
+ did: userData.user?.did || `did:swarm:${normalizedDomain}:${userHandle}`,
handle: fullHandle,
displayName: userData.user?.displayName || userHandle,
avatarUrl: userData.user?.avatarUrl,
diff --git a/src/lib/swarm/timeline.ts b/src/lib/swarm/timeline.ts
index 293943c..273071a 100644
--- a/src/lib/swarm/timeline.ts
+++ b/src/lib/swarm/timeline.ts
@@ -6,6 +6,8 @@
import { getActiveSwarmNodes } from './registry';
import type { SwarmPost } from '@/app/api/swarm/timeline/route';
+import { filterBlockedDomains, isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
+import type { LinkPreviewData } from '@/lib/media/linkPreview';
interface TimelineResult {
posts: SwarmPost[];
@@ -41,12 +43,7 @@ function extractFirstUrl(content: string): string | null {
/**
* Fetch link preview for a URL
*/
-async function fetchLinkPreview(url: string): Promise<{
- url: string;
- title: string | null;
- description: string | null;
- image: string | null;
-} | null> {
+async function fetchLinkPreview(url: string): Promise {
try {
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
const protocol = nodeDomain === 'localhost' ? 'http' : 'https';
@@ -70,6 +67,9 @@ async function fetchLinkPreview(url: string): Promise<{
title: data.title || null,
description: data.description || null,
image: data.image || null,
+ type: data.type || null,
+ videoUrl: data.videoUrl || null,
+ media: data.media || null,
};
} catch {
return null;
@@ -101,6 +101,9 @@ async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise
linkPreviewTitle: preview.title || undefined,
linkPreviewDescription: preview.description || undefined,
linkPreviewImage: preview.image || undefined,
+ linkPreviewType: preview.type || undefined,
+ linkPreviewVideoUrl: preview.videoUrl || undefined,
+ linkPreviewMedia: preview.media || undefined,
};
});
@@ -116,14 +119,19 @@ async function fetchNodeTimeline(
cursor?: string
): Promise<{ posts: SwarmPost[]; nodeIsNsfw?: boolean; error?: string }> {
try {
+ const normalizedDomain = normalizeNodeDomain(domain);
+ if (await isNodeBlocked(normalizedDomain)) {
+ return { posts: [], error: 'Blocked node' };
+ }
+
// Determine protocol - use http for localhost, https for everything else
let baseUrl: string;
if (domain.startsWith('http')) {
baseUrl = domain;
- } else if (domain.startsWith('localhost') || domain.startsWith('127.0.0.1')) {
- baseUrl = `http://${domain}`;
+ } else if (normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1')) {
+ baseUrl = `http://${normalizedDomain}`;
} else {
- baseUrl = `https://${domain}`;
+ baseUrl = `https://${normalizedDomain}`;
}
const url = `${baseUrl}/api/swarm/timeline?limit=${limit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
@@ -166,13 +174,14 @@ export async function fetchSwarmTimeline(
const nodes = await getActiveSwarmNodes(maxNodes);
// Always include our own posts
- const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
+ const ourDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost');
// Always query all nodes - we filter posts, not nodes
- const nodesToQuery = [
+ const candidateDomains = [
ourDomain,
...nodes.map(n => n.domain).filter(d => d !== ourDomain)
- ].slice(0, maxNodes);
+ ];
+ const nodesToQuery = (await filterBlockedDomains(candidateDomains)).slice(0, maxNodes);
console.log(`[Swarm Timeline] Querying ${nodesToQuery.length} nodes: ${nodesToQuery.join(', ')}`);
console.log(`[Swarm Timeline] includeNsfw: ${includeNsfw}, cursor: ${cursor || 'none'}`);
diff --git a/src/lib/types.ts b/src/lib/types.ts
index f859f67..0549ef3 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -40,6 +40,13 @@ export interface Attachment {
altText?: string | null;
}
+export interface LinkPreviewMediaItem {
+ url: string;
+ width?: number | null;
+ height?: number | null;
+ mimeType?: string | null;
+}
+
export interface Post {
id: string;
content: string;
@@ -53,8 +60,13 @@ export interface Post {
linkPreviewTitle?: string | null;
linkPreviewDescription?: string | null;
linkPreviewImage?: string | null;
+ linkPreviewType?: 'card' | 'image' | 'gallery' | 'video' | null;
+ linkPreviewVideoUrl?: string | null;
+ linkPreviewMedia?: LinkPreviewMediaItem[] | null;
replyTo?: Post | null;
replyToId?: string | null;
+ repostOf?: Post | null;
+ repostOfId?: string | null;
// Swarm reply info (when replying to a post on another node)
swarmReplyToId?: string | null;
swarmReplyToContent?: string | null;