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<string, unknown>; + 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<SQL | undefined>) => { 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<typeof users.$inferSelect, 'id' | 'handle' | 'displayName' | 'avatarUrl' | 'isBot'> +): 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<string>(); + + 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<string>, + repostedIds: Set<string> +): 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<string, unknown>; + 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<string>(); let blockedIds = new Set<string>(); @@ -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 = <T>(promise: Promise<T>, ms: number): Promise<T | null> => { @@ -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<string>(); + + 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<string>, + repostedIds: Set<string> +): 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<string>(); + const repostedPostIds = viewer + ? await getViewerSwarmRepostedPostIds( + allTimelinePosts.map(post => ({ + id: `swarm:${post.nodeDomain}:${post.id}`, + nodeDomain: post.nodeDomain, + originalPostId: post.id, + })), + viewer.id + ) + : new Set<string>(); 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<string, Array<{ url: string; mimeType?: string; altText?: string }>>, + repostById: Map<string, SwarmPost>, + 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<string, Array<{ url: string; mimeType?: string; altText?: string }>>(); + 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<string, SwarmPost>(); + 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<string>(); + 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<string>(); 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<typeof users.$inferSelect, 'id' | 'handle' | 'displayName' | 'avatarUrl' | 'isBot'> +): 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<string>(); + + 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<string>, + repostedIds: Set<string> +): 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<string>(); + const repostedPostIds = new Set<string>(); + + 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<string>(); + 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() { <option value="openai">OpenAI</option> <option value="anthropic">Anthropic</option> <option value="openrouter">OpenRouter</option> + <option value="custom">Custom (OpenAI Compatible)</option> </select> </div> + {formData.llmProvider === 'custom' && ( + <div> + <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> + OpenAI-Compatible Endpoint + </label> + <input + type="url" + value={formData.llmEndpoint} + onChange={(e) => setFormData({ ...formData, llmEndpoint: e.target.value })} + className="input" + placeholder="https://api.example.com/v1/chat/completions" + required + /> + <p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}> + Enter the full chat completions endpoint for a public OpenAI-compatible API. + </p> + </div> + )} + <div> <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> Model @@ -850,22 +873,23 @@ export default function EditBotPage() { </p> </div> - <div> - <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> - Posting Frequency - </label> - <select - value={formData.postingFrequency} - onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })} - className="input" - disabled={!formData.autonomousMode} - > - <option value="every_2_hours">Every 2 Hours</option> - <option value="every_4_hours">Every 4 Hours</option> - <option value="every_6_hours">Every 6 Hours</option> - <option value="daily">Once Daily</option> - </select> - </div> + {formData.autonomousMode && ( + <div> + <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> + Posting Frequency + </label> + <select + value={formData.postingFrequency} + onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })} + className="input" + > + <option value="every_2_hours">Every 2 Hours</option> + <option value="every_4_hours">Every 4 Hours</option> + <option value="every_6_hours">Every 6 Hours</option> + <option value="daily">Once Daily</option> + </select> + </div> + )} 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() { <option value="openai">OpenAI</option> <option value="anthropic">Anthropic</option> <option value="openrouter">OpenRouter</option> + <option value="custom">Custom (OpenAI Compatible)</option> </select> </div> + {formData.llmProvider === 'custom' && ( + <div> + <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> + OpenAI-Compatible Endpoint + </label> + <input + type="url" + value={formData.llmEndpoint} + onChange={(e) => setFormData({ ...formData, llmEndpoint: e.target.value })} + className="input" + placeholder="https://api.example.com/v1/chat/completions" + /> + <p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}> + Enter the full chat completions endpoint for a public OpenAI-compatible API. + </p> + </div> + )} + <div> <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> Model @@ -445,7 +467,7 @@ export default function NewBotPage() { placeholder="gpt-4" /> <p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}> - e.g., gpt-4, claude-3-opus, etc. + e.g., gpt-4, claude-3-opus, mistral-large, etc. </p> </div> @@ -782,22 +804,23 @@ export default function NewBotPage() { </p> </div> - <div> - <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> - Posting Frequency - </label> - <select - value={formData.postingFrequency} - onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })} - className="input" - disabled={!formData.autonomousMode} - > - <option value="every_2_hours">Every 2 Hours</option> - <option value="every_4_hours">Every 4 Hours</option> - <option value="every_6_hours">Every 6 Hours</option> - <option value="daily">Once Daily</option> - </select> - </div> + {formData.autonomousMode && ( + <div> + <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> + Posting Frequency + </label> + <select + value={formData.postingFrequency} + onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })} + className="input" + > + <option value="every_2_hours">Every 2 Hours</option> + <option value="every_4_hours">Every 4 Hours</option> + <option value="every_6_hours">Every 6 Hours</option> + <option value="daily">Once Daily</option> + </select> + </div> + )} @@ -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 = ( <div style={{ - minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', - padding: '24px', + padding: modal ? '0' : '24px', }}> - <div style={{ width: '100%', maxWidth: mode === 'register' ? '680px' : '400px' }}> + <div style={{ + width: '100%', + maxWidth: mode === 'register' ? '680px' : '400px', + }}> {/* Logo */} <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '32px', minHeight: '120px' }}> {nodeInfoLoaded && ( @@ -984,10 +1002,86 @@ export default function LoginPage() { </form> )} - <p style={{ textAlign: 'center', marginTop: '24px', color: 'var(--foreground-tertiary)', fontSize: '14px' }}> - <Link href="/">โ† Back to home</Link> - </p> + {!modal && ( + <p style={{ textAlign: 'center', marginTop: '24px', color: 'var(--foreground-tertiary)', fontSize: '14px' }}> + <Link href="/">โ† Back to home</Link> + </p> + )} </div> </div> ); + + if (modal) { + return ( + <div + style={{ + position: 'fixed', + inset: 0, + background: 'rgba(0, 0, 0, 0.72)', + backdropFilter: 'blur(10px)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '24px', + zIndex: 120, + }} + onClick={onClose} + > + <div + style={{ + position: 'relative', + width: 'min(760px, 100%)', + }} + onClick={(event) => event.stopPropagation()} + > + {onClose && ( + <button + onClick={onClose} + aria-label="Close" + style={{ + position: 'absolute', + top: '18px', + right: '18px', + width: '44px', + height: '44px', + borderRadius: '999px', + border: '1px solid var(--border)', + background: 'rgba(0, 0, 0, 0.78)', + color: 'var(--foreground)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + zIndex: 3, + }} + > + <X size={20} /> + </button> + )} + <div + className="card" + style={{ + maxHeight: 'calc(100vh - 48px)', + overflowY: 'auto', + padding: '28px', + background: 'rgba(10, 10, 10, 0.98)', + boxShadow: '0 30px 90px rgba(0, 0, 0, 0.55)', + }} + > + {content} + </div> + </div> + </div> + ); + } + + return ( + <div style={{ minHeight: '100vh' }}> + {content} + </div> + ); +} + +export default function LoginPage() { + return <AuthScreen />; } 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<boolean | null>(null); - const [tab, setTab] = useState<'reports' | 'posts' | 'users'>('reports'); + const [tab, setTab] = useState<'reports' | 'posts' | 'users' | 'nodes'>('reports'); const [reports, setReports] = useState<Report[]>([]); const [posts, setPosts] = useState<AdminPost[]>([]); const [users, setUsers] = useState<AdminUser[]>([]); + const [nodes, setNodes] = useState<AdminNode[]>([]); 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 </button> + <button + className={`btn btn-sm ${tab === 'nodes' ? 'btn-primary' : 'btn-ghost'}`} + onClick={() => setTab('nodes')} + > + Nodes + </button> </div> {tab === 'reports' && ( @@ -470,6 +518,114 @@ export default function ModerationPage() { )} </div> )} + {tab === 'nodes' && ( + <div style={{ padding: '16px' }}> + <div className="card" style={{ padding: '16px', marginBottom: '16px' }}> + <h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px' }}>Block node</h2> + <p style={{ color: 'var(--foreground-secondary)', marginBottom: '12px' }}> + Blocked nodes cannot deliver swarm interactions, appear in swarm feeds, or exchange chat with this node. + </p> + <div style={{ display: 'grid', gap: '12px' }}> + <input + value={nodeDomain} + onChange={(e) => setNodeDomain(e.target.value)} + placeholder="node.example.com" + className="input" + /> + <textarea + value={nodeReason} + onChange={(e) => setNodeReason(e.target.value)} + placeholder="Reason for blocking this node (optional)" + className="input" + style={{ minHeight: '88px', resize: 'vertical' }} + /> + <div> + <button + className="btn btn-primary btn-sm" + onClick={() => handleNodeAction('block', nodeDomain, nodeReason)} + disabled={!nodeDomain.trim()} + > + Block node + </button> + </div> + </div> + </div> + + {loading ? ( + <div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>Loading nodes...</div> + ) : nodes.length === 0 ? ( + <div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>No known nodes.</div> + ) : ( + <div style={{ display: 'grid', gap: '12px' }}> + {nodes.map((node) => ( + <div key={node.id} className="card" style={{ padding: '16px' }}> + <div style={{ display: 'flex', justifyContent: 'space-between', gap: '16px', alignItems: 'flex-start' }}> + <div style={{ flex: 1, minWidth: 0 }}> + <div style={{ display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '8px', flexWrap: 'wrap' }}> + <span style={{ + fontSize: '11px', + padding: '2px 8px', + borderRadius: '4px', + background: node.isBlocked ? 'rgba(239, 68, 68, 0.1)' : 'rgba(34, 197, 94, 0.1)', + color: node.isBlocked ? 'rgb(239, 68, 68)' : 'rgb(34, 197, 94)', + fontWeight: 600, + textTransform: 'uppercase', + }}> + {node.isBlocked ? 'blocked' : 'allowed'} + </span> + {!node.isBlocked && !node.isActive && ( + <span style={{ fontSize: '11px', color: 'var(--foreground-tertiary)' }}> + inactive + </span> + )} + {node.isNsfw && ( + <span style={{ fontSize: '11px', color: 'rgb(245, 158, 11)' }}> + NSFW + </span> + )} + </div> + <div style={{ fontWeight: 600, marginBottom: '4px' }}> + {node.name || node.domain} + </div> + <div style={{ color: 'var(--foreground-secondary)', marginBottom: '8px' }}> + {node.domain} + </div> + {node.description && ( + <div style={{ fontSize: '14px', color: 'var(--foreground-secondary)', marginBottom: '8px' }}> + {node.description} + </div> + )} + <div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}> + {node.blockedAt ? `Blocked ${formatDate(node.blockedAt)}` : node.lastSeenAt ? `Last seen ${formatDate(node.lastSeenAt)}` : 'Never seen'} + {typeof node.trustScore === 'number' && <span> โ€ข Trust {node.trustScore}</span>} + </div> + {node.blockReason && ( + <div style={{ fontSize: '13px', color: 'var(--foreground-secondary)', marginTop: '6px' }}> + Reason: {node.blockReason} + </div> + )} + </div> + <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}> + {node.isBlocked ? ( + <button className="btn btn-ghost btn-sm" onClick={() => handleNodeAction('unblock', node.domain)}> + Unblock + </button> + ) : ( + <button + className="btn btn-primary btn-sm" + onClick={() => handleNodeAction('block', node.domain, window.prompt('Reason (optional):') || '')} + > + Block + </button> + )} + </div> + </div> + </div> + ))} + </div> + )} + </div> + )} </> ); } 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 ( <div style={{ maxWidth: '600px', margin: '0 auto', minHeight: '100vh' }}> @@ -518,7 +540,8 @@ export default function ProfilePage() { {/* Banner */} <div style={{ - height: '150px', + width: '100%', + aspectRatio: '3 / 1', background: (isEditing ? profileForm.headerUrl : user.headerUrl) ? `url(${isEditing ? profileForm.headerUrl : user.headerUrl}) center/cover` : 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)', @@ -553,7 +576,7 @@ export default function ProfilePage() { </div> <div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}> - {!isOwnProfile && currentUser && ( + {!isOwnProfile && !isOwnedBotProfile && currentUser && ( <> {!isBlocked && ( <button @@ -713,18 +736,20 @@ export default function ProfilePage() { <strong>{user.followersCount}</strong>{' '} <span style={{ color: 'var(--foreground-tertiary)' }}>Followers</span> </button> - <button - onClick={() => setActiveTab('following')} - style={{ - background: 'none', - border: 'none', - color: 'var(--foreground)', - cursor: 'pointer', - }} - > - <strong>{user.followingCount}</strong>{' '} - <span style={{ color: 'var(--foreground-tertiary)' }}>Following</span> - </button> + {showFollowingUi && ( + <button + onClick={() => setActiveTab('following')} + style={{ + background: 'none', + border: 'none', + color: 'var(--foreground)', + cursor: 'pointer', + }} + > + <strong>{user.followingCount}</strong>{' '} + <span style={{ color: 'var(--foreground-tertiary)' }}>Following</span> + </button> + )} </div> </div> </div> @@ -807,10 +832,7 @@ export default function ProfilePage() { {/* Tabs */} <div style={{ display: 'flex', borderTop: '1px solid var(--border)' }}> - {(user?.isBot - ? ['posts', 'replies', 'followers', 'following'] as const - : ['posts', 'replies', 'likes', 'followers', 'following'] as const - ).map(tab => ( + {visibleTabs.map(tab => ( <button key={tab} onClick={() => 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 </button> <VideoEmbed url={linkPreview.url} /> - {!linkPreview.url.match(/(youtube\.com|youtu\.be|vimeo\.com)/) && ( + {!isEmbeddedVideo && ( <div className="link-preview-card mini"> - {linkPreview.image && ( + {linkPreview.type === 'video' && linkPreview.videoUrl ? ( <div className="link-preview-image"> - <img src={linkPreview.image} alt="" /> + <video + src={linkPreview.videoUrl} + poster={previewImage || undefined} + muted + playsInline + preload="metadata" + /> + </div> + ) : linkPreview.type === 'gallery' && previewMedia.length > 0 ? ( + <div className="link-preview-gallery compact"> + {previewMedia.slice(0, 3).map((item: { url: string }, index: number) => ( + <div className="link-preview-gallery-item" key={`${item.url}-${index}`}> + <img src={item.url} alt="" /> + {index === Math.min(previewMedia.length, 3) - 1 && previewMedia.length > 3 && ( + <span className="link-preview-gallery-more">+{previewMedia.length - 3}</span> + )} + </div> + ))} + </div> + ) : previewImage && ( + <div className="link-preview-image"> + <img src={previewImage} alt="" /> </div> )} <div className="link-preview-info"> 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 ( + <div className={`link-preview-gallery ${compact ? 'compact' : ''}`}> + {visibleMedia.map((item, index) => ( + <div className="link-preview-gallery-item" key={`${item.url}-${index}`}> + <img src={item.url} alt={alt} loading="lazy" /> + {index === visibleMedia.length - 1 && media.length > visibleMedia.length && ( + <span className="link-preview-gallery-more">+{media.length - visibleMedia.length}</span> + )} + </div> + ))} + </div> + ); +} + 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<LinkPreviewData | null>(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 ( + <a + href={effectivePreview.url} + target="_blank" + rel="noopener noreferrer" + className={`link-preview-card ${compact ? 'mini' : ''}`} + onClick={(e) => e.stopPropagation()} + > + {isRichVideoPreview && effectivePreview.videoUrl ? ( + <div className="link-preview-video"> + <video + src={effectivePreview.videoUrl} + poster={previewImage || undefined} + controls + playsInline + preload="metadata" + /> + </div> + ) : isGalleryPreview ? ( + <LinkPreviewGallery + media={previewMedia} + alt={effectivePreview.title || 'Link preview'} + compact={compact} + /> + ) : previewImage ? ( + <LinkPreviewImage src={previewImage} alt={effectivePreview.title || ''} /> + ) : null} + <div className="link-preview-info"> + <div className="link-preview-title">{effectivePreview.title ? decodeHtmlEntities(effectivePreview.title) : ''}</div> + {effectivePreview.description && ( + <div className="link-preview-description">{decodeHtmlEntities(effectivePreview.description)}</div> + )} + <div className="link-preview-url"> + {new URL(effectivePreview.url.startsWith('http') ? effectivePreview.url : `https://${effectivePreview.url}`).hostname} + </div> + </div> + </a> + ); + }; // If this is a thread parent being rendered, just render the article if (isThreadParent) { return ( - <article className="post thread-parent"> + <article className={`post thread-parent ${isEmbedded ? 'embedded' : ''}`}> <div className="post-header"> <Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}> <div className="avatar"> @@ -458,6 +657,46 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, ); } + if (isRepostEvent && post.repostOf) { + return ( + <> + <article className={`post repost-event ${isDetail ? 'detail' : ''} ${isEmbedded ? 'embedded' : ''}`}> + <div className="repost-event-header"> + <span className="repost-event-icon" aria-hidden="true"> + <RepeatIcon /> + </span> + <span className="repost-event-text"> + <Link href={`/u/${profileHandle}`} onClick={(e) => e.stopPropagation()}> + {post.author.displayName || post.author.handle} + </Link> + <span className="repost-event-copy"> reposted</span> + <span className="post-time"> {repostHandle} ยท {formatTime(post.createdAt)}</span> + </span> + </div> + + {hasOwnContent && ( + <div className="post-content">{renderContent(post.content, post.linkPreviewUrl ?? undefined)}</div> + )} + + <div className="repost-embed"> + <PostCard + post={post.repostOf} + onLike={onLike} + onRepost={onRepost} + onComment={onComment} + onDelete={onDelete} + onHide={onHide} + isDetail={isDetail} + showThread={false} + isEmbedded={true} + parentPostAuthorId={parentPostAuthorId} + /> + </div> + </article> + </> + ); + } + 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, /> </div> )} - <article className={`post ${isDetail ? 'detail' : ''}`}> + <article className={`post ${isDetail ? 'detail' : ''} ${isEmbedded ? 'embedded' : ''}`}> {!isDetail && <Link href={postUrl} className="post-link-overlay" aria-label="View post" />} <div className="post-header"> @@ -515,7 +754,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, </div> <span className="post-time">{authorHandle} ยท {formatTime(post.createdAt)}</span> </div> - {currentUser && currentUser.id !== post.author.id && ( + {currentUser && !isOwnOrOwnedBotPost && ( <div style={{ position: 'relative', marginLeft: 'auto' }}> <button className="post-menu-btn" @@ -671,28 +910,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, <VideoEmbed url={post.linkPreviewUrl} /> )} - {post.linkPreviewUrl && !post.linkPreviewUrl.match(/(youtube\.com|youtu\.be|vimeo\.com)/) && ( - <a - href={post.linkPreviewUrl} - target="_blank" - rel="noopener noreferrer" - className="link-preview-card" - onClick={(e) => e.stopPropagation()} - > - {post.linkPreviewImage && ( - <LinkPreviewImage src={post.linkPreviewImage} alt={post.linkPreviewTitle || ''} /> - )} - <div className="link-preview-info"> - <div className="link-preview-title">{post.linkPreviewTitle ? decodeHtmlEntities(post.linkPreviewTitle) : ''}</div> - {post.linkPreviewDescription && ( - <div className="link-preview-description">{decodeHtmlEntities(post.linkPreviewDescription)}</div> - )} - <div className="link-preview-url"> - {new URL(post.linkPreviewUrl.startsWith('http') ? post.linkPreviewUrl : `https://${post.linkPreviewUrl}`).hostname} - </div> - </div> - </a> - )} + {renderLinkPreviewCard()} <div className="post-actions"> <button className="post-action" onClick={handleComment}> @@ -707,20 +925,13 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, <HeartIcon filled={liked} /> <span>{likesCount || ''}</span> </button> - <button className="post-action" onClick={handleReport} disabled={reporting}> - <FlagIcon /> - <span>{reporting ? '...' : ''}</span> - </button> - {(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 && ( + <button className="post-action" onClick={handleReport} disabled={reporting}> + <FlagIcon /> + <span>{reporting ? '...' : ''}</span> + </button> + )} + {canDeletePost && ( <button className="post-action delete-action" onClick={handleDelete} disabled={deleting} title="Delete post"> <TrashIcon /> <span>{deleting ? '...' : ''}</span> 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<string | null | undefined>(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<string | null>(null); + const [accountMenuOpen, setAccountMenuOpen] = useState(false); + const [showAuthModal, setShowAuthModal] = useState(false); + const accountMenuRef = useRef<HTMLDivElement | null>(null); + const accountTriggerRef = useRef<HTMLButtonElement | null>(null); + const accountPopupRef = useRef<HTMLDivElement | null>(null); + const [accountPopupStyle, setAccountPopupStyle] = useState<React.CSSProperties | null>(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 ( <aside className="sidebar"> <Link href={user ? "/" : "/explore"} className="logo" style={{ minHeight: '42px' }}> @@ -196,8 +293,130 @@ export function Sidebar() { )} </nav> {user && ( - <div style={{ marginTop: 'auto', paddingTop: '16px' }} className="sidebar-user-info"> - <div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0, marginBottom: '12px' }}> + <div + ref={accountMenuRef} + style={{ marginTop: 'auto', paddingTop: '16px', position: 'relative' }} + className="sidebar-user-info" + > + {accountMenuOpen && accountPopupStyle && typeof document !== 'undefined' && createPortal( + <div style={{ + ...accountPopupStyle, + }}> + <div ref={accountPopupRef}> + <div style={{ padding: '8px 0' }}> + {accounts.map((account) => { + const isActive = account.id === user.id; + const isSwitching = switchingAccountId === account.id; + + return ( + <button + key={account.id} + onClick={() => 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', + }} + > + <div className="avatar avatar-sm" style={{ flexShrink: 0 }}> + {account.avatarUrl ? ( + <img src={account.avatarUrl} alt={account.displayName || account.handle} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> + ) : ( + (account.displayName?.charAt(0) || account.handle.charAt(0)).toUpperCase() + )} + </div> + <div style={{ minWidth: 0, flex: 1, display: 'grid', gap: '2px' }}> + <div style={{ fontWeight: 700, fontSize: '15px', lineHeight: 1.2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> + {account.displayName || account.handle} + </div> + <div style={{ color: 'var(--foreground-secondary)', fontSize: '13px', lineHeight: 1.2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> + {shortHandle(account.handle)} + </div> + </div> + <div style={{ width: '24px', display: 'flex', justifyContent: 'center', color: 'var(--accent)', flexShrink: 0 }}> + {isActive ? <Check size={18} /> : isSwitching ? <span style={{ fontSize: '12px' }}>...</span> : null} + </div> + </button> + ); + })} + </div> + + <div style={{ borderTop: '1px solid var(--border)', padding: '6px 0' }}> + <button + onClick={() => { + 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', + }} + > + <Plus size={18} style={{ flexShrink: 0 }} /> + <span>Add an existing account</span> + </button> + + <button + onClick={handleLogout} + disabled={loggingOut} + 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', + }} + > + <LogOut size={18} style={{ flexShrink: 0 }} /> + <span>{loggingOut ? 'Signing out...' : `Sign out ${formattedHandle}`}</span> + </button> + </div> + </div> + </div>, + document.body + )} + + <button + ref={accountTriggerRef} + onClick={() => 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', + }} + > <div className="avatar avatar-sm" style={{ flexShrink: 0 }}> {user.avatarUrl ? ( <img src={user.avatarUrl} alt={user.displayName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> @@ -205,31 +424,30 @@ export function Sidebar() { (user.displayName?.charAt(0) || user.handle.charAt(0)).toUpperCase() )} </div> - <div style={{ minWidth: 0, flex: 1 }}> + <div style={{ minWidth: 0, flex: 1, textAlign: 'left' }}> <div style={{ fontWeight: 600, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.displayName}</div> <div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{formattedHandle}</div> </div> - </div> - - <button - onClick={handleLogout} - disabled={loggingOut} - className="btn btn-ghost" - style={{ - width: '100%', - justifyContent: 'flex-start', - gap: '12px', - padding: '10px 12px', - fontSize: '14px', - }} - > - <LogOut size={20} /> - <span>{loggingOut ? 'Signing out...' : 'Sign out'}</span> + <ChevronDown size={18} style={{ + transform: accountMenuOpen ? 'rotate(180deg)' : 'rotate(0deg)', + transition: 'transform 0.2s ease', + flexShrink: 0, + }} /> </button> </div> )} {/* Identity Unlock Prompt Modal is now handled in LayoutWrapper */} + {showAuthModal && ( + <AuthScreen + modal + onClose={() => setShowAuthModal(false)} + onSuccess={() => { + setShowAuthModal(false); + router.refresh(); + }} + /> + )} </aside> ); } 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<SessionRecord[]> { + 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<string> { + 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<string> { 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<string> { * 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<AuthAccount[]> { + 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<typeof users.$inferSelect> { /** * Destroy the current session */ -export async function destroySession(): Promise<void> { - const cookieStore = await cookies(); - const token = cookieStore.get(SESSION_COOKIE_NAME)?.value; +export async function destroySession(userId?: string): Promise<void> { + 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<typeof bots.$inferInsert> = { @@ -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<FeedItem[]> { - // 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<LLMProvider, RegExp> = { 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<LLMProvider, string> = { 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<LLMProvider, string> = { 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<string, unknown>, - 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<stri headers['X-Title'] = 'Synapsis Bot'; break; case 'openai': + case 'custom': headers['Authorization'] = `Bearer ${apiKey}`; break; case 'anthropic': @@ -386,6 +391,7 @@ export class LLMClient { private provider: LLMProvider; private apiKey: string; private model: string; + private endpoint: string | null; private retryConfig: RetryConfig; private timeoutMs: number; @@ -403,6 +409,7 @@ export class LLMClient { ) { this.provider = config.provider; this.model = config.model || DEFAULT_MODELS[config.provider]; + this.endpoint = config.endpoint || null; this.retryConfig = retryConfig; this.timeoutMs = timeoutMs; @@ -480,7 +487,16 @@ export class LLMClient { * @throws LLMClientError if the request fails */ private async makeRequest(request: LLMCompletionRequest): Promise<LLMCompletionResponse> { - 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<string, unknown>; // 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<MentionResponse personalityConfig: JSON.parse(bot.personalityConfig), llmProvider: bot.llmProvider as any, llmModel: bot.llmModel, + llmEndpoint: bot.llmEndpoint, llmApiKeyEncrypted: bot.llmApiKeyEncrypted, }; diff --git a/src/lib/bots/posting.ts b/src/lib/bots/posting.ts index 02e8dc5..b54598b 100644 --- a/src/lib/bots/posting.ts +++ b/src/lib/bots/posting.ts @@ -7,12 +7,15 @@ * Requirements: 5.4, 11.5, 11.6 */ -import { db, posts, users, bots, botContentItems, botActivityLogs } from '@/db'; +import { db, posts, users, bots, botContentItems, botActivityLogs, botContentSources } from '@/db'; import { eq, and, inArray } from 'drizzle-orm'; import { ContentGenerator, type Bot as ContentGeneratorBot, type ContentItem } from './contentGenerator'; import { canPost, recordPost } from './rateLimiter'; import { getBotById } from './botManager'; -import { decryptApiKey, deserializeEncryptedData } from './encryption'; +import { decryptApiKey, deserializeEncryptedData, type LLMProvider } from './encryption'; +import { fetchRedditRichPreview } from '@/lib/media/redditPreview'; +import type { LinkPreviewData } from '@/lib/media/linkPreview'; +import { fetchGenericLinkPreview } from '@/lib/media/genericPreview'; // ============================================ // TYPES @@ -139,8 +142,9 @@ function toContentGeneratorBot(bot: typeof bots.$inferSelect & { user: { handle: name: bot.name, handle: bot.user.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, }; } @@ -175,6 +179,7 @@ async function getContentItemById(contentItemId: string): Promise<ContentItem | return { id: item.id, sourceId: item.sourceId, + externalId: item.externalId, title: item.title, content: item.content, url: item.url, @@ -250,6 +255,7 @@ async function getNextUnprocessedContentItem(botId: string): Promise<ContentItem return { id: item.id, sourceId: item.sourceId, + externalId: item.externalId, title: item.title, content: item.content, url: item.url, @@ -630,62 +636,8 @@ function isRedditUrl(url: string): boolean { * Fetch link preview for Reddit URLs using their oEmbed API. * Reddit blocks regular scraping but provides oEmbed for embedding. */ -async function fetchRedditPreview(url: string): Promise<{ - url: string; - title: string | null; - description: string | null; - image: string | null; -} | null> { - 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<LinkPreviewData | null> { + 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<LinkPreviewData | null> { // 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(`<meta[^>]+(?:property|name)=["'](?:og:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i'); - const match = html.match(regex); - if (match) return match[1]; - - const regexRev = new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:)?${property}["']`, 'i'); - const matchRev = html.match(regexRev); - return matchRev ? matchRev[1] : null; - }; - - const title = getMeta('title') || html.match(/<title>([^<]+)<\/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<string | undefined> { + 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<typeof posts.$inferSelect> { // 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<ReturnType<typeof fetchLinkPreview>> = 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<void>; unlockIdentity: (password: string, explicitUser?: User) => Promise<void>; - login: (user: User) => void; - logout: () => Promise<void>; + login: (user?: User) => Promise<void>; + logout: (userId?: string) => Promise<void>; + switchAccount: (userId: string) => Promise<void>; + refreshAuth: () => Promise<void>; lockIdentity: () => Promise<void>; // New: manual lock signUserAction: (action: string, data: any) => Promise<any>; requiresUnlock: boolean; // True if user has encrypted key but not unlocked @@ -35,6 +43,8 @@ interface AuthContextType { const AuthContext = createContext<AuthContextType>({ user: null, + accounts: [], + activeAccountId: null, isAdmin: false, loading: true, isIdentityUnlocked: false, @@ -43,8 +53,10 @@ const AuthContext = createContext<AuthContextType>({ 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<AuthContextType>({ export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState<User | null>(null); + const [accounts, setAccounts] = useState<AuthAccount[]>([]); 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 ( <AuthContext.Provider value={{ user, + accounts, + activeAccountId, isAdmin, loading, isIdentityUnlocked: isUnlocked, @@ -197,6 +229,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { unlockIdentity, login, logout, + switchAccount, + refreshAuth, lockIdentity, signUserAction, requiresUnlock, diff --git a/src/lib/crypto/key-persistence.ts b/src/lib/crypto/key-persistence.ts index adcc7c2..2d0b278 100644 --- a/src/lib/crypto/key-persistence.ts +++ b/src/lib/crypto/key-persistence.ts @@ -17,8 +17,8 @@ import { deserializeEncryptedKey, type EncryptedPrivateKey } from './private-key const DB_NAME = 'synapsis-identity'; const DB_VERSION = 1; const STORE_NAME = 'keys'; -const SESSION_KEY_ITEM = 'synapsis_session_key'; -const WRAPPED_KEY_ITEM = 'synapsis_wrapped_key'; +const SESSION_KEY_PREFIX = 'synapsis_session_key:'; +const WRAPPED_KEY_PREFIX = 'synapsis_wrapped_key:'; interface WrappedKey { wrapped: string; // Base64 of wrapped key @@ -32,6 +32,14 @@ interface SessionData { createdAt: number; } +function getSessionKeyItem(identifier: string): string { + return `${SESSION_KEY_PREFIX}${identifier}`; +} + +function getWrappedKeyItem(identifier: string): string { + return `${WRAPPED_KEY_PREFIX}${identifier}`; +} + // ============================================ // IndexedDB Operations // ============================================ @@ -218,7 +226,8 @@ async function unwrapRawPrivateKey( */ export async function persistUnlockedKey( privateKeyBase64: string, - password: string + password: string, + identifier: string ): Promise<void> { 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<ArrayBuffer | null> { +export async function tryRestoreKey(identifier: string): Promise<ArrayBuffer | null> { 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<ArrayBuffer | null> { 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<WrappedKey>(WRAPPED_KEY_ITEM); + const wrapped = await getFromDB<WrappedKey>(getWrappedKeyItem(identifier)); if (!wrapped) { console.log('[KeyPersistence] No wrapped key found'); return null; @@ -293,10 +302,24 @@ export async function tryRestoreKey(): Promise<ArrayBuffer | null> { /** * Clear the persisted key (logout) */ -export async function clearPersistentKey(): Promise<void> { +export async function clearPersistentKey(identifier?: string): Promise<void> { 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<void> { /** * Check if a persisted key is available */ -export async function hasPersistentKey(): Promise<boolean> { - const sessionData = localStorage.getItem(SESSION_KEY_ITEM); +export async function hasPersistentKey(identifier: string): Promise<boolean> { + 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(`<meta[^>]+(?:property|name|itemprop)=["'](?:og:|twitter:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i'), + new RegExp(`<meta[^>]+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<LinkPreviewData | null> { + 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>([^<]+)<\/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<LinkPreviewData | null> { + 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<string, StorageSessionPayload>; + 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<StorageSessionMap> { + 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<void> { + 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<StorageSessionPayload | null> { - 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<void> { - const cookieStore = await cookies(); - cookieStore.delete(STORAGE_SESSION_COOKIE); +export async function clearStorageSession(userId?: string): Promise<void> { + 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<boolean> { + 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<SwarmNodeInfo | null> { + 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<SwarmInteractionResponse> { 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<SwarmProfileResponse | null> { 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<SwarmUserPost | null> { 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<string[]> 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<boolean> { + 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<Set<string>> { + 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<string[]> { + 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<SwarmNodeInfo[]> } 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<SwarmNodeInfo[]> 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<SwarmNode } const nodes = await db.query.swarmNodes.findMany({ - where: gt(swarmNodes.updatedAt, since), + where: and( + gt(swarmNodes.updatedAt, since), + eq(swarmNodes.isBlocked, false), + ), orderBy: [desc(swarmNodes.updatedAt)], limit, }); @@ -177,7 +189,7 @@ export async function markNodeFailure(domain: string): Promise<void> { .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<void> { .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<Set<string>> { + const repostedIds = new Set<string>(); + + 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<string | null> { 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<boolean> { + 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<boolean> { 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<LinkPreviewData | null> { 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<SwarmPost[]> 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;