Add docker publish flow, node blocklist & API updates
Replace docker/metadata GH Action with a docker-publish.sh driven workflow (supports auto-versioning, extra tags, multi-arch builds and optional builder). Update docs and READMEs to use the updater and new publish flow. Add server-side swarm/node blocklist support and admin nodes API. Improve auth endpoints (me, logout, switch, session accounts) and support account switching. Extend bots API to support custom LLM provider and optional endpoint. Enforce node blocklist on chat send/receive, refactor link preview handling into dedicated preview modules, and enhance notifications and posts like/repost handlers to accept both signed actions and regular auth flows. Misc: remove admin update UI details, add helper libs (media previews, node-blocklist, reposts, notifications, etc.), and minor housekeeping (pyc, workflow tweaks).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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:<YYYY.MM.DD.N>`
|
||||
- `ghcr.io/gnosyslabs/synapsis:<short-sha>`
|
||||
|
||||
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:
|
||||
|
||||
@@ -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:**
|
||||
|
||||
+24
-1
@@ -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:<YYYY.MM.DD.N>`
|
||||
- `ghcr.io/gnosyslabs/synapsis:<short-sha>`
|
||||
|
||||
---
|
||||
|
||||
For full documentation, visit [docs.synapsis.social](https://docs.synapsis.social)
|
||||
|
||||
Binary file not shown.
+75
-43
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<header style={{
|
||||
@@ -783,51 +766,6 @@ export default function AdminPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: '8px', marginTop: '16px', fontSize: '13px' }}>
|
||||
<div>
|
||||
<strong>Current build:</strong>{' '}
|
||||
{updateStatus?.current?.version || 'Unknown'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Latest build:</strong>{' '}
|
||||
{updateStatus?.latest?.version || 'Unavailable'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Status:</strong>{' '}
|
||||
{updateStatusLabel}
|
||||
</div>
|
||||
{updateStatus?.updater.lastStartedAt && (
|
||||
<div>
|
||||
<strong>Last started:</strong>{' '}
|
||||
{formatTimestamp(updateStatus.updater.lastStartedAt)}
|
||||
</div>
|
||||
)}
|
||||
{updateStatus?.updater.lastFinishedAt && (
|
||||
<div>
|
||||
<strong>Last finished:</strong>{' '}
|
||||
{formatTimestamp(updateStatus.updater.lastFinishedAt)}
|
||||
</div>
|
||||
)}
|
||||
{typeof updateStatus?.updater.lastExitCode === 'number' && (
|
||||
<div>
|
||||
<strong>Last exit code:</strong>{' '}
|
||||
{updateStatus.updater.lastExitCode}
|
||||
</div>
|
||||
)}
|
||||
{updateStatus?.updater.trigger && (
|
||||
<div>
|
||||
<strong>Last trigger:</strong>{' '}
|
||||
{updateStatus.updater.trigger === 'auto' ? 'Automatic update' : 'Manual update'}
|
||||
</div>
|
||||
)}
|
||||
{updateStatus?.updater.lastError && (
|
||||
<div style={{ color: 'var(--danger)' }}>
|
||||
<strong>Last error:</strong>{' '}
|
||||
{updateStatus.updater.lastError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!updateStatus?.updater.available && (
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, swarmNodes } from '@/db';
|
||||
import { desc } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { normalizeNodeDomain, unblockNode, upsertBlockedNode } from '@/lib/swarm/node-blocklist';
|
||||
|
||||
const mutateNodeSchema = z.object({
|
||||
action: z.enum(['block', 'unblock']),
|
||||
domain: z.string().min(1),
|
||||
reason: z.string().max(500).optional().nullable(),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const nodes = await db.query.swarmNodes.findMany({
|
||||
orderBy: [desc(swarmNodes.isBlocked), desc(swarmNodes.blockedAt), desc(swarmNodes.lastSeenAt)],
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
nodes: nodes.map((node) => ({
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: [] });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(`<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 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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
+293
-104
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) || [],
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
|
||||
+41
-18
@@ -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
|
||||
|
||||
+126
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+105
-11
@@ -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 />;
|
||||
}
|
||||
|
||||
+157
-1
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
+44
-22
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
+252
-41
@@ -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>
|
||||
|
||||
+244
-26
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+55
-1
@@ -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),
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
+174
-30
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
+67
-103
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+79
-28
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+21
-12
@@ -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'}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user