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
|
name: Build and Push Docker Image to GHCR
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Manual trigger only - you decide when to build
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
description: 'Image tag (default: latest)'
|
description: 'Optional explicit version tag (for example 2026.03.09.10). Leave blank to auto-increment.'
|
||||||
required: false
|
required: false
|
||||||
default: 'latest'
|
default: ''
|
||||||
# Also build on version tags
|
# Also build on version tags
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
@@ -42,26 +41,22 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
env:
|
||||||
with:
|
IMAGE_REPO: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
context: .
|
PLATFORMS: linux/amd64,linux/arm64
|
||||||
file: ./docker/Dockerfile
|
PRUNE_BUILD_CACHE: '0'
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
APP_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || '' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
EXTRA_TAGS: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
run: |
|
||||||
no-cache: true
|
if [ -n "$EXTRA_TAGS" ]; then
|
||||||
platforms: linux/amd64,linux/arm64
|
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:
|
Then build and push the multi-arch image:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker buildx build \
|
BUILDER=colima ./scripts/docker-publish.sh
|
||||||
--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 \
|
|
||||||
.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
That publishes:
|
That publishes:
|
||||||
- `ghcr.io/gnosyslabs/synapsis:latest`
|
- `ghcr.io/gnosyslabs/synapsis:latest`
|
||||||
|
- `ghcr.io/gnosyslabs/synapsis:<YYYY.MM.DD.N>`
|
||||||
- `ghcr.io/gnosyslabs/synapsis:<short-sha>`
|
- `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
|
## 4. Update The Server
|
||||||
Once a new image is published, update the server with:
|
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):**
|
**Updating (migrations run automatically):**
|
||||||
```bash
|
```bash
|
||||||
docker compose pull && docker compose up -d
|
curl -fsSL https://synapsis.social/update.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
**Full uninstall:**
|
**Full uninstall:**
|
||||||
|
|||||||
+24
-1
@@ -96,9 +96,20 @@ server {
|
|||||||
|
|
||||||
## 🔄 Updates (migrations run automatically)
|
## 🔄 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
|
```bash
|
||||||
cd /opt/synapsis
|
cd /opt/synapsis
|
||||||
docker compose pull && docker compose up -d
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🗑️ Full Uninstall
|
## 🗑️ Full Uninstall
|
||||||
@@ -222,6 +233,18 @@ cd synapsis/docker
|
|||||||
docker compose up -d --build
|
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)
|
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}"
|
IMAGE_REPO="${IMAGE_REPO:-ghcr.io/gnosyslabs/synapsis}"
|
||||||
PACKAGE_API="${PACKAGE_API:-/orgs/GnosysLabs/packages/container/synapsis/versions?per_page=100}"
|
PACKAGE_API="${PACKAGE_API:-/orgs/GnosysLabs/packages/container/synapsis/versions?per_page=100}"
|
||||||
BUILDER="${BUILDER:-colima}"
|
BUILDER="${BUILDER:-}"
|
||||||
PLATFORMS="${PLATFORMS:-linux/amd64}"
|
PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}"
|
||||||
DATE_PREFIX="${DATE_PREFIX:-$(date -u +%Y.%m.%d)}"
|
DATE_PREFIX="${DATE_PREFIX:-$(date -u +%Y.%m.%d)}"
|
||||||
SOURCE_REPO="${SOURCE_REPO:-https://github.com/GnosysLabs/Synapsis}"
|
SOURCE_REPO="${SOURCE_REPO:-https://github.com/GnosysLabs/Synapsis}"
|
||||||
PRUNE_BUILD_CACHE="${PRUNE_BUILD_CACHE:-1}"
|
PRUNE_BUILD_CACHE="${PRUNE_BUILD_CACHE:-1}"
|
||||||
|
APP_VERSION="${APP_VERSION:-}"
|
||||||
|
EXTRA_TAGS="${EXTRA_TAGS:-}"
|
||||||
|
|
||||||
require_command() {
|
require_command() {
|
||||||
if ! command -v "$1" >/dev/null 2>&1; then
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
@@ -24,56 +26,78 @@ require_command git
|
|||||||
CURRENT_SHA="$(git rev-parse --short HEAD)"
|
CURRENT_SHA="$(git rev-parse --short HEAD)"
|
||||||
CURRENT_FULL_SHA="$(git rev-parse HEAD)"
|
CURRENT_FULL_SHA="$(git rev-parse HEAD)"
|
||||||
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
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}"
|
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 "========================================"
|
||||||
echo " Synapsis Docker Publish"
|
echo " Synapsis Docker Publish"
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
echo " Version: ${APP_VERSION}"
|
echo " Version: ${APP_VERSION}"
|
||||||
echo " Commit: ${CURRENT_SHA}"
|
echo " Commit: ${CURRENT_SHA}"
|
||||||
|
echo " Build Date: ${BUILD_DATE}"
|
||||||
echo " Image: ${IMAGE_REPO}"
|
echo " Image: ${IMAGE_REPO}"
|
||||||
|
echo " Platforms: ${PLATFORMS}"
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
|
|
||||||
docker buildx build \
|
set -- docker buildx build
|
||||||
--builder "${BUILDER}" \
|
|
||||||
--platform "${PLATFORMS}" \
|
if [ -n "${BUILDER}" ]; then
|
||||||
--build-arg "APP_VERSION=${APP_VERSION}" \
|
set -- "$@" --builder "${BUILDER}"
|
||||||
--build-arg "APP_COMMIT=${CURRENT_FULL_SHA}" \
|
fi
|
||||||
--build-arg "APP_BUILD_DATE=${BUILD_DATE}" \
|
|
||||||
--build-arg "APP_GITHUB_URL=${GITHUB_URL}" \
|
# shellcheck disable=SC2086
|
||||||
--build-arg "APP_IMAGE_REPO=${IMAGE_REPO}" \
|
set -- "$@" $BUILD_ARGS -f docker/Dockerfile
|
||||||
--build-arg "APP_SOURCE_REPO=${SOURCE_REPO}" \
|
# shellcheck disable=SC2086
|
||||||
-f docker/Dockerfile \
|
set -- "$@" $TAG_ARGS --push .
|
||||||
-t "${IMAGE_REPO}:latest" \
|
|
||||||
-t "${IMAGE_REPO}:${APP_VERSION}" \
|
"$@"
|
||||||
-t "${IMAGE_REPO}:${CURRENT_SHA}" \
|
|
||||||
--push \
|
|
||||||
.
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Published:"
|
echo "✅ Published:"
|
||||||
@@ -81,8 +105,16 @@ echo " ${IMAGE_REPO}:latest"
|
|||||||
echo " ${IMAGE_REPO}:${APP_VERSION}"
|
echo " ${IMAGE_REPO}:${APP_VERSION}"
|
||||||
echo " ${IMAGE_REPO}:${CURRENT_SHA}"
|
echo " ${IMAGE_REPO}:${CURRENT_SHA}"
|
||||||
|
|
||||||
|
for extra_tag in ${EXTRA_TAGS}; do
|
||||||
|
echo " ${IMAGE_REPO}:${extra_tag}"
|
||||||
|
done
|
||||||
|
|
||||||
if [ "${PRUNE_BUILD_CACHE}" = "1" ]; then
|
if [ "${PRUNE_BUILD_CACHE}" = "1" ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "🧹 Pruning BuildKit cache"
|
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
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<header style={{
|
<header style={{
|
||||||
@@ -783,51 +766,6 @@ export default function AdminPage() {
|
|||||||
</div>
|
</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 && (
|
{!updateStatus?.updater.available && (
|
||||||
<div style={{
|
<div style={{
|
||||||
marginTop: '16px',
|
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 { NextResponse } from 'next/server';
|
||||||
import { destroySession } from '@/lib/auth';
|
import { destroySession, getSession } from '@/lib/auth';
|
||||||
import { clearStorageSession } from '@/lib/storage/session';
|
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 {
|
try {
|
||||||
await clearStorageSession();
|
const currentSession = await getSession();
|
||||||
await destroySession();
|
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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getSession } from '@/lib/auth';
|
import { getSession, getSessionAccounts } from '@/lib/auth';
|
||||||
import { db, users } from '@/db';
|
import { db, users } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -22,9 +22,10 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
|
const accounts = await getSessionAccounts();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ user: null });
|
return NextResponse.json({ user: null, accounts: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -40,10 +41,11 @@ export async function GET() {
|
|||||||
publicKey: session.user.publicKey,
|
publicKey: session.user.publicKey,
|
||||||
privateKeyEncrypted: session.user.privateKeyEncrypted,
|
privateKeyEncrypted: session.user.privateKeyEncrypted,
|
||||||
},
|
},
|
||||||
|
accounts,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Session check error:', 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
|
// Schema for setting API key
|
||||||
const setApiKeySchema = z.object({
|
const setApiKeySchema = z.object({
|
||||||
apiKey: z.string().min(1, 'API key is required'),
|
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),
|
maxTokens: z.number().int().min(1).max(100000),
|
||||||
responseStyle: z.string().optional(),
|
responseStyle: z.string().optional(),
|
||||||
}).optional(),
|
}).optional(),
|
||||||
llmProvider: z.enum(['openrouter', 'openai', 'anthropic']).optional(),
|
llmProvider: z.enum(['openrouter', 'openai', 'anthropic', 'custom']).optional(),
|
||||||
llmModel: z.string().min(1).optional(),
|
llmModel: z.string().min(1).optional(),
|
||||||
|
llmEndpoint: z.string().url().optional().nullable(),
|
||||||
llmApiKey: z.string().min(1).optional(),
|
llmApiKey: z.string().min(1).optional(),
|
||||||
schedule: z.object({
|
schedule: z.object({
|
||||||
type: z.enum(['interval', 'times', 'cron']),
|
type: z.enum(['interval', 'times', 'cron']),
|
||||||
@@ -91,6 +92,7 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
personalityConfig: bot.personalityConfig,
|
personalityConfig: bot.personalityConfig,
|
||||||
llmProvider: bot.llmProvider,
|
llmProvider: bot.llmProvider,
|
||||||
llmModel: bot.llmModel,
|
llmModel: bot.llmModel,
|
||||||
|
llmEndpoint: bot.llmEndpoint,
|
||||||
scheduleConfig: bot.scheduleConfig,
|
scheduleConfig: bot.scheduleConfig,
|
||||||
autonomousMode: bot.autonomousMode,
|
autonomousMode: bot.autonomousMode,
|
||||||
isActive: bot.isActive,
|
isActive: bot.isActive,
|
||||||
@@ -162,6 +164,7 @@ async function handleUpdate(request: Request, context: RouteContext) {
|
|||||||
if (data.personality !== undefined) updateInput.personality = data.personality;
|
if (data.personality !== undefined) updateInput.personality = data.personality;
|
||||||
if (data.llmProvider !== undefined) updateInput.llmProvider = data.llmProvider;
|
if (data.llmProvider !== undefined) updateInput.llmProvider = data.llmProvider;
|
||||||
if (data.llmModel !== undefined) updateInput.llmModel = data.llmModel;
|
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.llmApiKey !== undefined) updateInput.llmApiKey = data.llmApiKey;
|
||||||
if (data.schedule !== undefined) updateInput.schedule = data.schedule;
|
if (data.schedule !== undefined) updateInput.schedule = data.schedule;
|
||||||
if (data.autonomousMode !== undefined) updateInput.autonomousMode = data.autonomousMode;
|
if (data.autonomousMode !== undefined) updateInput.autonomousMode = data.autonomousMode;
|
||||||
@@ -183,6 +186,7 @@ async function handleUpdate(request: Request, context: RouteContext) {
|
|||||||
personalityConfig: updatedBot.personalityConfig,
|
personalityConfig: updatedBot.personalityConfig,
|
||||||
llmProvider: updatedBot.llmProvider,
|
llmProvider: updatedBot.llmProvider,
|
||||||
llmModel: updatedBot.llmModel,
|
llmModel: updatedBot.llmModel,
|
||||||
|
llmEndpoint: updatedBot.llmEndpoint,
|
||||||
scheduleConfig: updatedBot.scheduleConfig,
|
scheduleConfig: updatedBot.scheduleConfig,
|
||||||
autonomousMode: updatedBot.autonomousMode,
|
autonomousMode: updatedBot.autonomousMode,
|
||||||
isActive: updatedBot.isActive,
|
isActive: updatedBot.isActive,
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ const createBotSchema = z.object({
|
|||||||
maxTokens: z.number().int().min(1).max(100000),
|
maxTokens: z.number().int().min(1).max(100000),
|
||||||
responseStyle: z.string().optional(),
|
responseStyle: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
llmProvider: z.enum(['openrouter', 'openai', 'anthropic']),
|
llmProvider: z.enum(['openrouter', 'openai', 'anthropic', 'custom']),
|
||||||
llmModel: z.string().min(1),
|
llmModel: z.string().min(1),
|
||||||
|
llmEndpoint: z.string().url().optional(),
|
||||||
llmApiKey: z.string().min(1),
|
llmApiKey: z.string().min(1),
|
||||||
schedule: z.object({
|
schedule: z.object({
|
||||||
type: z.enum(['interval', 'times', 'cron']),
|
type: z.enum(['interval', 'times', 'cron']),
|
||||||
@@ -96,6 +97,7 @@ export async function POST(request: Request) {
|
|||||||
personality: data.personality,
|
personality: data.personality,
|
||||||
llmProvider: data.llmProvider,
|
llmProvider: data.llmProvider,
|
||||||
llmModel: data.llmModel,
|
llmModel: data.llmModel,
|
||||||
|
llmEndpoint: data.llmEndpoint,
|
||||||
llmApiKey: data.llmApiKey,
|
llmApiKey: data.llmApiKey,
|
||||||
schedule: data.schedule,
|
schedule: data.schedule,
|
||||||
autonomousMode: data.autonomousMode,
|
autonomousMode: data.autonomousMode,
|
||||||
@@ -114,6 +116,7 @@ export async function POST(request: Request) {
|
|||||||
personalityConfig: bot.personalityConfig,
|
personalityConfig: bot.personalityConfig,
|
||||||
llmProvider: bot.llmProvider,
|
llmProvider: bot.llmProvider,
|
||||||
llmModel: bot.llmModel,
|
llmModel: bot.llmModel,
|
||||||
|
llmEndpoint: bot.llmEndpoint,
|
||||||
scheduleConfig: bot.scheduleConfig,
|
scheduleConfig: bot.scheduleConfig,
|
||||||
autonomousMode: bot.autonomousMode,
|
autonomousMode: bot.autonomousMode,
|
||||||
isActive: bot.isActive,
|
isActive: bot.isActive,
|
||||||
@@ -189,6 +192,7 @@ export async function GET() {
|
|||||||
personalityConfig: bot.personalityConfig,
|
personalityConfig: bot.personalityConfig,
|
||||||
llmProvider: bot.llmProvider,
|
llmProvider: bot.llmProvider,
|
||||||
llmModel: bot.llmModel,
|
llmModel: bot.llmModel,
|
||||||
|
llmEndpoint: bot.llmEndpoint,
|
||||||
scheduleConfig: bot.scheduleConfig,
|
scheduleConfig: bot.scheduleConfig,
|
||||||
autonomousMode: bot.autonomousMode,
|
autonomousMode: bot.autonomousMode,
|
||||||
isActive: bot.isActive,
|
isActive: bot.isActive,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-sign
|
|||||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||||
import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache';
|
import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
|
||||||
|
|
||||||
const signedChatActionSchema = z.object({
|
const signedChatActionSchema = z.object({
|
||||||
action: z.string().min(1),
|
action: z.string().min(1),
|
||||||
@@ -76,6 +77,11 @@ export async function POST(request: NextRequest) {
|
|||||||
let fullSenderHandle: string | null = null;
|
let fullSenderHandle: string | null = null;
|
||||||
|
|
||||||
if (swarmSignature && sourceDomain && body.userAction) {
|
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
|
// Federated envelope format - validate and verify node signature
|
||||||
const envelopeValidation = federatedEnvelopeSchema.safeParse(body);
|
const envelopeValidation = federatedEnvelopeSchema.safeParse(body);
|
||||||
if (!envelopeValidation.success) {
|
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
|
// Use full handle if provided in envelope, otherwise fall back to signed handle
|
||||||
const senderHandle = fullSenderHandle || 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}`);
|
console.log(`[Chat Receive] From: ${senderHandle} (DID: ${did}), To: ${recipientDid}`);
|
||||||
|
|
||||||
// 1. Resolve Sender Public Key
|
// 1. Resolve Sender Public Key
|
||||||
@@ -134,21 +146,25 @@ export async function POST(request: NextRequest) {
|
|||||||
// Derive domain from full sender handle if possible
|
// Derive domain from full sender handle if possible
|
||||||
if (senderHandle.includes('@')) {
|
if (senderHandle.includes('@')) {
|
||||||
const parts = senderHandle.split('@');
|
const parts = senderHandle.split('@');
|
||||||
senderNodeDomain = parts[parts.length - 1];
|
senderNodeDomain = normalizeNodeDomain(parts[parts.length - 1]);
|
||||||
} else {
|
} else {
|
||||||
// Try to get from header first
|
// Try to get from header first
|
||||||
const sourceDomainHeader = request.headers.get('X-Swarm-Source-Domain');
|
const sourceDomainHeader = request.headers.get('X-Swarm-Source-Domain');
|
||||||
if (sourceDomainHeader) {
|
if (sourceDomainHeader) {
|
||||||
senderNodeDomain = sourceDomainHeader;
|
senderNodeDomain = normalizeNodeDomain(sourceDomainHeader);
|
||||||
} else {
|
} else {
|
||||||
// Try handle registry (though we likely don't have it if we don't have the user)
|
// Try handle registry (though we likely don't have it if we don't have the user)
|
||||||
const registryEntry = await db.query.handleRegistry.findFirst({
|
const registryEntry = await db.query.handleRegistry.findFirst({
|
||||||
where: eq(handleRegistry.did, did)
|
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) {
|
if (senderNodeDomain) {
|
||||||
try {
|
try {
|
||||||
const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https';
|
const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { eq, and } from 'drizzle-orm';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { createSignedPayload } from '@/lib/swarm/signature';
|
import { createSignedPayload } from '@/lib/swarm/signature';
|
||||||
import { federatedHandleSchema } from '@/lib/utils/federation';
|
import { federatedHandleSchema } from '@/lib/utils/federation';
|
||||||
|
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
|
||||||
|
|
||||||
const chatSendSchema = z.object({
|
const chatSendSchema = z.object({
|
||||||
recipientDid: z.string().min(1).regex(/^did:/, 'Must be a valid DID (did:key:... or did:synapsis:...)'),
|
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 });
|
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}`);
|
console.log(`[Remote Send] Sending to ${targetHandle} at ${targetDomain}`);
|
||||||
|
|
||||||
// 2. Send to Remote Node (Forward the Signed Action)
|
// 2. Send to Remote Node (Forward the Signed Action)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 {
|
function isRedditUrl(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
@@ -12,58 +12,16 @@ function isRedditUrl(url: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function buildBasicPreview(url: string, title?: string | null, description?: string | null, image?: string | null): LinkPreviewData {
|
||||||
* Fetch preview for Reddit URLs using their oEmbed API.
|
return {
|
||||||
*/
|
url,
|
||||||
async function fetchRedditPreview(url: string): Promise<{
|
title: title || url,
|
||||||
url: string;
|
description: description || null,
|
||||||
title: string | null;
|
image: image || null,
|
||||||
description: string | null;
|
type: image ? 'image' : 'card',
|
||||||
image: string | null;
|
videoUrl: null,
|
||||||
} | null> {
|
media: image ? [{ url: image }] : 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
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 });
|
return NextResponse.json({ error: 'No URL provided' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize URL
|
|
||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
url = 'https://' + url;
|
url = 'https://' + url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use Reddit-specific handler
|
|
||||||
if (isRedditUrl(url)) {
|
if (isRedditUrl(url)) {
|
||||||
const preview = await fetchRedditPreview(url);
|
const preview = await fetchRedditRichPreview(url);
|
||||||
if (preview) {
|
if (preview) {
|
||||||
return NextResponse.json(preview);
|
return NextResponse.json(preview);
|
||||||
}
|
}
|
||||||
// Fall back to URL-only response if oEmbed fails
|
|
||||||
return NextResponse.json({
|
return NextResponse.json(buildBasicPreview(url, 'Reddit'));
|
||||||
url,
|
|
||||||
title: 'Reddit',
|
|
||||||
description: null,
|
|
||||||
image: null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic OG tag scraping for other sites
|
const preview = await fetchGenericLinkPreview(url);
|
||||||
let response;
|
if (!preview) {
|
||||||
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);
|
|
||||||
return NextResponse.json({ error: 'Could not reach the URL' }, { status: 404 });
|
return NextResponse.json({ error: 'Could not reach the URL' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
return NextResponse.json(buildBasicPreview(
|
||||||
return NextResponse.json({ error: `URL returned status ${response.status}` }, { status: 404 });
|
preview.url,
|
||||||
}
|
preview.title?.trim() || url,
|
||||||
|
preview.description?.trim() || null,
|
||||||
const html = await response.text();
|
preview.image?.trim() || null,
|
||||||
|
));
|
||||||
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,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Link preview error:', error);
|
console.error('Link preview error:', error);
|
||||||
return NextResponse.json({ error: 'Failed to fetch preview' }, { status: 500 });
|
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,
|
avatarUrl: freshProfile?.avatarUrl || row.actorAvatarUrl,
|
||||||
nodeDomain: row.actorNodeDomain,
|
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 ? {
|
post: row.postId ? {
|
||||||
id: row.postId,
|
id: row.postId,
|
||||||
content: row.postContent,
|
content: row.postContent,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db, posts, likes, users, notifications, userSwarmLikes } from '@/db';
|
import { db, posts, likes, users, notifications, userSwarmLikes } from '@/db';
|
||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||||
import { eq, and, sql } from 'drizzle-orm';
|
import { eq, and, sql } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { buildNotificationTarget } from '@/lib/notifications';
|
||||||
|
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ id: string }> };
|
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'),
|
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)
|
* 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,
|
linkPreviewTitle: post.linkPreviewTitle || null,
|
||||||
linkPreviewDescription: post.linkPreviewDescription || null,
|
linkPreviewDescription: post.linkPreviewDescription || null,
|
||||||
linkPreviewImage: post.linkPreviewImage || 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,
|
mediaJson: post.media ? JSON.stringify(post.media) : null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -81,15 +106,19 @@ async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) {
|
|||||||
// Like a post
|
// Like a post
|
||||||
export async function POST(request: Request, context: RouteContext) {
|
export async function POST(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
// Parse the signed action from the request body
|
const body = await readOptionalJson(request);
|
||||||
const signedAction: SignedAction = await request.json();
|
const { id: paramId } = await context.params;
|
||||||
|
const decodedParamId = decodeURIComponent(paramId);
|
||||||
|
|
||||||
// Verify the signature and get the user
|
const user = isSignedActionPayload(body)
|
||||||
const user = await requireSignedAction(signedAction);
|
? await requireSignedAction(body)
|
||||||
|
: await requireAuth();
|
||||||
|
|
||||||
// Extract and validate postId from the signed action data
|
if (isSignedActionPayload(body) && body.data?.postId && body.data.postId !== decodedParamId) {
|
||||||
const { postId: rawId } = signedAction.data;
|
return NextResponse.json({ error: 'Post ID mismatch' }, { status: 400 });
|
||||||
const decodedId = decodeURIComponent(rawId);
|
}
|
||||||
|
|
||||||
|
const decodedId = decodedParamId;
|
||||||
const postId = postIdSchema.parse(decodedId);
|
const postId = postIdSchema.parse(decodedId);
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
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));
|
.where(eq(posts.id, postId));
|
||||||
|
|
||||||
if (post.userId !== user.id) {
|
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
|
// Create notification with actor info stored directly
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
userId: post.userId,
|
userId: post.userId,
|
||||||
@@ -193,13 +226,11 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
actorNodeDomain: null, // Local user
|
actorNodeDomain: null, // Local user
|
||||||
postId,
|
postId,
|
||||||
postContent: post.content?.slice(0, 200) || null,
|
postContent: post.content?.slice(0, 200) || null,
|
||||||
|
...(postAuthor?.isBot ? buildNotificationTarget(postAuthor) : {}),
|
||||||
type: 'like',
|
type: 'like',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also notify bot owner if this is a bot's post
|
// 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) {
|
if (postAuthor?.isBot && postAuthor.botOwnerId) {
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
userId: postAuthor.botOwnerId,
|
userId: postAuthor.botOwnerId,
|
||||||
@@ -210,6 +241,7 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
actorNodeDomain: null,
|
actorNodeDomain: null,
|
||||||
postId,
|
postId,
|
||||||
postContent: post.content?.slice(0, 200) || null,
|
postContent: post.content?.slice(0, 200) || null,
|
||||||
|
...buildNotificationTarget(postAuthor),
|
||||||
type: 'like',
|
type: 'like',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -277,15 +309,19 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
// Unlike a post
|
// Unlike a post
|
||||||
export async function DELETE(request: Request, context: RouteContext) {
|
export async function DELETE(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
// Parse the signed action from the request body
|
const body = await readOptionalJson(request);
|
||||||
const signedAction: SignedAction = await request.json();
|
const { id: paramId } = await context.params;
|
||||||
|
const decodedParamId = decodeURIComponent(paramId);
|
||||||
|
|
||||||
// Verify the signature and get the user
|
const user = isSignedActionPayload(body)
|
||||||
const user = await requireSignedAction(signedAction);
|
? await requireSignedAction(body)
|
||||||
|
: await requireAuth();
|
||||||
|
|
||||||
// Extract and validate postId from the signed action data
|
if (isSignedActionPayload(body) && body.data?.postId && body.data.postId !== decodedParamId) {
|
||||||
const { postId: rawId } = signedAction.data;
|
return NextResponse.json({ error: 'Post ID mismatch' }, { status: 400 });
|
||||||
const decodedId = decodeURIComponent(rawId);
|
}
|
||||||
|
|
||||||
|
const decodedId = decodedParamId;
|
||||||
const postId = postIdSchema.parse(decodedId);
|
const postId = postIdSchema.parse(decodedId);
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { NextResponse } from 'next/server';
|
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 { requireAuth } from '@/lib/auth';
|
||||||
import { eq, and, sql } from 'drizzle-orm';
|
import { eq, and, sql } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { buildNotificationTarget } from '@/lib/notifications';
|
||||||
|
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ id: string }> };
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
@@ -40,6 +42,47 @@ function extractSwarmPostId(apId: string): string | null {
|
|||||||
return apId.substring(lastColonIndex + 1);
|
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
|
// Repost a post
|
||||||
export async function POST(request: Request, context: RouteContext) {
|
export async function POST(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
@@ -62,6 +105,18 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 });
|
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
|
// Deliver repost directly to the origin node
|
||||||
const { deliverSwarmRepost } = await import('@/lib/swarm/interactions');
|
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 });
|
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}`);
|
console.log(`[Swarm] Repost delivered to ${targetDomain} for post ${originalPostId}`);
|
||||||
return NextResponse.json({ success: true, reposted: true });
|
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));
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
if (originalPost.userId !== 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
|
// Create notification with actor info stored directly
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
userId: originalPost.userId,
|
userId: originalPost.userId,
|
||||||
@@ -143,13 +223,11 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
actorNodeDomain: null, // Local user
|
actorNodeDomain: null, // Local user
|
||||||
postId,
|
postId,
|
||||||
postContent: originalPost.content?.slice(0, 200) || null,
|
postContent: originalPost.content?.slice(0, 200) || null,
|
||||||
|
...(postAuthor?.isBot ? buildNotificationTarget(postAuthor) : {}),
|
||||||
type: 'repost',
|
type: 'repost',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also notify bot owner if this is a bot's post
|
// 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) {
|
if (postAuthor?.isBot && postAuthor.botOwnerId) {
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
userId: postAuthor.botOwnerId,
|
userId: postAuthor.botOwnerId,
|
||||||
@@ -160,6 +238,7 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
actorNodeDomain: null,
|
actorNodeDomain: null,
|
||||||
postId,
|
postId,
|
||||||
postContent: originalPost.content?.slice(0, 200) || null,
|
postContent: originalPost.content?.slice(0, 200) || null,
|
||||||
|
...buildNotificationTarget(postAuthor),
|
||||||
type: 'repost',
|
type: 'repost',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -236,6 +315,18 @@ export async function DELETE(request: Request, context: RouteContext) {
|
|||||||
return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 });
|
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
|
// Deliver unrepost directly to the origin node
|
||||||
const { deliverSwarmUnrepost } = await import('@/lib/swarm/interactions');
|
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 });
|
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}`);
|
console.log(`[Swarm] Unrepost delivered to ${targetDomain} for post ${originalPostId}`);
|
||||||
return NextResponse.json({ success: true, reposted: false });
|
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 { db, posts, users, media, remotePosts } from '@/db';
|
||||||
import { eq, desc, and, sql, inArray } from 'drizzle-orm';
|
import { eq, desc, and, sql, inArray } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
||||||
|
|
||||||
// Schema for local post ID (UUID)
|
// Schema for local post ID (UUID)
|
||||||
const localPostIdSchema = z.string().uuid('Invalid post ID format');
|
const localPostIdSchema = z.string().uuid('Invalid post ID format');
|
||||||
@@ -15,6 +16,69 @@ const swarmPostIdSchema = z.string().regex(
|
|||||||
// Combined schema that accepts either format
|
// Combined schema that accepts either format
|
||||||
const postIdSchema = z.union([localPostIdSchema, swarmPostIdSchema]);
|
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(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
@@ -47,67 +111,25 @@ export async function GET(
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
// Transform to match expected format
|
mainPost = mapSwarmDetailPost({
|
||||||
mainPost = {
|
...data.post,
|
||||||
id: id,
|
id,
|
||||||
originalPostId: 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,
|
|
||||||
nodeDomain: originDomain,
|
nodeDomain: originDomain,
|
||||||
author: {
|
}, originDomain);
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Transform replies from the origin node
|
// Transform replies from the origin node
|
||||||
replyPosts = (data.replies || []).map((r: any) => ({
|
replyPosts = (data.replies || []).map((reply: any) => mapSwarmDetailPost({
|
||||||
id: `swarm:${originDomain}:${r.id}`,
|
...reply,
|
||||||
originalPostId: r.id,
|
|
||||||
content: r.content,
|
|
||||||
createdAt: r.createdAt,
|
|
||||||
likesCount: r.likesCount || 0,
|
|
||||||
repostsCount: r.repostsCount || 0,
|
|
||||||
repliesCount: r.repliesCount || 0,
|
|
||||||
isSwarm: true,
|
|
||||||
nodeDomain: originDomain,
|
nodeDomain: originDomain,
|
||||||
author: {
|
}, originDomain));
|
||||||
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,
|
|
||||||
})) || [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
mainPost.repliesCount = replyPosts.length;
|
mainPost.repliesCount = replyPosts.length;
|
||||||
|
|
||||||
// Check if current user has liked this post
|
// Check if current user has liked this post
|
||||||
try {
|
try {
|
||||||
const { requireAuth } = await import('@/lib/auth');
|
const { requireAuth } = await import('@/lib/auth');
|
||||||
|
const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts');
|
||||||
const viewer = await requireAuth();
|
const viewer = await requireAuth();
|
||||||
|
|
||||||
const likeCheckRes = await fetch(
|
const likeCheckRes = await fetch(
|
||||||
@@ -119,6 +141,15 @@ export async function GET(
|
|||||||
const likeData = await likeCheckRes.json();
|
const likeData = await likeCheckRes.json();
|
||||||
mainPost.isLiked = likeData.isLiked;
|
mainPost.isLiked = likeData.isLiked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const repostedIds = await getViewerSwarmRepostedPostIds([
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
nodeDomain: originDomain,
|
||||||
|
originalPostId,
|
||||||
|
},
|
||||||
|
], viewer.id);
|
||||||
|
mainPost.isReposted = repostedIds.has(id);
|
||||||
} catch {
|
} catch {
|
||||||
// Not logged in or timeout
|
// Not logged in or timeout
|
||||||
}
|
}
|
||||||
@@ -138,13 +169,7 @@ export async function GET(
|
|||||||
|
|
||||||
const post = await db.query.posts.findFirst({
|
const post = await db.query.posts.findFirst({
|
||||||
where: eq(posts.id, id),
|
where: eq(posts.id, id),
|
||||||
with: {
|
with: postDetailRelations,
|
||||||
author: true,
|
|
||||||
media: true,
|
|
||||||
replyTo: {
|
|
||||||
with: { author: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (post) {
|
if (post) {
|
||||||
@@ -155,10 +180,7 @@ export async function GET(
|
|||||||
eq(posts.replyToId, id),
|
eq(posts.replyToId, id),
|
||||||
eq(posts.isRemoved, false)
|
eq(posts.isRemoved, false)
|
||||||
),
|
),
|
||||||
with: {
|
with: postDetailRelations,
|
||||||
author: true,
|
|
||||||
media: true,
|
|
||||||
},
|
|
||||||
orderBy: [desc(posts.createdAt)],
|
orderBy: [desc(posts.createdAt)],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,6 +257,9 @@ export async function GET(
|
|||||||
linkPreviewTitle: cached.linkPreviewTitle,
|
linkPreviewTitle: cached.linkPreviewTitle,
|
||||||
linkPreviewDescription: cached.linkPreviewDescription,
|
linkPreviewDescription: cached.linkPreviewDescription,
|
||||||
linkPreviewImage: cached.linkPreviewImage,
|
linkPreviewImage: cached.linkPreviewImage,
|
||||||
|
linkPreviewType: cached.linkPreviewType,
|
||||||
|
linkPreviewVideoUrl: cached.linkPreviewVideoUrl,
|
||||||
|
linkPreviewMedia: parseLinkPreviewMediaJson(cached.linkPreviewMediaJson) || null,
|
||||||
isLiked: false,
|
isLiked: false,
|
||||||
isReposted: false,
|
isReposted: false,
|
||||||
};
|
};
|
||||||
|
|||||||
+293
-104
@@ -1,10 +1,12 @@
|
|||||||
import { NextResponse } from 'next/server';
|
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 { requireAuth } from '@/lib/auth';
|
||||||
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||||
import { eq, desc, and, inArray, isNull, isNotNull, or, lt, sql } from 'drizzle-orm';
|
import { eq, desc, and, inArray, isNull, isNotNull, or, lt, sql } from 'drizzle-orm';
|
||||||
import type { SQL } from 'drizzle-orm';
|
import type { SQL } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { buildNotificationTarget } from '@/lib/notifications';
|
||||||
|
import { serializeLinkPreviewMedia, parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
||||||
|
|
||||||
const POST_MAX_LENGTH = 600;
|
const POST_MAX_LENGTH = 600;
|
||||||
const CURATION_WINDOW_HOURS = 72;
|
const CURATION_WINDOW_HOURS = 72;
|
||||||
@@ -17,6 +19,138 @@ const buildWhere = (...conditions: Array<SQL | undefined>) => {
|
|||||||
return and(...filtered);
|
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({
|
const createPostSchema = z.object({
|
||||||
content: z.string().min(1).max(POST_MAX_LENGTH),
|
content: z.string().min(1).max(POST_MAX_LENGTH),
|
||||||
replyToId: z.string().uuid().optional(), // Must be UUID (swarm replies use separate field)
|
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(),
|
title: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
image: z.string().url().optional().nullable(),
|
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(),
|
}).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
|
// Create a new post
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
// Parse the signed action from the request body
|
const requestBody = await request.json();
|
||||||
const signedAction: SignedAction = await request.json();
|
const user = isSignedActionPayload(requestBody)
|
||||||
|
? await requireSignedAction(requestBody)
|
||||||
// Strictly verify the signature and get the user
|
: await requireAuth();
|
||||||
// This replaces requireAuth() - the signature proves identity AND intent
|
const data = createPostSchema.parse(
|
||||||
const user = await requireSignedAction(signedAction);
|
isSignedActionPayload(requestBody) ? requestBody.data : requestBody
|
||||||
|
);
|
||||||
// Extract post data from the signed action
|
|
||||||
const data = createPostSchema.parse(signedAction.data);
|
|
||||||
|
|
||||||
if (user.isSuspended || user.isSilenced) {
|
if (user.isSuspended || user.isSilenced) {
|
||||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||||
@@ -84,6 +237,9 @@ export async function POST(request: Request) {
|
|||||||
linkPreviewTitle: data.linkPreview?.title,
|
linkPreviewTitle: data.linkPreview?.title,
|
||||||
linkPreviewDescription: data.linkPreview?.description,
|
linkPreviewDescription: data.linkPreview?.description,
|
||||||
linkPreviewImage: data.linkPreview?.image,
|
linkPreviewImage: data.linkPreview?.image,
|
||||||
|
linkPreviewType: data.linkPreview?.type,
|
||||||
|
linkPreviewVideoUrl: data.linkPreview?.videoUrl,
|
||||||
|
linkPreviewMediaJson: serializeLinkPreviewMedia(data.linkPreview?.media),
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
let unattachedMedia: typeof media.$inferSelect[] = [];
|
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)
|
// Handle local mentions (create notifications for users on this node)
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const { extractMentions } = await import('@/lib/swarm/interactions');
|
const { extractMentions } = await import('@/lib/swarm/interactions');
|
||||||
const { notifications } = await import('@/db');
|
|
||||||
|
|
||||||
const mentions = extractMentions(data.content);
|
const mentions = extractMentions(data.content);
|
||||||
|
|
||||||
for (const mention of mentions) {
|
for (const mention of mentions) {
|
||||||
@@ -209,6 +409,7 @@ export async function POST(request: Request) {
|
|||||||
actorNodeDomain: null, // Local user
|
actorNodeDomain: null, // Local user
|
||||||
postId: post.id,
|
postId: post.id,
|
||||||
postContent: post.content?.slice(0, 200) || null,
|
postContent: post.content?.slice(0, 200) || null,
|
||||||
|
...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}),
|
||||||
type: 'mention',
|
type: 'mention',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -223,6 +424,7 @@ export async function POST(request: Request) {
|
|||||||
actorNodeDomain: null,
|
actorNodeDomain: null,
|
||||||
postId: post.id,
|
postId: post.id,
|
||||||
postContent: post.content?.slice(0, 200) || null,
|
postContent: post.content?.slice(0, 200) || null,
|
||||||
|
...buildNotificationTarget(mentionedUser),
|
||||||
type: 'mention',
|
type: 'mention',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -374,6 +576,9 @@ const transformRemotePosts = (remotePostsData: typeof remotePosts.$inferSelect[]
|
|||||||
linkPreviewTitle: rp.linkPreviewTitle,
|
linkPreviewTitle: rp.linkPreviewTitle,
|
||||||
linkPreviewDescription: rp.linkPreviewDescription,
|
linkPreviewDescription: rp.linkPreviewDescription,
|
||||||
linkPreviewImage: rp.linkPreviewImage,
|
linkPreviewImage: rp.linkPreviewImage,
|
||||||
|
linkPreviewType: rp.linkPreviewType,
|
||||||
|
linkPreviewVideoUrl: rp.linkPreviewVideoUrl,
|
||||||
|
linkPreviewMedia: parseLinkPreviewMediaJson(rp.linkPreviewMediaJson) || null,
|
||||||
author: {
|
author: {
|
||||||
id: rp.authorActorUrl,
|
id: rp.authorActorUrl,
|
||||||
handle: rp.authorHandle,
|
handle: rp.authorHandle,
|
||||||
@@ -437,14 +642,7 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
feedPosts = await db.query.posts.findMany({
|
feedPosts = await db.query.posts.findMany({
|
||||||
where: whereCondition,
|
where: whereCondition,
|
||||||
with: {
|
with: feedPostRelations,
|
||||||
author: true,
|
|
||||||
bot: true,
|
|
||||||
media: true,
|
|
||||||
replyTo: {
|
|
||||||
with: { author: true, media: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: [desc(posts.createdAt)],
|
orderBy: [desc(posts.createdAt)],
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
@@ -452,14 +650,7 @@ export async function GET(request: Request) {
|
|||||||
// Public timeline - all local posts + all cached remote posts
|
// Public timeline - all local posts + all cached remote posts
|
||||||
const localPosts = await db.query.posts.findMany({
|
const localPosts = await db.query.posts.findMany({
|
||||||
where: baseFilter,
|
where: baseFilter,
|
||||||
with: {
|
with: feedPostRelations,
|
||||||
author: true,
|
|
||||||
bot: true,
|
|
||||||
media: true,
|
|
||||||
replyTo: {
|
|
||||||
with: { author: true, media: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: [desc(posts.createdAt)],
|
orderBy: [desc(posts.createdAt)],
|
||||||
limit: limit * 2,
|
limit: limit * 2,
|
||||||
});
|
});
|
||||||
@@ -492,14 +683,7 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
feedPosts = await db.query.posts.findMany({
|
feedPosts = await db.query.posts.findMany({
|
||||||
where: whereCondition,
|
where: whereCondition,
|
||||||
with: {
|
with: feedPostRelations,
|
||||||
author: true,
|
|
||||||
bot: true,
|
|
||||||
media: true,
|
|
||||||
replyTo: {
|
|
||||||
with: { author: true, media: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: [desc(posts.createdAt)],
|
orderBy: [desc(posts.createdAt)],
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
@@ -519,14 +703,7 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
feedPosts = await db.query.posts.findMany({
|
feedPosts = await db.query.posts.findMany({
|
||||||
where: whereCondition,
|
where: whereCondition,
|
||||||
with: {
|
with: feedPostRelations,
|
||||||
author: true,
|
|
||||||
bot: true,
|
|
||||||
media: true,
|
|
||||||
replyTo: {
|
|
||||||
with: { author: true, media: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: [desc(posts.createdAt)],
|
orderBy: [desc(posts.createdAt)],
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
@@ -554,7 +731,7 @@ export async function GET(request: Request) {
|
|||||||
includeNsfw,
|
includeNsfw,
|
||||||
});
|
});
|
||||||
|
|
||||||
const swarmPosts = swarmResult.posts.map(sp => ({
|
const mapSwarmPostToFeedPost = (sp: (typeof swarmResult.posts)[number]): any => ({
|
||||||
id: `swarm:${sp.nodeDomain}:${sp.id}`,
|
id: `swarm:${sp.nodeDomain}:${sp.id}`,
|
||||||
originalPostId: sp.id, // Keep the original ID for replies
|
originalPostId: sp.id, // Keep the original ID for replies
|
||||||
content: sp.content,
|
content: sp.content,
|
||||||
@@ -564,6 +741,8 @@ export async function GET(request: Request) {
|
|||||||
repliesCount: sp.replyCount,
|
repliesCount: sp.replyCount,
|
||||||
isSwarm: true,
|
isSwarm: true,
|
||||||
nodeDomain: sp.nodeDomain,
|
nodeDomain: sp.nodeDomain,
|
||||||
|
repostOfId: sp.repostOfId ? `swarm:${sp.nodeDomain}:${sp.repostOfId}` : null,
|
||||||
|
repostOf: sp.repostOf ? mapSwarmPostToFeedPost(sp.repostOf) : null,
|
||||||
author: {
|
author: {
|
||||||
id: `swarm:${sp.nodeDomain}:${sp.author.handle}`,
|
id: `swarm:${sp.nodeDomain}:${sp.author.handle}`,
|
||||||
handle: sp.author.handle,
|
handle: sp.author.handle,
|
||||||
@@ -583,8 +762,13 @@ export async function GET(request: Request) {
|
|||||||
linkPreviewTitle: sp.linkPreviewTitle || null,
|
linkPreviewTitle: sp.linkPreviewTitle || null,
|
||||||
linkPreviewDescription: sp.linkPreviewDescription || null,
|
linkPreviewDescription: sp.linkPreviewDescription || null,
|
||||||
linkPreviewImage: sp.linkPreviewImage || null,
|
linkPreviewImage: sp.linkPreviewImage || null,
|
||||||
|
linkPreviewType: sp.linkPreviewType || null,
|
||||||
|
linkPreviewVideoUrl: sp.linkPreviewVideoUrl || null,
|
||||||
|
linkPreviewMedia: sp.linkPreviewMedia || null,
|
||||||
replyTo: null,
|
replyTo: null,
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
const swarmPosts = swarmResult.posts.map(mapSwarmPostToFeedPost);
|
||||||
|
|
||||||
let mutedIds = new Set<string>();
|
let mutedIds = new Set<string>();
|
||||||
let blockedIds = new Set<string>();
|
let blockedIds = new Set<string>();
|
||||||
@@ -674,31 +858,48 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
// Build where condition with cursor support
|
// Build where condition with cursor support
|
||||||
let whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds));
|
let whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds));
|
||||||
|
const cursorDate = await getMixedFeedCursorDate(cursor);
|
||||||
|
|
||||||
if (cursor) {
|
if (cursorDate) {
|
||||||
const cursorPost = await db.query.posts.findFirst({
|
whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds), lt(posts.createdAt, cursorDate));
|
||||||
where: eq(posts.id, cursor),
|
|
||||||
});
|
|
||||||
if (cursorPost) {
|
|
||||||
whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds), lt(posts.createdAt, cursorPost.createdAt));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get local posts from people the user follows + their own posts
|
// Get local posts from people the user follows + their own posts
|
||||||
const localPosts = await db.query.posts.findMany({
|
const localPosts = await db.query.posts.findMany({
|
||||||
where: whereCondition,
|
where: whereCondition,
|
||||||
with: {
|
with: feedPostRelations,
|
||||||
author: true,
|
|
||||||
bot: true,
|
|
||||||
media: true,
|
|
||||||
replyTo: {
|
|
||||||
with: { author: true, media: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: [desc(posts.createdAt)],
|
orderBy: [desc(posts.createdAt)],
|
||||||
limit: cursor ? limit : limit * 2, // Get more on first load to account for mixing with remote
|
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
|
// Get handles of remote users we follow
|
||||||
const followedRemoteUsers = await db.query.remoteFollows.findMany({
|
const followedRemoteUsers = await db.query.remoteFollows.findMany({
|
||||||
where: eq(remoteFollows.followerId, user.id),
|
where: eq(remoteFollows.followerId, user.id),
|
||||||
@@ -708,6 +909,7 @@ export async function GET(request: Request) {
|
|||||||
let liveRemotePosts: any[] = [];
|
let liveRemotePosts: any[] = [];
|
||||||
if (followedRemoteUsers.length > 0) {
|
if (followedRemoteUsers.length > 0) {
|
||||||
const { fetchSwarmUserProfile, isSwarmNode } = await import('@/lib/swarm/interactions');
|
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
|
// Wrap each fetch with a timeout to prevent slow nodes from blocking
|
||||||
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
|
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
|
||||||
@@ -737,34 +939,15 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
return profileData.posts
|
return profileData.posts
|
||||||
.filter((post: any) => !post.replyToId && !post.swarmReplyToId && !post.isReply)
|
.filter((post: any) => !post.replyToId && !post.swarmReplyToId && !post.isReply)
|
||||||
.map(post => ({
|
.map((post: any) => mapRemoteProfilePost({
|
||||||
id: `swarm:${domain}:${post.id}`,
|
...post,
|
||||||
content: post.content,
|
author: post.author || {
|
||||||
createdAt: new Date(post.createdAt),
|
handle,
|
||||||
likesCount: post.likesCount || 0,
|
displayName: follow.displayName || profileData.profile?.displayName || handle,
|
||||||
repostsCount: post.repostsCount || 0,
|
avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl,
|
||||||
repliesCount: post.repliesCount || 0,
|
isBot: profileData.profile?.isBot,
|
||||||
isRemote: true,
|
},
|
||||||
isNsfw: post.isNsfw,
|
}, domain));
|
||||||
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,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Home] Error fetching posts from ${follow.targetHandle}:`, error);
|
console.error(`[Home] Error fetching posts from ${follow.targetHandle}:`, error);
|
||||||
return [];
|
return [];
|
||||||
@@ -776,7 +959,7 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Merge and sort by date
|
// 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())
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
|
|
||||||
@@ -785,14 +968,7 @@ export async function GET(request: Request) {
|
|||||||
// Not authenticated, return public timeline
|
// Not authenticated, return public timeline
|
||||||
feedPosts = await db.query.posts.findMany({
|
feedPosts = await db.query.posts.findMany({
|
||||||
where: baseFilter,
|
where: baseFilter,
|
||||||
with: {
|
with: feedPostRelations,
|
||||||
author: true,
|
|
||||||
bot: true,
|
|
||||||
media: true,
|
|
||||||
replyTo: {
|
|
||||||
with: { author: true, media: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: [desc(posts.createdAt)],
|
orderBy: [desc(posts.createdAt)],
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
@@ -807,12 +983,13 @@ export async function GET(request: Request) {
|
|||||||
if (session?.user && feedPosts && feedPosts.length > 0) {
|
if (session?.user && feedPosts && feedPosts.length > 0) {
|
||||||
const viewer = session.user;
|
const viewer = session.user;
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
const allFeedPosts = collectNestedPosts(feedPosts as FeedPostWithChildren[]);
|
||||||
|
|
||||||
// Separate local and swarm posts
|
// Separate local and swarm posts
|
||||||
const localPostIds: string[] = [];
|
const localPostIds: string[] = [];
|
||||||
const swarmPosts: Array<{ id: string; domain: string; originalId: 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:')) {
|
if (p.id.startsWith('swarm:')) {
|
||||||
const parts = p.id.split(':');
|
const parts = p.id.split(':');
|
||||||
if (parts.length >= 3) {
|
if (parts.length >= 3) {
|
||||||
@@ -852,6 +1029,8 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
// Check swarm likes in real-time (query origin nodes)
|
// Check swarm likes in real-time (query origin nodes)
|
||||||
if (swarmPosts.length > 0) {
|
if (swarmPosts.length > 0) {
|
||||||
|
const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts');
|
||||||
|
|
||||||
const checkPromises = swarmPosts.map(async (sp) => {
|
const checkPromises = swarmPosts.map(async (sp) => {
|
||||||
try {
|
try {
|
||||||
const protocol = sp.domain.includes('localhost') ? 'http' : 'https';
|
const protocol = sp.domain.includes('localhost') ? 'http' : 'https';
|
||||||
@@ -874,13 +1053,23 @@ export async function GET(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(checkPromises);
|
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 }) => ({
|
feedPosts = applyInteractionFlags(
|
||||||
...p,
|
feedPosts as FeedPostWithChildren[],
|
||||||
isLiked: likedPostIds.has(p.id),
|
likedPostIds,
|
||||||
isReposted: repostedPostIds.has(p.id),
|
repostedPostIds
|
||||||
})) as any;
|
) as any;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error populating interaction flags:', 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 { fetchSwarmTimeline } from '@/lib/swarm/timeline';
|
||||||
import { getSession } from '@/lib/auth';
|
import { getSession } from '@/lib/auth';
|
||||||
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
|
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
|
* GET /api/posts/swarm
|
||||||
@@ -35,9 +80,10 @@ export async function GET(request: NextRequest) {
|
|||||||
const session = await getSession().catch(() => null);
|
const session = await getSession().catch(() => null);
|
||||||
const viewer = session?.user;
|
const viewer = session?.user;
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
const allTimelinePosts = collectNestedSwarmPosts(timeline.posts as SwarmFeedPost[]);
|
||||||
const likedPostIds = viewer
|
const likedPostIds = viewer
|
||||||
? await getViewerSwarmLikedPostIds(
|
? await getViewerSwarmLikedPostIds(
|
||||||
timeline.posts.map(post => ({
|
allTimelinePosts.map(post => ({
|
||||||
id: `swarm:${post.nodeDomain}:${post.id}`,
|
id: `swarm:${post.nodeDomain}:${post.id}`,
|
||||||
nodeDomain: post.nodeDomain,
|
nodeDomain: post.nodeDomain,
|
||||||
originalPostId: post.id,
|
originalPostId: post.id,
|
||||||
@@ -46,12 +92,19 @@ export async function GET(request: NextRequest) {
|
|||||||
nodeDomain
|
nodeDomain
|
||||||
)
|
)
|
||||||
: new Set<string>();
|
: 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({
|
return NextResponse.json({
|
||||||
posts: timeline.posts.map(post => ({
|
posts: applyInteractionFlags(timeline.posts as SwarmFeedPost[], likedPostIds, repostedPostIds),
|
||||||
...post,
|
|
||||||
isLiked: likedPostIds.has(`swarm:${post.nodeDomain}:${post.id}`),
|
|
||||||
})),
|
|
||||||
sources: timeline.sources,
|
sources: timeline.sources,
|
||||||
cached: false,
|
cached: false,
|
||||||
fetchedAt: timeline.fetchedAt,
|
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 { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
||||||
import { discoverNode } from '@/lib/swarm/discovery';
|
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 = {
|
type SearchUser = {
|
||||||
id: string;
|
id: string;
|
||||||
handle: string;
|
handle: string;
|
||||||
@@ -176,11 +196,7 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
const postResults = await db.query.posts.findMany({
|
const postResults = await db.query.posts.findMany({
|
||||||
where: and(...postConditions),
|
where: and(...postConditions),
|
||||||
with: {
|
with: searchPostRelations,
|
||||||
author: true,
|
|
||||||
media: true,
|
|
||||||
bot: true,
|
|
||||||
},
|
|
||||||
orderBy: [desc(posts.createdAt)],
|
orderBy: [desc(posts.createdAt)],
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
import { db, chatConversations, chatMessages, users } from '@/db';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { getSession } from '@/lib/auth';
|
import { getSession } from '@/lib/auth';
|
||||||
|
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// Schema for conversation ID parameter
|
// Schema for conversation ID parameter
|
||||||
@@ -73,10 +74,14 @@ export async function DELETE(
|
|||||||
|
|
||||||
if (isRemote) {
|
if (isRemote) {
|
||||||
// Extract domain from handle (format: handle@domain)
|
// Extract domain from handle (format: handle@domain)
|
||||||
const domain = participant2Handle.split('@')[1];
|
const domain = normalizeNodeDomain(participant2Handle.split('@')[1]);
|
||||||
const handle = participant2Handle.split('@')[0];
|
const handle = participant2Handle.split('@')[0];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (await isNodeBlocked(domain)) {
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
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 { eq, and } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||||
|
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
|
||||||
|
|
||||||
const deletionSchema = z.object({
|
const deletionSchema = z.object({
|
||||||
senderHandle: z.string(),
|
senderHandle: z.string(),
|
||||||
@@ -30,6 +31,11 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const data = deletionSchema.parse(body);
|
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
|
// SECURITY: Verify the signature
|
||||||
const { signature, ...payload } = data;
|
const { signature, ...payload } = data;
|
||||||
@@ -37,7 +43,7 @@ export async function POST(request: NextRequest) {
|
|||||||
payload,
|
payload,
|
||||||
signature,
|
signature,
|
||||||
data.senderHandle,
|
data.senderHandle,
|
||||||
data.senderNodeDomain
|
senderNodeDomain
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
@@ -55,7 +61,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find the conversation with the sender
|
// Find the conversation with the sender
|
||||||
const senderFullHandle = `${data.senderHandle}@${data.senderNodeDomain}`;
|
const senderFullHandle = `${data.senderHandle}@${senderNodeDomain}`;
|
||||||
const conversation = await db.query.chatConversations.findFirst({
|
const conversation = await db.query.chatConversations.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(chatConversations.participant1Id, recipient.id),
|
eq(chatConversations.participant1Id, recipient.id),
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ const swarmPostSchema = z.object({
|
|||||||
linkPreviewTitle: z.string().optional(),
|
linkPreviewTitle: z.string().optional(),
|
||||||
linkPreviewDescription: z.string().optional(),
|
linkPreviewDescription: z.string().optional(),
|
||||||
linkPreviewImage: 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({
|
author: z.object({
|
||||||
handle: z.string(),
|
handle: z.string(),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { eq, and, sql } from 'drizzle-orm';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||||
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
|
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
|
||||||
|
import { buildNotificationTarget } from '@/lib/notifications';
|
||||||
|
|
||||||
const swarmFollowSchema = z.object({
|
const swarmFollowSchema = z.object({
|
||||||
targetHandle: localHandleSchema,
|
targetHandle: localHandleSchema,
|
||||||
@@ -107,6 +108,7 @@ export async function POST(request: NextRequest) {
|
|||||||
actorDisplayName: data.follow.followerDisplayName,
|
actorDisplayName: data.follow.followerDisplayName,
|
||||||
actorAvatarUrl: data.follow.followerAvatarUrl || null,
|
actorAvatarUrl: data.follow.followerAvatarUrl || null,
|
||||||
actorNodeDomain: data.follow.followerNodeDomain,
|
actorNodeDomain: data.follow.followerNodeDomain,
|
||||||
|
...(targetUser.isBot ? buildNotificationTarget(targetUser) : {}),
|
||||||
type: 'follow',
|
type: 'follow',
|
||||||
});
|
});
|
||||||
console.log(`[Swarm] Created follow notification for @${data.targetHandle} from ${data.follow.followerHandle}@${data.follow.followerNodeDomain}`);
|
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,
|
actorDisplayName: data.follow.followerDisplayName,
|
||||||
actorAvatarUrl: data.follow.followerAvatarUrl || null,
|
actorAvatarUrl: data.follow.followerAvatarUrl || null,
|
||||||
actorNodeDomain: data.follow.followerNodeDomain,
|
actorNodeDomain: data.follow.followerNodeDomain,
|
||||||
|
...buildNotificationTarget(targetUser),
|
||||||
type: 'follow',
|
type: 'follow',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { eq, and } from 'drizzle-orm';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||||
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
|
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
|
||||||
|
import { buildNotificationTarget } from '@/lib/notifications';
|
||||||
|
|
||||||
const swarmLikeSchema = z.object({
|
const swarmLikeSchema = z.object({
|
||||||
postId: z.string().uuid(),
|
postId: z.string().uuid(),
|
||||||
@@ -88,6 +89,8 @@ export async function POST(request: NextRequest) {
|
|||||||
.set({ likesCount: post.likesCount + 1 })
|
.set({ likesCount: post.likesCount + 1 })
|
||||||
.where(eq(posts.id, data.postId));
|
.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
|
// Create notification with actor info stored directly
|
||||||
try {
|
try {
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
@@ -98,6 +101,7 @@ export async function POST(request: NextRequest) {
|
|||||||
actorNodeDomain: data.like.actorNodeDomain,
|
actorNodeDomain: data.like.actorNodeDomain,
|
||||||
postId: data.postId,
|
postId: data.postId,
|
||||||
postContent: post.content?.slice(0, 200) || null,
|
postContent: post.content?.slice(0, 200) || null,
|
||||||
|
...(author?.isBot ? buildNotificationTarget(author as any) : {}),
|
||||||
type: 'like',
|
type: 'like',
|
||||||
});
|
});
|
||||||
console.log(`[Swarm] Created like notification for post ${data.postId} from ${data.like.actorHandle}@${data.like.actorNodeDomain}`);
|
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
|
// 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) {
|
if (author?.isBot && author.botOwnerId) {
|
||||||
try {
|
try {
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
@@ -119,6 +122,7 @@ export async function POST(request: NextRequest) {
|
|||||||
actorNodeDomain: data.like.actorNodeDomain,
|
actorNodeDomain: data.like.actorNodeDomain,
|
||||||
postId: data.postId,
|
postId: data.postId,
|
||||||
postContent: post.content?.slice(0, 200) || null,
|
postContent: post.content?.slice(0, 200) || null,
|
||||||
|
...buildNotificationTarget(author as any),
|
||||||
type: 'like',
|
type: 'like',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||||
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
|
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
|
||||||
|
import { buildNotificationTarget } from '@/lib/notifications';
|
||||||
|
|
||||||
const swarmMentionSchema = z.object({
|
const swarmMentionSchema = z.object({
|
||||||
mentionedHandle: localHandleSchema,
|
mentionedHandle: localHandleSchema,
|
||||||
@@ -73,6 +74,7 @@ export async function POST(request: NextRequest) {
|
|||||||
actorAvatarUrl: data.mention.actorAvatarUrl || null,
|
actorAvatarUrl: data.mention.actorAvatarUrl || null,
|
||||||
actorNodeDomain: data.mention.actorNodeDomain,
|
actorNodeDomain: data.mention.actorNodeDomain,
|
||||||
postContent: data.mention.postContent.slice(0, 200),
|
postContent: data.mention.postContent.slice(0, 200),
|
||||||
|
...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}),
|
||||||
type: 'mention',
|
type: 'mention',
|
||||||
});
|
});
|
||||||
console.log(`[Swarm] Created mention notification for @${data.mentionedHandle} from ${data.mention.actorHandle}@${data.mention.actorNodeDomain}`);
|
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,
|
actorAvatarUrl: data.mention.actorAvatarUrl || null,
|
||||||
actorNodeDomain: data.mention.actorNodeDomain,
|
actorNodeDomain: data.mention.actorNodeDomain,
|
||||||
postContent: data.mention.postContent.slice(0, 200),
|
postContent: data.mention.postContent.slice(0, 200),
|
||||||
|
...buildNotificationTarget(mentionedUser),
|
||||||
type: 'mention',
|
type: 'mention',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -7,11 +7,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db, posts, users, notifications } from '@/db';
|
import { db, posts, users, notifications, remoteReposts } from '@/db';
|
||||||
import { eq, sql } from 'drizzle-orm';
|
import { eq, sql, and } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||||
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
|
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
|
||||||
|
import { buildNotificationTarget } from '@/lib/notifications';
|
||||||
|
|
||||||
const swarmRepostSchema = z.object({
|
const swarmRepostSchema = z.object({
|
||||||
postId: z.string().uuid(),
|
postId: z.string().uuid(),
|
||||||
@@ -64,11 +65,34 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
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
|
// Increment repost count
|
||||||
await db.update(posts)
|
await db.update(posts)
|
||||||
.set({ repostsCount: sql`${posts.repostsCount} + 1` })
|
.set({ repostsCount: sql`${posts.repostsCount} + 1` })
|
||||||
.where(eq(posts.id, data.postId));
|
.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
|
// Create notification with actor info stored directly
|
||||||
try {
|
try {
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
@@ -79,6 +103,7 @@ export async function POST(request: NextRequest) {
|
|||||||
actorNodeDomain: data.repost.actorNodeDomain,
|
actorNodeDomain: data.repost.actorNodeDomain,
|
||||||
postId: data.postId,
|
postId: data.postId,
|
||||||
postContent: post.content?.slice(0, 200) || null,
|
postContent: post.content?.slice(0, 200) || null,
|
||||||
|
...(author?.isBot ? buildNotificationTarget(author as any) : {}),
|
||||||
type: 'repost',
|
type: 'repost',
|
||||||
});
|
});
|
||||||
console.log(`[Swarm] Created repost notification for post ${data.postId} from ${data.repost.actorHandle}@${data.repost.actorNodeDomain}`);
|
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
|
// 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) {
|
if (author?.isBot && author.botOwnerId) {
|
||||||
try {
|
try {
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
@@ -98,6 +122,7 @@ export async function POST(request: NextRequest) {
|
|||||||
actorNodeDomain: data.repost.actorNodeDomain,
|
actorNodeDomain: data.repost.actorNodeDomain,
|
||||||
postId: data.postId,
|
postId: data.postId,
|
||||||
postContent: post.content?.slice(0, 200) || null,
|
postContent: post.content?.slice(0, 200) || null,
|
||||||
|
...buildNotificationTarget(author as any),
|
||||||
type: 'repost',
|
type: 'repost',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db, posts } from '@/db';
|
import { db, posts, remoteReposts } from '@/db';
|
||||||
import { eq, sql } from 'drizzle-orm';
|
import { eq, sql, and } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||||
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
|
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 });
|
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
|
// Decrement repost count
|
||||||
await db.update(posts)
|
await db.update(posts)
|
||||||
.set({ repostsCount: sql`GREATEST(0, ${posts.repostsCount} - 1)` })
|
.set({ repostsCount: sql`GREATEST(0, ${posts.repostsCount} - 1)` })
|
||||||
.where(eq(posts.id, data.postId));
|
.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}`);
|
console.log(`[Swarm] Received unrepost from ${data.unrepost.actorHandle}@${data.unrepost.actorNodeDomain} on post ${data.postId}`);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 { eq, desc, and } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ id: string }> };
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
@@ -43,7 +44,62 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!post || post.isRemoved) {
|
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
|
// Get replies
|
||||||
@@ -84,6 +140,9 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
|||||||
linkPreviewTitle: post.linkPreviewTitle,
|
linkPreviewTitle: post.linkPreviewTitle,
|
||||||
linkPreviewDescription: post.linkPreviewDescription,
|
linkPreviewDescription: post.linkPreviewDescription,
|
||||||
linkPreviewImage: post.linkPreviewImage,
|
linkPreviewImage: post.linkPreviewImage,
|
||||||
|
linkPreviewType: post.linkPreviewType,
|
||||||
|
linkPreviewVideoUrl: post.linkPreviewVideoUrl,
|
||||||
|
linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson) || [],
|
||||||
},
|
},
|
||||||
replies: replies.map(r => {
|
replies: replies.map(r => {
|
||||||
const replyAuthor = r.author as any;
|
const replyAuthor = r.author as any;
|
||||||
@@ -103,6 +162,13 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
|||||||
url: m.url,
|
url: m.url,
|
||||||
altText: m.altText,
|
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 { z } from 'zod';
|
||||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||||
import { upsertRemoteUser } from '@/lib/swarm/user-cache';
|
import { upsertRemoteUser } from '@/lib/swarm/user-cache';
|
||||||
|
import { buildNotificationTarget } from '@/lib/notifications';
|
||||||
|
|
||||||
// Schema for incoming swarm reply
|
// Schema for incoming swarm reply
|
||||||
const swarmReplySchema = z.object({
|
const swarmReplySchema = z.object({
|
||||||
@@ -145,6 +146,8 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
await syncParentReplyCount(data.postId);
|
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) {
|
if (parentPost.userId !== remoteUser.id) {
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
userId: parentPost.userId,
|
userId: parentPost.userId,
|
||||||
@@ -154,8 +157,23 @@ export async function POST(request: NextRequest) {
|
|||||||
actorNodeDomain: sourceDomain,
|
actorNodeDomain: sourceDomain,
|
||||||
postId: data.postId,
|
postId: data.postId,
|
||||||
postContent: data.reply.content.slice(0, 200),
|
postContent: data.reply.content.slice(0, 200),
|
||||||
|
...(parentAuthor?.isBot ? buildNotificationTarget(parentAuthor as any) : {}),
|
||||||
type: 'reply',
|
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' });
|
return NextResponse.json({ success: true, message: 'Reply received' });
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db, posts, users, media, nodes } from '@/db';
|
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 {
|
export interface SwarmPost {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,6 +16,8 @@ export interface SwarmPost {
|
|||||||
isReply?: boolean;
|
isReply?: boolean;
|
||||||
replyToId?: string | null;
|
replyToId?: string | null;
|
||||||
swarmReplyToId?: string | null;
|
swarmReplyToId?: string | null;
|
||||||
|
repostOfId?: string | null;
|
||||||
|
repostOf?: SwarmPost | null;
|
||||||
author: {
|
author: {
|
||||||
handle: string;
|
handle: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -34,6 +37,74 @@ export interface SwarmPost {
|
|||||||
linkPreviewTitle?: string;
|
linkPreviewTitle?: string;
|
||||||
linkPreviewDescription?: string;
|
linkPreviewDescription?: string;
|
||||||
linkPreviewImage?: 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,
|
createdAt: posts.createdAt,
|
||||||
replyToId: posts.replyToId,
|
replyToId: posts.replyToId,
|
||||||
swarmReplyToId: posts.swarmReplyToId,
|
swarmReplyToId: posts.swarmReplyToId,
|
||||||
|
repostOfId: posts.repostOfId,
|
||||||
isNsfw: posts.isNsfw,
|
isNsfw: posts.isNsfw,
|
||||||
likesCount: posts.likesCount,
|
likesCount: posts.likesCount,
|
||||||
repostsCount: posts.repostsCount,
|
repostsCount: posts.repostsCount,
|
||||||
@@ -97,6 +169,9 @@ export async function GET(request: NextRequest) {
|
|||||||
linkPreviewTitle: posts.linkPreviewTitle,
|
linkPreviewTitle: posts.linkPreviewTitle,
|
||||||
linkPreviewDescription: posts.linkPreviewDescription,
|
linkPreviewDescription: posts.linkPreviewDescription,
|
||||||
linkPreviewImage: posts.linkPreviewImage,
|
linkPreviewImage: posts.linkPreviewImage,
|
||||||
|
linkPreviewType: posts.linkPreviewType,
|
||||||
|
linkPreviewVideoUrl: posts.linkPreviewVideoUrl,
|
||||||
|
linkPreviewMediaJson: posts.linkPreviewMediaJson,
|
||||||
authorHandle: users.handle,
|
authorHandle: users.handle,
|
||||||
authorDisplayName: users.displayName,
|
authorDisplayName: users.displayName,
|
||||||
authorAvatarUrl: users.avatarUrl,
|
authorAvatarUrl: users.avatarUrl,
|
||||||
@@ -112,47 +187,84 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
console.log(`[Swarm Timeline API] Found ${recentPosts.length} posts for ${nodeDomain}`);
|
console.log(`[Swarm Timeline API] Found ${recentPosts.length} posts for ${nodeDomain}`);
|
||||||
|
|
||||||
// Fetch media for each post
|
const repostIds = Array.from(new Set(
|
||||||
const swarmPosts: SwarmPost[] = [];
|
recentPosts
|
||||||
|
.map(post => post.repostOfId)
|
||||||
|
.filter((id): id is string => Boolean(id))
|
||||||
|
));
|
||||||
|
|
||||||
for (const post of recentPosts) {
|
const repostTargets = repostIds.length > 0
|
||||||
const postMedia = await db
|
? await db
|
||||||
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
|
.select({
|
||||||
.from(media)
|
id: posts.id,
|
||||||
.where(eq(media.postId, post.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({
|
const mediaPostIds = Array.from(new Set([
|
||||||
id: post.id,
|
...recentPosts.map(post => post.id),
|
||||||
content: post.content,
|
...repostTargets.map(post => post.id),
|
||||||
createdAt: post.createdAt.toISOString(),
|
]));
|
||||||
isReply: Boolean(post.replyToId || post.swarmReplyToId),
|
|
||||||
replyToId: post.replyToId,
|
const mediaRows = mediaPostIds.length > 0
|
||||||
swarmReplyToId: post.swarmReplyToId,
|
? await db
|
||||||
author: {
|
.select({
|
||||||
handle: post.authorHandle,
|
postId: media.postId,
|
||||||
displayName: post.authorDisplayName || post.authorHandle,
|
url: media.url,
|
||||||
avatarUrl: post.authorAvatarUrl || undefined,
|
mimeType: media.mimeType,
|
||||||
isNsfw: post.authorIsNsfw,
|
altText: media.altText,
|
||||||
isBot: post.authorIsBot,
|
})
|
||||||
},
|
.from(media)
|
||||||
nodeDomain,
|
.where(inArray(media.postId, mediaPostIds))
|
||||||
nodeIsNsfw,
|
: [];
|
||||||
isNsfw: post.isNsfw || post.authorIsNsfw, // Post-level NSFW (not node-level)
|
|
||||||
likeCount: post.likesCount,
|
const mediaByPostId = new Map<string, Array<{ url: string; mimeType?: string; altText?: string }>>();
|
||||||
repostCount: post.repostsCount,
|
for (const item of mediaRows) {
|
||||||
replyCount: post.repliesCount,
|
if (!item.postId) continue;
|
||||||
media: postMedia.length > 0 ? postMedia.map(m => ({
|
const bucket = mediaByPostId.get(item.postId) || [];
|
||||||
url: m.url,
|
bucket.push({
|
||||||
mimeType: m.mimeType || undefined,
|
url: item.url,
|
||||||
altText: m.altText || undefined,
|
mimeType: item.mimeType || undefined,
|
||||||
})) : undefined,
|
altText: item.altText || undefined,
|
||||||
linkPreviewUrl: post.linkPreviewUrl || undefined,
|
|
||||||
linkPreviewTitle: post.linkPreviewTitle || undefined,
|
|
||||||
linkPreviewDescription: post.linkPreviewDescription || undefined,
|
|
||||||
linkPreviewImage: post.linkPreviewImage || 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({
|
return NextResponse.json({
|
||||||
posts: swarmPosts,
|
posts: swarmPosts,
|
||||||
nodeDomain,
|
nodeDomain,
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 { eq, desc, and, isNull } from 'drizzle-orm';
|
||||||
|
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
||||||
|
|
||||||
export interface SwarmUserProfile {
|
export interface SwarmUserProfile {
|
||||||
handle: string;
|
handle: string;
|
||||||
@@ -28,21 +29,144 @@ export interface SwarmUserProfile {
|
|||||||
|
|
||||||
export interface SwarmUserPost {
|
export interface SwarmUserPost {
|
||||||
id: string;
|
id: string;
|
||||||
|
originalPostId?: string;
|
||||||
content: string;
|
content: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
isNsfw: boolean;
|
isNsfw: boolean;
|
||||||
likesCount: number;
|
likesCount: number;
|
||||||
repostsCount: number;
|
repostsCount: number;
|
||||||
repliesCount: number;
|
repliesCount: number;
|
||||||
|
nodeDomain?: string;
|
||||||
|
author?: {
|
||||||
|
handle: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
isBot?: boolean;
|
||||||
|
nodeDomain?: string;
|
||||||
|
};
|
||||||
media?: { url: string; mimeType?: string; altText?: string }[];
|
media?: { url: string; mimeType?: string; altText?: string }[];
|
||||||
linkPreviewUrl?: string;
|
linkPreviewUrl?: string;
|
||||||
linkPreviewTitle?: string;
|
linkPreviewTitle?: string;
|
||||||
linkPreviewDescription?: string;
|
linkPreviewDescription?: string;
|
||||||
linkPreviewImage?: 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 }> };
|
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]
|
* GET /api/swarm/users/[handle]
|
||||||
*
|
*
|
||||||
@@ -97,61 +221,30 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
|||||||
did: user.did || undefined,
|
did: user.did || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get user's recent posts
|
const localPosts = await db.query.posts.findMany({
|
||||||
const userPosts = await db
|
where: and(
|
||||||
.select({
|
eq(posts.userId, user.id),
|
||||||
id: posts.id,
|
eq(posts.isRemoved, false),
|
||||||
content: posts.content,
|
isNull(posts.replyToId),
|
||||||
createdAt: posts.createdAt,
|
isNull(posts.swarmReplyToId)
|
||||||
isNsfw: posts.isNsfw,
|
),
|
||||||
likesCount: posts.likesCount,
|
with: profilePostRelations,
|
||||||
repostsCount: posts.repostsCount,
|
orderBy: [desc(posts.createdAt)],
|
||||||
repliesCount: posts.repliesCount,
|
limit: limit * 2,
|
||||||
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);
|
|
||||||
|
|
||||||
// Fetch media for each post
|
const remoteRepostRows = await db.query.userSwarmReposts.findMany({
|
||||||
const swarmPosts: SwarmUserPost[] = [];
|
where: eq(userSwarmReposts.userId, user.id),
|
||||||
|
orderBy: [desc(userSwarmReposts.repostedAt)],
|
||||||
|
limit: limit * 2,
|
||||||
|
});
|
||||||
|
|
||||||
for (const post of userPosts) {
|
const swarmPosts: SwarmUserPost[] = [
|
||||||
const postMedia = await db
|
...localPosts.map((post) => mapLocalPostToSwarmPost(post, nodeDomain)),
|
||||||
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
|
...remoteRepostRows.map((row) => mapUserSwarmRepostToSwarmPost(row, user, nodeDomain)),
|
||||||
.from(media)
|
]
|
||||||
.where(eq(media.postId, post.id));
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.slice(0, limit);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
profile,
|
profile,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { requireAuth } from '@/lib/auth';
|
|||||||
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
||||||
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
|
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
|
||||||
import { discoverNode } from '@/lib/swarm/discovery';
|
import { discoverNode } from '@/lib/swarm/discovery';
|
||||||
|
import { buildNotificationTarget } from '@/lib/notifications';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ handle: string }> };
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
@@ -220,6 +221,7 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
actorDisplayName: currentUser.displayName,
|
actorDisplayName: currentUser.displayName,
|
||||||
actorAvatarUrl: currentUser.avatarUrl,
|
actorAvatarUrl: currentUser.avatarUrl,
|
||||||
actorNodeDomain: null,
|
actorNodeDomain: null,
|
||||||
|
...(targetUser.isBot ? buildNotificationTarget(targetUser) : {}),
|
||||||
type: 'follow',
|
type: 'follow',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -232,6 +234,7 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
actorDisplayName: currentUser.displayName,
|
actorDisplayName: currentUser.displayName,
|
||||||
actorAvatarUrl: currentUser.avatarUrl,
|
actorAvatarUrl: currentUser.avatarUrl,
|
||||||
actorNodeDomain: null,
|
actorNodeDomain: null,
|
||||||
|
...buildNotificationTarget(targetUser),
|
||||||
type: 'follow',
|
type: 'follow',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,34 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db, likes, posts, users, userSwarmLikes } from '@/db';
|
import { db, likes, posts, users, userSwarmLikes } from '@/db';
|
||||||
import { eq, desc, and, inArray } from 'drizzle-orm';
|
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 }> };
|
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) => {
|
const parseMediaJson = (mediaJson: string | null) => {
|
||||||
if (!mediaJson) {
|
if (!mediaJson) {
|
||||||
return [];
|
return [];
|
||||||
@@ -22,11 +47,88 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
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
|
// Find the user
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: eq(users.handle, cleanHandle),
|
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) {
|
if (!user) {
|
||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
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),
|
where: eq(likes.userId, user.id),
|
||||||
with: {
|
with: {
|
||||||
post: {
|
post: {
|
||||||
with: {
|
with: likedPostRelations,
|
||||||
author: true,
|
|
||||||
media: true,
|
|
||||||
bot: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: [desc(likes.createdAt)],
|
orderBy: [desc(likes.createdAt)],
|
||||||
@@ -82,6 +180,9 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
linkPreviewTitle: like.linkPreviewTitle,
|
linkPreviewTitle: like.linkPreviewTitle,
|
||||||
linkPreviewDescription: like.linkPreviewDescription,
|
linkPreviewDescription: like.linkPreviewDescription,
|
||||||
linkPreviewImage: like.linkPreviewImage,
|
linkPreviewImage: like.linkPreviewImage,
|
||||||
|
linkPreviewType: like.linkPreviewType,
|
||||||
|
linkPreviewVideoUrl: like.linkPreviewVideoUrl,
|
||||||
|
linkPreviewMedia: parseLinkPreviewMediaJson(like.linkPreviewMediaJson) || null,
|
||||||
isSwarm: true,
|
isSwarm: true,
|
||||||
nodeDomain: like.nodeDomain,
|
nodeDomain: like.nodeDomain,
|
||||||
likedAt: like.likedAt.toISOString(),
|
likedAt: like.likedAt.toISOString(),
|
||||||
@@ -110,6 +211,17 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
.filter((post: any) => !post.isSwarm)
|
.filter((post: any) => !post.isSwarm)
|
||||||
.map((post: any) => post.id)
|
.map((post: any) => post.id)
|
||||||
.filter(Boolean);
|
.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) {
|
if (localPostIds.length > 0) {
|
||||||
const viewerLikes = await db.query.likes.findMany({
|
const viewerLikes = await db.query.likes.findMany({
|
||||||
@@ -132,12 +244,13 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
likedPosts = likedPosts.map(p => ({
|
likedPosts = likedPosts.map(p => ({
|
||||||
...p!,
|
...p!,
|
||||||
isLiked: p!.isSwarm ? isOwnLikesView : likedPostIds.has(p!.id),
|
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;
|
})) as any;
|
||||||
} else {
|
} else {
|
||||||
likedPosts = likedPosts.map(p => ({
|
likedPosts = likedPosts.map(p => ({
|
||||||
...p!,
|
...p!,
|
||||||
isLiked: p!.isSwarm ? isOwnLikesView : p!.isLiked,
|
isLiked: p!.isSwarm ? isOwnLikesView : p!.isLiked,
|
||||||
|
isReposted: p!.isSwarm ? swarmRepostedIds.has(p!.id) : p!.isReposted,
|
||||||
})) as any;
|
})) as any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,169 @@
|
|||||||
import { NextResponse } from 'next/server';
|
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 { eq, desc, and, inArray, lt, sql, isNull } from 'drizzle-orm';
|
||||||
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
||||||
import { discoverNode } from '@/lib/swarm/discovery';
|
import { discoverNode } from '@/lib/swarm/discovery';
|
||||||
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
|
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 }> };
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
const parseRemoteHandle = (handle: string) => {
|
type FeedPostWithChildren = {
|
||||||
const clean = handle.toLowerCase().replace(/^@/, '');
|
id: string;
|
||||||
const parts = clean.split('@').filter(Boolean);
|
createdAt?: string | Date;
|
||||||
if (parts.length === 2) {
|
repostOf?: FeedPostWithChildren | null;
|
||||||
return { handle: parts[0], domain: parts[1] };
|
replyTo?: FeedPostWithChildren | null;
|
||||||
}
|
isLiked?: boolean;
|
||||||
return null;
|
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(
|
async function populateViewerLikeState(
|
||||||
remotePosts: any[],
|
remotePosts: any[]
|
||||||
domain: string
|
|
||||||
) {
|
) {
|
||||||
if (!remotePosts.length) {
|
if (!remotePosts.length) {
|
||||||
return remotePosts;
|
return remotePosts;
|
||||||
@@ -34,20 +179,32 @@ async function populateViewerLikeState(
|
|||||||
return remotePosts;
|
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(
|
const likedIds = await getViewerSwarmLikedPostIds(
|
||||||
remotePosts.map((post) => ({
|
swarmTargets,
|
||||||
id: `swarm:${domain}:${post.originalPostId}`,
|
|
||||||
nodeDomain: domain,
|
|
||||||
originalPostId: post.originalPostId,
|
|
||||||
})),
|
|
||||||
viewer.handle,
|
viewer.handle,
|
||||||
nodeDomain
|
nodeDomain
|
||||||
);
|
);
|
||||||
|
const repostedIds = await getViewerSwarmRepostedPostIds(
|
||||||
|
swarmTargets,
|
||||||
|
viewer.id
|
||||||
|
);
|
||||||
|
|
||||||
return remotePosts.map((post) => ({
|
return applyInteractionFlags(
|
||||||
...post,
|
remotePosts as FeedPostWithChildren[],
|
||||||
isLiked: likedIds.has(`swarm:${domain}:${post.originalPostId}`),
|
likedIds,
|
||||||
}));
|
repostedIds
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return remotePosts;
|
return remotePosts;
|
||||||
}
|
}
|
||||||
@@ -62,6 +219,31 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
const cursor = searchParams.get('cursor');
|
const cursor = searchParams.get('cursor');
|
||||||
|
|
||||||
const remote = parseRemoteHandle(handle);
|
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 (!db) {
|
||||||
if (!remote) {
|
if (!remote) {
|
||||||
@@ -104,11 +286,14 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
linkPreviewTitle: post.linkPreviewTitle || null,
|
linkPreviewTitle: post.linkPreviewTitle || null,
|
||||||
linkPreviewDescription: post.linkPreviewDescription || null,
|
linkPreviewDescription: post.linkPreviewDescription || null,
|
||||||
linkPreviewImage: post.linkPreviewImage || null,
|
linkPreviewImage: post.linkPreviewImage || null,
|
||||||
|
linkPreviewType: post.linkPreviewType || null,
|
||||||
|
linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
|
||||||
|
linkPreviewMedia: post.linkPreviewMedia || null,
|
||||||
isSwarm: true,
|
isSwarm: true,
|
||||||
nodeDomain: remote.domain,
|
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: [] });
|
return NextResponse.json({ posts: [] });
|
||||||
@@ -118,8 +303,9 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: eq(users.handle, cleanHandle),
|
where: eq(users.handle, cleanHandle),
|
||||||
});
|
});
|
||||||
|
const isRemotePlaceholder = user && cleanHandle.includes('@');
|
||||||
|
|
||||||
if (!user) {
|
if (!user || isRemotePlaceholder) {
|
||||||
if (!remote) {
|
if (!remote) {
|
||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
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' });
|
return NextResponse.json({ posts: [], message: 'Only Synapsis swarm nodes are supported' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileData = await fetchSwarmUserProfile(remote.handle, remote.domain, limit);
|
return await fetchRemotePostsRoute();
|
||||||
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: [] });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.isSuspended) {
|
if (user.isSuspended) {
|
||||||
@@ -175,43 +329,49 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get user's posts with cursor-based pagination
|
// Get user's posts with cursor-based pagination
|
||||||
|
const cursorDate = await getMixedProfileCursorDate(cursor);
|
||||||
let whereConditions = and(
|
let whereConditions = and(
|
||||||
eq(posts.userId, user.id),
|
eq(posts.userId, user.id),
|
||||||
eq(posts.isRemoved, false),
|
eq(posts.isRemoved, false),
|
||||||
isNull(posts.replyToId),
|
isNull(posts.replyToId),
|
||||||
isNull(posts.swarmReplyToId)
|
isNull(posts.swarmReplyToId)
|
||||||
);
|
);
|
||||||
|
|
||||||
// If cursor provided, get posts older than the cursor
|
if (cursorDate) {
|
||||||
if (cursor) {
|
whereConditions = and(
|
||||||
const cursorPost = await db.query.posts.findFirst({
|
eq(posts.userId, user.id),
|
||||||
where: eq(posts.id, cursor),
|
eq(posts.isRemoved, false),
|
||||||
});
|
isNull(posts.replyToId),
|
||||||
if (cursorPost) {
|
isNull(posts.swarmReplyToId),
|
||||||
whereConditions = and(
|
lt(posts.createdAt, cursorDate)
|
||||||
eq(posts.userId, user.id),
|
);
|
||||||
eq(posts.isRemoved, false),
|
|
||||||
isNull(posts.replyToId),
|
|
||||||
isNull(posts.swarmReplyToId),
|
|
||||||
lt(posts.createdAt, cursorPost.createdAt)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let userPosts: any[] = await db.query.posts.findMany({
|
const localPosts = await db.query.posts.findMany({
|
||||||
where: whereConditions,
|
where: whereConditions,
|
||||||
with: {
|
with: userPostRelations,
|
||||||
author: true,
|
|
||||||
media: true,
|
|
||||||
bot: true,
|
|
||||||
replyTo: {
|
|
||||||
with: { author: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: [desc(posts.createdAt)],
|
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
|
// Populate isLiked and isReposted for authenticated users
|
||||||
try {
|
try {
|
||||||
const { getSession } = await import('@/lib/auth');
|
const { getSession } = await import('@/lib/auth');
|
||||||
@@ -219,32 +379,71 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
|
|
||||||
if (session?.user && userPosts.length > 0) {
|
if (session?.user && userPosts.length > 0) {
|
||||||
const viewer = session.user;
|
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({
|
const viewerLikes = await db.query.likes.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(likes.userId, viewer.id),
|
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({
|
const viewerReposts = await db.query.posts.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(posts.userId, viewer.id),
|
eq(posts.userId, viewer.id),
|
||||||
inArray(posts.repostOfId, postIds),
|
inArray(posts.repostOfId, localPostIds),
|
||||||
eq(posts.isRemoved, false)
|
eq(posts.isRemoved, false)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
|
viewerReposts.forEach((repost) => {
|
||||||
|
if (repost.repostOfId) {
|
||||||
userPosts = userPosts.map(p => ({
|
repostedPostIds.add(repost.repostOfId);
|
||||||
...p,
|
}
|
||||||
isLiked: likedPostIds.has(p.id),
|
});
|
||||||
isReposted: repostedPostIds.has(p.id),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('Error populating interaction flags:', 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);
|
const profileData = await fetchSwarmUserProfile(remoteHandle, remoteDomain, 0);
|
||||||
if (profileData?.profile) {
|
if (profileData?.profile) {
|
||||||
const profile = 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
|
// CACHE: Upsert the remote user into our local database
|
||||||
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
|
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
|
||||||
@@ -80,6 +89,12 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
isSwarm: true,
|
isSwarm: true,
|
||||||
nodeDomain: remoteDomain,
|
nodeDomain: remoteDomain,
|
||||||
isBot: profile.isBot || false,
|
isBot: profile.isBot || false,
|
||||||
|
botOwner: normalizedBotOwnerHandle && botOwnerLocalHandle ? {
|
||||||
|
id: `swarm:${remoteDomain}:${botOwnerLocalHandle}`,
|
||||||
|
handle: normalizedBotOwnerHandle,
|
||||||
|
displayName: botOwnerLocalHandle,
|
||||||
|
avatarUrl: null,
|
||||||
|
} : null,
|
||||||
did: profile.did,
|
did: profile.did,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export default function EditBotPage() {
|
|||||||
systemPrompt: '',
|
systemPrompt: '',
|
||||||
llmProvider: 'openai',
|
llmProvider: 'openai',
|
||||||
llmModel: 'gpt-4',
|
llmModel: 'gpt-4',
|
||||||
|
llmEndpoint: '',
|
||||||
llmApiKey: '',
|
llmApiKey: '',
|
||||||
autonomousMode: false,
|
autonomousMode: false,
|
||||||
|
|
||||||
@@ -137,6 +138,7 @@ export default function EditBotPage() {
|
|||||||
systemPrompt: personalityConfig.systemPrompt || '',
|
systemPrompt: personalityConfig.systemPrompt || '',
|
||||||
llmProvider: bot.llmProvider || 'openai',
|
llmProvider: bot.llmProvider || 'openai',
|
||||||
llmModel: bot.llmModel || 'gpt-4',
|
llmModel: bot.llmModel || 'gpt-4',
|
||||||
|
llmEndpoint: bot.llmEndpoint || '',
|
||||||
llmApiKey: '', // Don't pre-fill API key for security
|
llmApiKey: '', // Don't pre-fill API key for security
|
||||||
autonomousMode: bot.autonomousMode || false,
|
autonomousMode: bot.autonomousMode || false,
|
||||||
postingFrequency,
|
postingFrequency,
|
||||||
@@ -288,6 +290,7 @@ export default function EditBotPage() {
|
|||||||
},
|
},
|
||||||
llmProvider: formData.llmProvider,
|
llmProvider: formData.llmProvider,
|
||||||
llmModel: formData.llmModel,
|
llmModel: formData.llmModel,
|
||||||
|
llmEndpoint: formData.llmProvider === 'custom' ? formData.llmEndpoint : null,
|
||||||
autonomousMode: formData.autonomousMode,
|
autonomousMode: formData.autonomousMode,
|
||||||
schedule: formData.autonomousMode ? {
|
schedule: formData.autonomousMode ? {
|
||||||
type: 'interval',
|
type: 'interval',
|
||||||
@@ -482,9 +485,29 @@ export default function EditBotPage() {
|
|||||||
<option value="openai">OpenAI</option>
|
<option value="openai">OpenAI</option>
|
||||||
<option value="anthropic">Anthropic</option>
|
<option value="anthropic">Anthropic</option>
|
||||||
<option value="openrouter">OpenRouter</option>
|
<option value="openrouter">OpenRouter</option>
|
||||||
|
<option value="custom">Custom (OpenAI Compatible)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||||
Model
|
Model
|
||||||
@@ -850,22 +873,23 @@ export default function EditBotPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{formData.autonomousMode && (
|
||||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
<div>
|
||||||
Posting Frequency
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||||
</label>
|
Posting Frequency
|
||||||
<select
|
</label>
|
||||||
value={formData.postingFrequency}
|
<select
|
||||||
onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })}
|
value={formData.postingFrequency}
|
||||||
className="input"
|
onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })}
|
||||||
disabled={!formData.autonomousMode}
|
className="input"
|
||||||
>
|
>
|
||||||
<option value="every_2_hours">Every 2 Hours</option>
|
<option value="every_2_hours">Every 2 Hours</option>
|
||||||
<option value="every_4_hours">Every 4 Hours</option>
|
<option value="every_4_hours">Every 4 Hours</option>
|
||||||
<option value="every_6_hours">Every 6 Hours</option>
|
<option value="every_6_hours">Every 6 Hours</option>
|
||||||
<option value="daily">Once Daily</option>
|
<option value="daily">Once Daily</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+41
-18
@@ -52,6 +52,7 @@ export default function NewBotPage() {
|
|||||||
systemPrompt: '',
|
systemPrompt: '',
|
||||||
llmProvider: 'openai',
|
llmProvider: 'openai',
|
||||||
llmModel: 'gpt-4',
|
llmModel: 'gpt-4',
|
||||||
|
llmEndpoint: '',
|
||||||
llmApiKey: '',
|
llmApiKey: '',
|
||||||
autonomousMode: false,
|
autonomousMode: false,
|
||||||
postingFrequency: 'every_4_hours',
|
postingFrequency: 'every_4_hours',
|
||||||
@@ -88,6 +89,7 @@ export default function NewBotPage() {
|
|||||||
...prev,
|
...prev,
|
||||||
llmProvider: lastBot.llmProvider || 'openai',
|
llmProvider: lastBot.llmProvider || 'openai',
|
||||||
llmModel: lastBot.llmModel || 'gpt-4',
|
llmModel: lastBot.llmModel || 'gpt-4',
|
||||||
|
llmEndpoint: lastBot.llmEndpoint || '',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,6 +239,7 @@ export default function NewBotPage() {
|
|||||||
},
|
},
|
||||||
llmProvider: formData.llmProvider,
|
llmProvider: formData.llmProvider,
|
||||||
llmModel: formData.llmModel,
|
llmModel: formData.llmModel,
|
||||||
|
llmEndpoint: formData.llmProvider === 'custom' ? formData.llmEndpoint : undefined,
|
||||||
llmApiKey: formData.llmApiKey,
|
llmApiKey: formData.llmApiKey,
|
||||||
autonomousMode: formData.autonomousMode,
|
autonomousMode: formData.autonomousMode,
|
||||||
schedule: formData.autonomousMode ? {
|
schedule: formData.autonomousMode ? {
|
||||||
@@ -430,9 +433,28 @@ export default function NewBotPage() {
|
|||||||
<option value="openai">OpenAI</option>
|
<option value="openai">OpenAI</option>
|
||||||
<option value="anthropic">Anthropic</option>
|
<option value="anthropic">Anthropic</option>
|
||||||
<option value="openrouter">OpenRouter</option>
|
<option value="openrouter">OpenRouter</option>
|
||||||
|
<option value="custom">Custom (OpenAI Compatible)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||||
Model
|
Model
|
||||||
@@ -445,7 +467,7 @@ export default function NewBotPage() {
|
|||||||
placeholder="gpt-4"
|
placeholder="gpt-4"
|
||||||
/>
|
/>
|
||||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -782,22 +804,23 @@ export default function NewBotPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{formData.autonomousMode && (
|
||||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
<div>
|
||||||
Posting Frequency
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||||
</label>
|
Posting Frequency
|
||||||
<select
|
</label>
|
||||||
value={formData.postingFrequency}
|
<select
|
||||||
onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })}
|
value={formData.postingFrequency}
|
||||||
className="input"
|
onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })}
|
||||||
disabled={!formData.autonomousMode}
|
className="input"
|
||||||
>
|
>
|
||||||
<option value="every_2_hours">Every 2 Hours</option>
|
<option value="every_2_hours">Every 2 Hours</option>
|
||||||
<option value="every_4_hours">Every 4 Hours</option>
|
<option value="every_4_hours">Every 4 Hours</option>
|
||||||
<option value="every_6_hours">Every 6 Hours</option>
|
<option value="every_6_hours">Every 6 Hours</option>
|
||||||
<option value="daily">Once Daily</option>
|
<option value="daily">Once Daily</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -895,7 +918,7 @@ export default function NewBotPage() {
|
|||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={
|
disabled={
|
||||||
(step === 'identity' && (!formData.name || !formData.handle)) ||
|
(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
|
Next
|
||||||
|
|||||||
+126
-3
@@ -246,10 +246,18 @@ a.btn-primary:visited {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
@@ -258,6 +266,8 @@ a.btn-primary:visited {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout.hide-right-sidebar .main {
|
.layout.hide-right-sidebar .main {
|
||||||
@@ -320,6 +330,22 @@ a.btn-primary:visited {
|
|||||||
padding-top: 8px;
|
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 */
|
||||||
.post {
|
.post {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -359,6 +385,34 @@ a.btn-primary:visited {
|
|||||||
word-wrap: break-word;
|
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 {
|
.post-reasons {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -2402,6 +2456,54 @@ a.btn-primary:visited {
|
|||||||
border-bottom: 1px solid var(--border);
|
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 {
|
.link-preview-info {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
@@ -2455,22 +2557,43 @@ a.btn-primary:visited {
|
|||||||
|
|
||||||
.link-preview-card.mini {
|
.link-preview-card.mini {
|
||||||
display: flex;
|
display: flex;
|
||||||
max-height: 80px;
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-preview-card.mini .link-preview-image {
|
.link-preview-card.mini .link-preview-image {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
height: 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;
|
height: 80px;
|
||||||
|
width: 80px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
border-right: 1px solid var(--border);
|
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 {
|
.link-preview-card.mini .link-preview-info {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2589,4 +2712,4 @@ a.btn-primary:visited {
|
|||||||
.layout.hide-right-sidebar .main {
|
.layout.hide-right-sidebar .main {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-11
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Image from 'next/image';
|
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 { decryptPrivateKey } from '@/lib/crypto/private-key-client';
|
||||||
import { keyStore, importPrivateKey } from '@/lib/crypto/user-signing';
|
import { keyStore, importPrivateKey } from '@/lib/crypto/user-signing';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
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 router = useRouter();
|
||||||
const [mode, setMode] = useState<'login' | 'register' | 'import'>('login');
|
const [mode, setMode] = useState<'login' | 'register' | 'import'>('login');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -204,7 +210,12 @@ export default function LoginPage() {
|
|||||||
setImportSuccess(data.message);
|
setImportSuccess(data.message);
|
||||||
// Soft navigation to preserve AuthContext/KeyStore state
|
// Soft navigation to preserve AuthContext/KeyStore state
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push('/');
|
router.refresh();
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -319,7 +330,12 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Soft navigation to preserve AuthContext/KeyStore state
|
// Soft navigation to preserve AuthContext/KeyStore state
|
||||||
router.push('/');
|
router.refresh();
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
// Reset Turnstile on error
|
// Reset Turnstile on error
|
||||||
@@ -332,15 +348,17 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<div style={{
|
<div style={{
|
||||||
minHeight: '100vh',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: '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 */}
|
{/* Logo */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '32px', minHeight: '120px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '32px', minHeight: '120px' }}>
|
||||||
{nodeInfoLoaded && (
|
{nodeInfoLoaded && (
|
||||||
@@ -984,10 +1002,86 @@ export default function LoginPage() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p style={{ textAlign: 'center', marginTop: '24px', color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
{!modal && (
|
||||||
<Link href="/">← Back to home</Link>
|
<p style={{ textAlign: 'center', marginTop: '24px', color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
||||||
</p>
|
<Link href="/">← Back to home</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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;
|
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 formatDate = (value: string) => {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return date.toLocaleString();
|
return date.toLocaleString();
|
||||||
@@ -52,12 +66,15 @@ const formatDate = (value: string) => {
|
|||||||
|
|
||||||
export default function ModerationPage() {
|
export default function ModerationPage() {
|
||||||
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
|
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 [reports, setReports] = useState<Report[]>([]);
|
||||||
const [posts, setPosts] = useState<AdminPost[]>([]);
|
const [posts, setPosts] = useState<AdminPost[]>([]);
|
||||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [nodes, setNodes] = useState<AdminNode[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [reportStatus, setReportStatus] = useState<'open' | 'resolved' | 'all'>('open');
|
const [reportStatus, setReportStatus] = useState<'open' | 'resolved' | 'all'>('open');
|
||||||
|
const [nodeDomain, setNodeDomain] = useState('');
|
||||||
|
const [nodeReason, setNodeReason] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/admin/me')
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin) return;
|
if (!isAdmin) return;
|
||||||
if (tab === 'reports') loadReports();
|
if (tab === 'reports') loadReports();
|
||||||
if (tab === 'posts') loadPosts();
|
if (tab === 'posts') loadPosts();
|
||||||
if (tab === 'users') loadUsers();
|
if (tab === 'users') loadUsers();
|
||||||
|
if (tab === 'nodes') loadNodes();
|
||||||
}, [tab, isAdmin, reportStatus]);
|
}, [tab, isAdmin, reportStatus]);
|
||||||
|
|
||||||
const handleReportResolve = async (id: string, status: 'open' | 'resolved') => {
|
const handleReportResolve = async (id: string, status: 'open' | 'resolved') => {
|
||||||
@@ -147,6 +178,17 @@ export default function ModerationPage() {
|
|||||||
loadUsers();
|
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(() => {
|
const reportCounts = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
open: reports.filter((r) => r.status === 'open').length,
|
open: reports.filter((r) => r.status === 'open').length,
|
||||||
@@ -209,6 +251,12 @@ export default function ModerationPage() {
|
|||||||
>
|
>
|
||||||
Users
|
Users
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${tab === 'nodes' ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
|
onClick={() => setTab('nodes')}
|
||||||
|
>
|
||||||
|
Nodes
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'reports' && (
|
{tab === 'reports' && (
|
||||||
@@ -470,6 +518,114 @@ export default function ModerationPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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;
|
avatarUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NotificationTarget {
|
||||||
|
handle: string;
|
||||||
|
displayName: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
nodeDomain?: string | null;
|
||||||
|
isBot?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface NotificationPost {
|
interface NotificationPost {
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -24,6 +32,7 @@ interface Notification {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
readAt: string | null;
|
readAt: string | null;
|
||||||
actor: NotificationActor | null;
|
actor: NotificationActor | null;
|
||||||
|
target: NotificationTarget | null;
|
||||||
post: NotificationPost | null;
|
post: NotificationPost | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,17 +86,28 @@ export default function NotificationsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getNotificationText = (notification: Notification) => {
|
const getNotificationText = (notification: Notification) => {
|
||||||
|
const targetName = notification.target?.displayName || notification.target?.handle;
|
||||||
switch (notification.type) {
|
switch (notification.type) {
|
||||||
case 'follow':
|
case 'follow':
|
||||||
return 'followed you';
|
return notification.target?.isBot && targetName
|
||||||
|
? `followed your bot ${targetName}`
|
||||||
|
: 'followed you';
|
||||||
case 'like':
|
case 'like':
|
||||||
return 'liked your post';
|
return notification.target?.isBot && targetName
|
||||||
|
? `liked a post from ${targetName}`
|
||||||
|
: 'liked your post';
|
||||||
case 'repost':
|
case 'repost':
|
||||||
return 'reposted your post';
|
return notification.target?.isBot && targetName
|
||||||
|
? `reposted a post from ${targetName}`
|
||||||
|
: 'reposted your post';
|
||||||
case 'mention':
|
case 'mention':
|
||||||
return 'mentioned you';
|
return notification.target?.isBot && targetName
|
||||||
|
? `mentioned your bot ${targetName}`
|
||||||
|
: 'mentioned you';
|
||||||
case 'reply':
|
case 'reply':
|
||||||
return 'replied to your post';
|
return notification.target?.isBot && targetName
|
||||||
|
? `replied to a post from ${targetName}`
|
||||||
|
: 'replied to your post';
|
||||||
default:
|
default:
|
||||||
return 'interacted with you';
|
return 'interacted with you';
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-22
@@ -175,7 +175,7 @@ export default function ProfilePage() {
|
|||||||
if (!repliesCursor || repliesLoadingMore || !user) return;
|
if (!repliesCursor || repliesLoadingMore || !user) return;
|
||||||
setRepliesLoadingMore(true);
|
setRepliesLoadingMore(true);
|
||||||
try {
|
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();
|
const data = await res.json();
|
||||||
setRepliesPosts(prev => [...prev, ...(data.posts || [])]);
|
setRepliesPosts(prev => [...prev, ...(data.posts || [])]);
|
||||||
setRepliesCursor(data.nextCursor || null);
|
setRepliesCursor(data.nextCursor || null);
|
||||||
@@ -266,7 +266,10 @@ export default function ProfilePage() {
|
|||||||
}, [user, currentUser]);
|
}, [user, currentUser]);
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
setIsFollowing(false);
|
||||||
setIsBlocked(false);
|
setIsBlocked(false);
|
||||||
return;
|
return;
|
||||||
@@ -314,7 +317,7 @@ export default function ProfilePage() {
|
|||||||
if (activeTab === 'replies' && user) {
|
if (activeTab === 'replies' && user) {
|
||||||
setRepliesLoading(true);
|
setRepliesLoading(true);
|
||||||
setRepliesCursor(null);
|
setRepliesCursor(null);
|
||||||
fetch(`/api/posts?type=replies&userId=${user.id}`)
|
fetch(`/api/users/${handle}/replies`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setRepliesPosts(data.posts || []);
|
setRepliesPosts(data.posts || []);
|
||||||
@@ -323,7 +326,13 @@ export default function ProfilePage() {
|
|||||||
.catch(() => setRepliesPosts([]))
|
.catch(() => setRepliesPosts([]))
|
||||||
.finally(() => setRepliesLoading(false));
|
.finally(() => setRepliesLoading(false));
|
||||||
}
|
}
|
||||||
}, [activeTab, handle, user]);
|
}, [activeTab, handle, user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.isBot && activeTab === 'following') {
|
||||||
|
setActiveTab('posts');
|
||||||
|
}
|
||||||
|
}, [user?.isBot, activeTab]);
|
||||||
|
|
||||||
const handleFollow = async () => {
|
const handleFollow = async () => {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
@@ -447,6 +456,19 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isOwnProfile = currentUser?.handle === user.handle;
|
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 (
|
return (
|
||||||
<div style={{ maxWidth: '600px', margin: '0 auto', minHeight: '100vh' }}>
|
<div style={{ maxWidth: '600px', margin: '0 auto', minHeight: '100vh' }}>
|
||||||
@@ -518,7 +540,8 @@ export default function ProfilePage() {
|
|||||||
{/* Banner */}
|
{/* Banner */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: '150px',
|
width: '100%',
|
||||||
|
aspectRatio: '3 / 1',
|
||||||
background: (isEditing ? profileForm.headerUrl : user.headerUrl)
|
background: (isEditing ? profileForm.headerUrl : user.headerUrl)
|
||||||
? `url(${isEditing ? profileForm.headerUrl : user.headerUrl}) center/cover`
|
? `url(${isEditing ? profileForm.headerUrl : user.headerUrl}) center/cover`
|
||||||
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
|
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
|
||||||
@@ -553,7 +576,7 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
{!isOwnProfile && currentUser && (
|
{!isOwnProfile && !isOwnedBotProfile && currentUser && (
|
||||||
<>
|
<>
|
||||||
{!isBlocked && (
|
{!isBlocked && (
|
||||||
<button
|
<button
|
||||||
@@ -713,18 +736,20 @@ export default function ProfilePage() {
|
|||||||
<strong>{user.followersCount}</strong>{' '}
|
<strong>{user.followersCount}</strong>{' '}
|
||||||
<span style={{ color: 'var(--foreground-tertiary)' }}>Followers</span>
|
<span style={{ color: 'var(--foreground-tertiary)' }}>Followers</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
{showFollowingUi && (
|
||||||
onClick={() => setActiveTab('following')}
|
<button
|
||||||
style={{
|
onClick={() => setActiveTab('following')}
|
||||||
background: 'none',
|
style={{
|
||||||
border: 'none',
|
background: 'none',
|
||||||
color: 'var(--foreground)',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
color: 'var(--foreground)',
|
||||||
}}
|
cursor: 'pointer',
|
||||||
>
|
}}
|
||||||
<strong>{user.followingCount}</strong>{' '}
|
>
|
||||||
<span style={{ color: 'var(--foreground-tertiary)' }}>Following</span>
|
<strong>{user.followingCount}</strong>{' '}
|
||||||
</button>
|
<span style={{ color: 'var(--foreground-tertiary)' }}>Following</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -807,10 +832,7 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div style={{ display: 'flex', borderTop: '1px solid var(--border)' }}>
|
<div style={{ display: 'flex', borderTop: '1px solid var(--border)' }}>
|
||||||
{(user?.isBot
|
{visibleTabs.map(tab => (
|
||||||
? ['posts', 'replies', 'followers', 'following'] as const
|
|
||||||
: ['posts', 'replies', 'likes', 'followers', 'following'] as const
|
|
||||||
).map(tab => (
|
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
|
|||||||
const [isNsfwNode, setIsNsfwNode] = useState(false);
|
const [isNsfwNode, setIsNsfwNode] = useState(false);
|
||||||
const maxLength = 600;
|
const maxLength = 600;
|
||||||
const remaining = maxLength - content.length;
|
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
|
// Check if user can post NSFW content and if node is NSFW
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -210,11 +217,32 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
|
|||||||
x
|
x
|
||||||
</button>
|
</button>
|
||||||
<VideoEmbed url={linkPreview.url} />
|
<VideoEmbed url={linkPreview.url} />
|
||||||
{!linkPreview.url.match(/(youtube\.com|youtu\.be|vimeo\.com)/) && (
|
{!isEmbeddedVideo && (
|
||||||
<div className="link-preview-card mini">
|
<div className="link-preview-card mini">
|
||||||
{linkPreview.image && (
|
{linkPreview.type === 'video' && linkPreview.videoUrl ? (
|
||||||
<div className="link-preview-image">
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className="link-preview-info">
|
<div className="link-preview-info">
|
||||||
|
|||||||
+252
-41
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/components/Icons';
|
import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/components/Icons';
|
||||||
import { Bot, MoreHorizontal, UserX, VolumeX, Globe } from 'lucide-react';
|
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 { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
import { useToast } from '@/lib/contexts/ToastContext';
|
import { useToast } from '@/lib/contexts/ToastContext';
|
||||||
import { VideoEmbed } from '@/components/VideoEmbed';
|
import { VideoEmbed } from '@/components/VideoEmbed';
|
||||||
@@ -13,6 +13,7 @@ import BlurredVideo from '@/components/BlurredVideo';
|
|||||||
import { useFormattedHandle } from '@/lib/utils/handle';
|
import { useFormattedHandle } from '@/lib/utils/handle';
|
||||||
import { useDomain } from '@/lib/contexts/ConfigContext';
|
import { useDomain } from '@/lib/contexts/ConfigContext';
|
||||||
import { signedAPI } from '@/lib/api/signed-fetch';
|
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||||
|
import type { LinkPreviewData } from '@/lib/media/linkPreview';
|
||||||
|
|
||||||
// Component for link preview image that hides on error
|
// Component for link preview image that hides on error
|
||||||
function LinkPreviewImage({ src, alt }: { src: string; alt: string }) {
|
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 {
|
interface PostCardProps {
|
||||||
post: Post;
|
post: Post;
|
||||||
onLike?: (id: string, currentLiked: boolean) => void;
|
onLike?: (id: string, currentLiked: boolean) => void;
|
||||||
@@ -41,10 +101,11 @@ interface PostCardProps {
|
|||||||
isDetail?: boolean;
|
isDetail?: boolean;
|
||||||
showThread?: boolean; // Show parent post inline as a thread
|
showThread?: boolean; // Show parent post inline as a thread
|
||||||
isThreadParent?: boolean; // This post is being shown as a parent in 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)
|
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 { user: currentUser, did, handle: currentUserHandle, isIdentityUnlocked } = useAuth();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -57,8 +118,25 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
const [reporting, setReporting] = useState(false);
|
const [reporting, setReporting] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
const [hydratedPreview, setHydratedPreview] = useState<LinkPreviewData | null>(null);
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
const authorHandle = useFormattedHandle(post.author.handle, post.nodeDomain);
|
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)
|
// Sync state if post changes (e.g. after a re-render from parent)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLiked(post.isLiked || false);
|
setLiked(post.isLiked || false);
|
||||||
@@ -67,6 +145,47 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
setRepostsCount(post.repostsCount || 0);
|
setRepostsCount(post.repostsCount || 0);
|
||||||
}, [post.isLiked, post.likesCount, post.isReposted, post.repostsCount, post.id]);
|
}, [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 formatTime = (dateStr: string | Date) => {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
|
|
||||||
@@ -431,11 +550,91 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
: post.swarmReplyToAuthor)?.nodeDomain,
|
: post.swarmReplyToAuthor)?.nodeDomain,
|
||||||
} as Post : null);
|
} as Post : null);
|
||||||
const replyToHandle = effectiveReplyTo?.author?.handle ? useFormattedHandle(effectiveReplyTo.author.handle, effectiveReplyTo.nodeDomain) : '';
|
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 this is a thread parent being rendered, just render the article
|
||||||
if (isThreadParent) {
|
if (isThreadParent) {
|
||||||
return (
|
return (
|
||||||
<article className="post thread-parent">
|
<article className={`post thread-parent ${isEmbedded ? 'embedded' : ''}`}>
|
||||||
<div className="post-header">
|
<div className="post-header">
|
||||||
<Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
|
<Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="avatar">
|
<div className="avatar">
|
||||||
@@ -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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Show parent post as part of thread - only on detail page */}
|
{/* 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>
|
</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" />}
|
{!isDetail && <Link href={postUrl} className="post-link-overlay" aria-label="View post" />}
|
||||||
|
|
||||||
<div className="post-header">
|
<div className="post-header">
|
||||||
@@ -515,7 +754,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
</div>
|
</div>
|
||||||
<span className="post-time">{authorHandle} · {formatTime(post.createdAt)}</span>
|
<span className="post-time">{authorHandle} · {formatTime(post.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
{currentUser && currentUser.id !== post.author.id && (
|
{currentUser && !isOwnOrOwnedBotPost && (
|
||||||
<div style={{ position: 'relative', marginLeft: 'auto' }}>
|
<div style={{ position: 'relative', marginLeft: 'auto' }}>
|
||||||
<button
|
<button
|
||||||
className="post-menu-btn"
|
className="post-menu-btn"
|
||||||
@@ -671,28 +910,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
<VideoEmbed url={post.linkPreviewUrl} />
|
<VideoEmbed url={post.linkPreviewUrl} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{post.linkPreviewUrl && !post.linkPreviewUrl.match(/(youtube\.com|youtu\.be|vimeo\.com)/) && (
|
{renderLinkPreviewCard()}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="post-actions">
|
<div className="post-actions">
|
||||||
<button className="post-action" onClick={handleComment}>
|
<button className="post-action" onClick={handleComment}>
|
||||||
@@ -707,20 +925,13 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
<HeartIcon filled={liked} />
|
<HeartIcon filled={liked} />
|
||||||
<span>{likesCount || ''}</span>
|
<span>{likesCount || ''}</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="post-action" onClick={handleReport} disabled={reporting}>
|
{!isOwnOrOwnedBotPost && (
|
||||||
<FlagIcon />
|
<button className="post-action" onClick={handleReport} disabled={reporting}>
|
||||||
<span>{reporting ? '...' : ''}</span>
|
<FlagIcon />
|
||||||
</button>
|
<span>{reporting ? '...' : ''}</span>
|
||||||
{(currentUser && (
|
</button>
|
||||||
currentUser.id === post.author.id ||
|
)}
|
||||||
(post.bot && currentUser.id === post.bot.ownerId) ||
|
{canDeletePost && (
|
||||||
(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}`
|
|
||||||
))
|
|
||||||
)) && (
|
|
||||||
<button className="post-action delete-action" onClick={handleDelete} disabled={deleting} title="Delete post">
|
<button className="post-action delete-action" onClick={handleDelete} disabled={deleting} title="Delete post">
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
<span>{deleting ? '...' : ''}</span>
|
<span>{deleting ? '...' : ''}</span>
|
||||||
|
|||||||
+244
-26
@@ -1,24 +1,38 @@
|
|||||||
'use client';
|
'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 Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons';
|
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons';
|
||||||
import { useFormattedHandle } from '@/lib/utils/handle';
|
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
|
// 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() {
|
export function Sidebar() {
|
||||||
const { user, isAdmin, logout } = useAuth();
|
const { user, accounts, isAdmin, logout, switchAccount } = useAuth();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined);
|
const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
const [unreadChatCount, setUnreadChatCount] = useState(0);
|
const [unreadChatCount, setUnreadChatCount] = useState(0);
|
||||||
const [loggingOut, setLoggingOut] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
fetch('/api/node')
|
fetch('/api/node')
|
||||||
@@ -91,18 +105,101 @@ export function Sidebar() {
|
|||||||
const isHome = pathname === '/';
|
const isHome = pathname === '/';
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
if (loggingOut) return;
|
if (loggingOut || !user) return;
|
||||||
|
|
||||||
setLoggingOut(true);
|
setLoggingOut(true);
|
||||||
try {
|
try {
|
||||||
await logout();
|
const isLastAccount = accounts.length <= 1;
|
||||||
window.location.href = '/explore';
|
await logout(user.id);
|
||||||
|
setAccountMenuOpen(false);
|
||||||
|
|
||||||
|
if (isLastAccount) {
|
||||||
|
window.location.href = '/explore';
|
||||||
|
} else {
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout failed:', error);
|
console.error('Logout failed:', error);
|
||||||
setLoggingOut(false);
|
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 (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<Link href={user ? "/" : "/explore"} className="logo" style={{ minHeight: '42px' }}>
|
<Link href={user ? "/" : "/explore"} className="logo" style={{ minHeight: '42px' }}>
|
||||||
@@ -196,8 +293,130 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
{user && (
|
{user && (
|
||||||
<div style={{ marginTop: 'auto', paddingTop: '16px' }} className="sidebar-user-info">
|
<div
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0, marginBottom: '12px' }}>
|
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 }}>
|
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
||||||
{user.avatarUrl ? (
|
{user.avatarUrl ? (
|
||||||
<img src={user.avatarUrl} alt={user.displayName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
<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()
|
(user.displayName?.charAt(0) || user.handle.charAt(0)).toUpperCase()
|
||||||
)}
|
)}
|
||||||
</div>
|
</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={{ 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 style={{ color: 'var(--foreground-tertiary)', fontSize: '13px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{formattedHandle}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ChevronDown size={18} style={{
|
||||||
|
transform: accountMenuOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||||
<button
|
transition: 'transform 0.2s ease',
|
||||||
onClick={handleLogout}
|
flexShrink: 0,
|
||||||
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>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Identity Unlock Prompt Modal is now handled in LayoutWrapper */}
|
{/* Identity Unlock Prompt Modal is now handled in LayoutWrapper */}
|
||||||
|
{showAuthModal && (
|
||||||
|
<AuthScreen
|
||||||
|
modal
|
||||||
|
onClose={() => setShowAuthModal(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowAuthModal(false);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-1
@@ -143,6 +143,9 @@ export const posts = pgTable('posts', {
|
|||||||
linkPreviewTitle: text('link_preview_title'),
|
linkPreviewTitle: text('link_preview_title'),
|
||||||
linkPreviewDescription: text('link_preview_description'),
|
linkPreviewDescription: text('link_preview_description'),
|
||||||
linkPreviewImage: text('link_preview_image'),
|
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(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
@@ -301,6 +304,9 @@ export const remotePosts = pgTable('remote_posts', {
|
|||||||
linkPreviewTitle: text('link_preview_title'),
|
linkPreviewTitle: text('link_preview_title'),
|
||||||
linkPreviewDescription: text('link_preview_description'),
|
linkPreviewDescription: text('link_preview_description'),
|
||||||
linkPreviewImage: text('link_preview_image'),
|
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
|
// Media attachments stored as JSON
|
||||||
mediaJson: text('media_json'), // JSON array of {url, altText}
|
mediaJson: text('media_json'), // JSON array of {url, altText}
|
||||||
// Metadata
|
// Metadata
|
||||||
@@ -374,6 +380,9 @@ export const userSwarmLikes = pgTable('user_swarm_likes', {
|
|||||||
linkPreviewTitle: text('link_preview_title'),
|
linkPreviewTitle: text('link_preview_title'),
|
||||||
linkPreviewDescription: text('link_preview_description'),
|
linkPreviewDescription: text('link_preview_description'),
|
||||||
linkPreviewImage: text('link_preview_image'),
|
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'),
|
mediaJson: text('media_json'),
|
||||||
likedAt: timestamp('liked_at').defaultNow().notNull(),
|
likedAt: timestamp('liked_at').defaultNow().notNull(),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
@@ -382,6 +391,38 @@ export const userSwarmLikes = pgTable('user_swarm_likes', {
|
|||||||
uniqueIndex('user_swarm_likes_unique').on(table.userId, table.nodeDomain, table.originalPostId),
|
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)
|
// REMOTE REPOSTS (reposts from federated users on local posts)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -411,6 +452,12 @@ export const notifications = pgTable('notifications', {
|
|||||||
actorDisplayName: text('actor_display_name'),
|
actorDisplayName: text('actor_display_name'),
|
||||||
actorAvatarUrl: text('actor_avatar_url'),
|
actorAvatarUrl: text('actor_avatar_url'),
|
||||||
actorNodeDomain: text('actor_node_domain'), // null for local actors
|
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
|
// Post reference
|
||||||
postId: uuid('post_id').references(() => posts.id, { onDelete: 'cascade' }),
|
postId: uuid('post_id').references(() => posts.id, { onDelete: 'cascade' }),
|
||||||
postContent: text('post_content'), // Cached content for display
|
postContent: text('post_content'), // Cached content for display
|
||||||
@@ -586,8 +633,9 @@ export const bots = pgTable('bots', {
|
|||||||
personalityConfig: text('personality_config').notNull(), // JSON
|
personalityConfig: text('personality_config').notNull(), // JSON
|
||||||
|
|
||||||
// LLM configuration
|
// LLM configuration
|
||||||
llmProvider: text('llm_provider').notNull(), // openrouter, openai, anthropic
|
llmProvider: text('llm_provider').notNull(), // openrouter, openai, anthropic, custom
|
||||||
llmModel: text('llm_model').notNull(),
|
llmModel: text('llm_model').notNull(),
|
||||||
|
llmEndpoint: text('llm_endpoint'),
|
||||||
llmApiKeyEncrypted: text('llm_api_key_encrypted').notNull(),
|
llmApiKeyEncrypted: text('llm_api_key_encrypted').notNull(),
|
||||||
|
|
||||||
// Scheduling
|
// Scheduling
|
||||||
@@ -840,6 +888,11 @@ export const swarmNodes = pgTable('swarm_nodes', {
|
|||||||
// Trust/reputation (for future spam prevention)
|
// Trust/reputation (for future spam prevention)
|
||||||
trustScore: integer('trust_score').default(50).notNull(), // 0-100
|
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
|
||||||
capabilities: text('capabilities'), // JSON array: ["handles", "gossip", "relay"]
|
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_last_seen_idx').on(table.lastSeenAt),
|
||||||
index('swarm_nodes_trust_idx').on(table.trustScore),
|
index('swarm_nodes_trust_idx').on(table.trustScore),
|
||||||
index('swarm_nodes_nsfw_idx').on(table.isNsfw),
|
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 { db, users, sessions } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { generateKeyPair } from '@/lib/crypto/keys';
|
import { generateKeyPair } from '@/lib/crypto/keys';
|
||||||
@@ -13,8 +13,107 @@ import { cookies } from 'next/headers';
|
|||||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||||
import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
|
import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = 'synapsis_session';
|
const ACTIVE_SESSION_COOKIE_NAME = 'synapsis_session';
|
||||||
const SESSION_EXPIRY_DAYS = 30;
|
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
|
* Hash a password
|
||||||
@@ -55,6 +154,16 @@ export function generateLegacyDID(): string {
|
|||||||
* Create a new session for a user
|
* Create a new session for a user
|
||||||
*/
|
*/
|
||||||
export async function createSession(userId: string): Promise<string> {
|
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 token = uuid();
|
||||||
const expiresAt = new Date();
|
const expiresAt = new Date();
|
||||||
expiresAt.setDate(expiresAt.getDate() + SESSION_EXPIRY_DAYS);
|
expiresAt.setDate(expiresAt.getDate() + SESSION_EXPIRY_DAYS);
|
||||||
@@ -65,15 +174,8 @@ export async function createSession(userId: string): Promise<string> {
|
|||||||
expiresAt,
|
expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set the session cookie
|
const filteredTokens = tokens.filter(existingToken => !existingUserTokens.includes(existingToken));
|
||||||
const cookieStore = await cookies();
|
await writeSessionState([token, ...filteredTokens], token);
|
||||||
cookieStore.set(SESSION_COOKIE_NAME, token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
expires: expiresAt,
|
|
||||||
path: '/',
|
|
||||||
});
|
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
@@ -82,25 +184,50 @@ export async function createSession(userId: string): Promise<string> {
|
|||||||
* Get the current session from cookies
|
* Get the current session from cookies
|
||||||
*/
|
*/
|
||||||
export async function getSession(): Promise<{ user: typeof users.$inferSelect } | null> {
|
export async function getSession(): Promise<{ user: typeof users.$inferSelect } | null> {
|
||||||
const cookieStore = await cookies();
|
const { activeToken, tokens } = await readSessionState();
|
||||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
const sessionRecords = await loadSessionsByTokens(tokens);
|
||||||
|
|
||||||
if (!token) {
|
if (sessionRecords.length === 0) {
|
||||||
|
await writeSessionState([], null);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await db.query.sessions.findFirst({
|
const activeSession = sessionRecords.find(session => session.token === activeToken) ?? sessionRecords[0];
|
||||||
where: eq(sessions.token, token),
|
await writeSessionState(sessionRecords.map(session => session.token), activeSession.token);
|
||||||
with: {
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!session || session.expiresAt < new Date()) {
|
return { user: activeSession.user };
|
||||||
return null;
|
}
|
||||||
|
|
||||||
|
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
|
* Destroy the current session
|
||||||
*/
|
*/
|
||||||
export async function destroySession(): Promise<void> {
|
export async function destroySession(userId?: string): Promise<void> {
|
||||||
const cookieStore = await cookies();
|
const { activeToken, tokens } = await readSessionState();
|
||||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
const sessionRecords = await loadSessionsByTokens(tokens);
|
||||||
|
|
||||||
if (token) {
|
if (sessionRecords.length === 0) {
|
||||||
await db.delete(sessions).where(eq(sessions.token, token));
|
await writeSessionState([], null);
|
||||||
cookieStore.delete(SESSION_COOKIE_NAME);
|
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 { eq, and } from 'drizzle-orm';
|
||||||
import { ContentGenerator, type Bot as ContentGeneratorBot, type ContentItem } from './contentGenerator';
|
import { ContentGenerator, type Bot as ContentGeneratorBot, type ContentItem } from './contentGenerator';
|
||||||
import { canPost } from './rateLimiter';
|
import { canPost } from './rateLimiter';
|
||||||
import { decryptApiKey, deserializeEncryptedData } from './encryption';
|
import { decryptApiKey, deserializeEncryptedData, type LLMProvider } from './encryption';
|
||||||
import { parseScheduleConfig, isDue } from './scheduler';
|
import { parseScheduleConfig, isDue } from './scheduler';
|
||||||
import { triggerPost } from './posting';
|
import { triggerPost } from './posting';
|
||||||
|
|
||||||
@@ -213,8 +213,9 @@ function toContentGeneratorBot(bot: typeof bots.$inferSelect, handle: string): C
|
|||||||
name: bot.name,
|
name: bot.name,
|
||||||
handle: handle,
|
handle: handle,
|
||||||
personalityConfig: JSON.parse(bot.personalityConfig),
|
personalityConfig: JSON.parse(bot.personalityConfig),
|
||||||
llmProvider: bot.llmProvider as 'openrouter' | 'openai' | 'anthropic',
|
llmProvider: bot.llmProvider as LLMProvider,
|
||||||
llmModel: bot.llmModel,
|
llmModel: bot.llmModel,
|
||||||
|
llmEndpoint: bot.llmEndpoint,
|
||||||
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
|
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
|
|||||||
import { decryptPrivateKey, deserializeEncryptedKey } from '@/lib/crypto/private-key';
|
import { decryptPrivateKey, deserializeEncryptedKey } from '@/lib/crypto/private-key';
|
||||||
import { eq, and, count } from 'drizzle-orm';
|
import { eq, and, count } from 'drizzle-orm';
|
||||||
import { generateKeyPair } from '@/lib/crypto/keys';
|
import { generateKeyPair } from '@/lib/crypto/keys';
|
||||||
|
import { isIP } from 'net';
|
||||||
import {
|
import {
|
||||||
encryptApiKey,
|
encryptApiKey,
|
||||||
decryptApiKey,
|
decryptApiKey,
|
||||||
@@ -52,6 +53,7 @@ export interface BotCreateInput {
|
|||||||
personality: PersonalityConfig;
|
personality: PersonalityConfig;
|
||||||
llmProvider: LLMProvider;
|
llmProvider: LLMProvider;
|
||||||
llmModel: string;
|
llmModel: string;
|
||||||
|
llmEndpoint?: string;
|
||||||
llmApiKey: string;
|
llmApiKey: string;
|
||||||
schedule?: ScheduleConfig;
|
schedule?: ScheduleConfig;
|
||||||
autonomousMode?: boolean;
|
autonomousMode?: boolean;
|
||||||
@@ -65,6 +67,7 @@ export interface BotUpdateInput {
|
|||||||
personality?: PersonalityConfig;
|
personality?: PersonalityConfig;
|
||||||
llmProvider?: LLMProvider;
|
llmProvider?: LLMProvider;
|
||||||
llmModel?: string;
|
llmModel?: string;
|
||||||
|
llmEndpoint?: string | null;
|
||||||
llmApiKey?: string;
|
llmApiKey?: string;
|
||||||
schedule?: ScheduleConfig | null;
|
schedule?: ScheduleConfig | null;
|
||||||
autonomousMode?: boolean;
|
autonomousMode?: boolean;
|
||||||
@@ -83,6 +86,7 @@ export interface Bot {
|
|||||||
personalityConfig: PersonalityConfig;
|
personalityConfig: PersonalityConfig;
|
||||||
llmProvider: LLMProvider;
|
llmProvider: LLMProvider;
|
||||||
llmModel: string;
|
llmModel: string;
|
||||||
|
llmEndpoint?: string | null;
|
||||||
scheduleConfig: ScheduleConfig | null;
|
scheduleConfig: ScheduleConfig | null;
|
||||||
autonomousMode: boolean;
|
autonomousMode: boolean;
|
||||||
isActive: 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
|
// HELPER FUNCTIONS
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -269,6 +332,7 @@ function dbBotToBot(dbBot: typeof bots.$inferSelect, botUser: typeof users.$infe
|
|||||||
personalityConfig: JSON.parse(dbBot.personalityConfig) as PersonalityConfig,
|
personalityConfig: JSON.parse(dbBot.personalityConfig) as PersonalityConfig,
|
||||||
llmProvider: dbBot.llmProvider as LLMProvider,
|
llmProvider: dbBot.llmProvider as LLMProvider,
|
||||||
llmModel: dbBot.llmModel,
|
llmModel: dbBot.llmModel,
|
||||||
|
llmEndpoint: dbBot.llmEndpoint,
|
||||||
scheduleConfig: dbBot.scheduleConfig ? JSON.parse(dbBot.scheduleConfig) as ScheduleConfig : null,
|
scheduleConfig: dbBot.scheduleConfig ? JSON.parse(dbBot.scheduleConfig) as ScheduleConfig : null,
|
||||||
autonomousMode: dbBot.autonomousMode,
|
autonomousMode: dbBot.autonomousMode,
|
||||||
isActive: dbBot.isActive,
|
isActive: dbBot.isActive,
|
||||||
@@ -308,6 +372,10 @@ export async function createBot(ownerId: string, config: BotCreateInput): Promis
|
|||||||
if (config.schedule) {
|
if (config.schedule) {
|
||||||
validateScheduleConfig(config.schedule);
|
validateScheduleConfig(config.schedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedEndpoint = config.llmProvider === 'custom'
|
||||||
|
? normalizeAndValidateLlmEndpoint(config.llmEndpoint || '')
|
||||||
|
: null;
|
||||||
|
|
||||||
// Validate API key format
|
// Validate API key format
|
||||||
const apiKeyValidation = validateApiKeyFormat(config.llmApiKey, config.llmProvider);
|
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),
|
personalityConfig: JSON.stringify(config.personality),
|
||||||
llmProvider: config.llmProvider,
|
llmProvider: config.llmProvider,
|
||||||
llmModel: config.llmModel,
|
llmModel: config.llmModel,
|
||||||
|
llmEndpoint: normalizedEndpoint,
|
||||||
llmApiKeyEncrypted: serializeEncryptedData(encryptedApiKey),
|
llmApiKeyEncrypted: serializeEncryptedData(encryptedApiKey),
|
||||||
scheduleConfig: config.schedule ? JSON.stringify(config.schedule) : null,
|
scheduleConfig: config.schedule ? JSON.stringify(config.schedule) : null,
|
||||||
autonomousMode: config.autonomousMode ?? false,
|
autonomousMode: config.autonomousMode ?? false,
|
||||||
@@ -443,15 +512,7 @@ export async function updateBot(botId: string, config: BotUpdateInput): Promise<
|
|||||||
if (config.schedule !== undefined && config.schedule !== null) {
|
if (config.schedule !== undefined && config.schedule !== null) {
|
||||||
validateScheduleConfig(config.schedule);
|
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
|
// Check if bot exists and get its user account
|
||||||
const existingBot = await db.query.bots.findFirst({
|
const existingBot = await db.query.bots.findFirst({
|
||||||
where: eq(bots.id, botId),
|
where: eq(bots.id, botId),
|
||||||
@@ -469,6 +530,25 @@ export async function updateBot(botId: string, config: BotUpdateInput): Promise<
|
|||||||
if (!botUser) {
|
if (!botUser) {
|
||||||
throw new BotNotFoundError(botId);
|
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
|
// Build update object for bot config
|
||||||
const botUpdateData: Partial<typeof bots.$inferInsert> = {
|
const botUpdateData: Partial<typeof bots.$inferInsert> = {
|
||||||
@@ -508,6 +588,12 @@ export async function updateBot(botId: string, config: BotUpdateInput): Promise<
|
|||||||
if (config.llmModel !== undefined) {
|
if (config.llmModel !== undefined) {
|
||||||
botUpdateData.llmModel = config.llmModel;
|
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) {
|
if (config.llmApiKey !== undefined) {
|
||||||
const encryptedApiKey = encryptApiKey(config.llmApiKey);
|
const encryptedApiKey = encryptApiKey(config.llmApiKey);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
calculateBackoffDelay,
|
calculateBackoffDelay,
|
||||||
shouldRetrySource,
|
shouldRetrySource,
|
||||||
isSourceDueForFetch,
|
isSourceDueForFetch,
|
||||||
|
shouldExcludeRedditPost,
|
||||||
|
fetchRedditPosts,
|
||||||
BASE_BACKOFF_DELAY_MS,
|
BASE_BACKOFF_DELAY_MS,
|
||||||
MAX_BACKOFF_DELAY_MS,
|
MAX_BACKOFF_DELAY_MS,
|
||||||
MAX_CONSECUTIVE_ERRORS,
|
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
|
// BACKOFF DELAY PROPERTY TESTS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface ContentItemInput {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
url: string;
|
url: string;
|
||||||
|
url_overridden_by_dest?: string;
|
||||||
publishedAt: Date;
|
publishedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,16 +326,39 @@ export async function fetchRedditPosts(
|
|||||||
subreddit: string,
|
subreddit: string,
|
||||||
options: FetchOptions = {}
|
options: FetchOptions = {}
|
||||||
): Promise<FeedItem[]> {
|
): Promise<FeedItem[]> {
|
||||||
// Use RSS feed instead of JSON API - more reliable and doesn't require auth
|
const requestedMaxItems = options.maxItems ?? DEFAULT_MAX_ITEMS;
|
||||||
const rssUrl = `https://www.reddit.com/r/${subreddit}/hot.rss`;
|
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 {
|
try {
|
||||||
return await fetchRSSFeed(rssUrl, options);
|
response = JSON.parse(responseText) as RedditListingResponse;
|
||||||
} catch (error) {
|
} catch {
|
||||||
// If RSS fails, try the old.reddit.com RSS which sometimes works better
|
throw new ParseError('Failed to parse Reddit JSON response');
|
||||||
const oldRedditUrl = `https://old.reddit.com/r/${subreddit}/hot.rss`;
|
|
||||||
return await fetchRSSFeed(oldRedditUrl, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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[];
|
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.
|
* Fetch articles from a news API.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface Bot {
|
|||||||
personalityConfig: PersonalityConfig;
|
personalityConfig: PersonalityConfig;
|
||||||
llmProvider: LLMProvider;
|
llmProvider: LLMProvider;
|
||||||
llmModel: string;
|
llmModel: string;
|
||||||
|
llmEndpoint?: string | null;
|
||||||
llmApiKeyEncrypted: string;
|
llmApiKeyEncrypted: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ export interface Bot {
|
|||||||
export interface ContentItem {
|
export interface ContentItem {
|
||||||
id: string;
|
id: string;
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
|
externalId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -467,6 +469,7 @@ export class ContentGenerator {
|
|||||||
provider: bot.llmProvider,
|
provider: bot.llmProvider,
|
||||||
apiKey: bot.llmApiKeyEncrypted,
|
apiKey: bot.llmApiKeyEncrypted,
|
||||||
model: bot.llmModel,
|
model: bot.llmModel,
|
||||||
|
endpoint: bot.llmEndpoint,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import * as crypto from 'crypto';
|
|||||||
// TYPES
|
// TYPES
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
export type LLMProvider = 'openrouter' | 'openai' | 'anthropic';
|
export type LLMProvider = 'openrouter' | 'openai' | 'anthropic' | 'custom';
|
||||||
|
|
||||||
export interface EncryptedData {
|
export interface EncryptedData {
|
||||||
encrypted: string; // Base64 encoded ciphertext + auth tag
|
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_-]+$/,
|
openrouter: /^sk-or-[a-zA-Z0-9_-]+$/,
|
||||||
anthropic: /^sk-ant-[a-zA-Z0-9_-]+$/,
|
anthropic: /^sk-ant-[a-zA-Z0-9_-]+$/,
|
||||||
openai: /^sk-[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
|
* @returns True if the provider is supported
|
||||||
*/
|
*/
|
||||||
export function isSupportedProvider(provider: string): provider is LLMProvider {
|
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
|
* @returns Array of supported provider names
|
||||||
*/
|
*/
|
||||||
export function getSupportedProviders(): LLMProvider[] {
|
export function getSupportedProviders(): LLMProvider[] {
|
||||||
return ['openrouter', 'openai', 'anthropic'];
|
return ['openrouter', 'openai', 'anthropic', 'custom'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface LLMConfig {
|
|||||||
provider: LLMProvider;
|
provider: LLMProvider;
|
||||||
apiKey: string; // Encrypted before storage
|
apiKey: string; // Encrypted before storage
|
||||||
model: string;
|
model: string;
|
||||||
|
endpoint?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,6 +124,7 @@ export const PROVIDER_ENDPOINTS: Record<LLMProvider, string> = {
|
|||||||
openrouter: 'https://openrouter.ai/api/v1/chat/completions',
|
openrouter: 'https://openrouter.ai/api/v1/chat/completions',
|
||||||
openai: 'https://api.openai.com/v1/chat/completions',
|
openai: 'https://api.openai.com/v1/chat/completions',
|
||||||
anthropic: 'https://api.anthropic.com/v1/messages',
|
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',
|
openrouter: 'openai/gpt-3.5-turbo',
|
||||||
openai: 'gpt-3.5-turbo',
|
openai: 'gpt-3.5-turbo',
|
||||||
anthropic: 'claude-3-haiku-20240307',
|
anthropic: 'claude-3-haiku-20240307',
|
||||||
|
custom: 'gpt-4o-mini',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -299,7 +302,8 @@ export function parseOpenRouterResponse(
|
|||||||
*/
|
*/
|
||||||
export function parseOpenAIResponse(
|
export function parseOpenAIResponse(
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
model: string
|
model: string,
|
||||||
|
provider: LLMProvider = 'openai'
|
||||||
): LLMCompletionResponse {
|
): LLMCompletionResponse {
|
||||||
const choices = data.choices as Array<{ message: { content: string } }>;
|
const choices = data.choices as Array<{ message: { content: string } }>;
|
||||||
const usage = data.usage as { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
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,
|
total: usage?.total_tokens ?? 0,
|
||||||
},
|
},
|
||||||
model: (data.model as string) ?? model,
|
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';
|
headers['X-Title'] = 'Synapsis Bot';
|
||||||
break;
|
break;
|
||||||
case 'openai':
|
case 'openai':
|
||||||
|
case 'custom':
|
||||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||||
break;
|
break;
|
||||||
case 'anthropic':
|
case 'anthropic':
|
||||||
@@ -386,6 +391,7 @@ export class LLMClient {
|
|||||||
private provider: LLMProvider;
|
private provider: LLMProvider;
|
||||||
private apiKey: string;
|
private apiKey: string;
|
||||||
private model: string;
|
private model: string;
|
||||||
|
private endpoint: string | null;
|
||||||
private retryConfig: RetryConfig;
|
private retryConfig: RetryConfig;
|
||||||
private timeoutMs: number;
|
private timeoutMs: number;
|
||||||
|
|
||||||
@@ -403,6 +409,7 @@ export class LLMClient {
|
|||||||
) {
|
) {
|
||||||
this.provider = config.provider;
|
this.provider = config.provider;
|
||||||
this.model = config.model || DEFAULT_MODELS[config.provider];
|
this.model = config.model || DEFAULT_MODELS[config.provider];
|
||||||
|
this.endpoint = config.endpoint || null;
|
||||||
this.retryConfig = retryConfig;
|
this.retryConfig = retryConfig;
|
||||||
this.timeoutMs = timeoutMs;
|
this.timeoutMs = timeoutMs;
|
||||||
|
|
||||||
@@ -480,7 +487,16 @@ export class LLMClient {
|
|||||||
* @throws LLMClientError if the request fails
|
* @throws LLMClientError if the request fails
|
||||||
*/
|
*/
|
||||||
private async makeRequest(request: LLMCompletionRequest): Promise<LLMCompletionResponse> {
|
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 headers = buildHeaders(this.provider, this.apiKey);
|
||||||
const body = this.buildRequestBody(request);
|
const body = this.buildRequestBody(request);
|
||||||
|
|
||||||
@@ -566,6 +582,7 @@ export class LLMClient {
|
|||||||
case 'openrouter':
|
case 'openrouter':
|
||||||
return buildOpenRouterRequest(request, this.model);
|
return buildOpenRouterRequest(request, this.model);
|
||||||
case 'openai':
|
case 'openai':
|
||||||
|
case 'custom':
|
||||||
return buildOpenAIRequest(request, this.model);
|
return buildOpenAIRequest(request, this.model);
|
||||||
case 'anthropic':
|
case 'anthropic':
|
||||||
return buildAnthropicRequest(request, this.model);
|
return buildAnthropicRequest(request, this.model);
|
||||||
@@ -580,7 +597,9 @@ export class LLMClient {
|
|||||||
case 'openrouter':
|
case 'openrouter':
|
||||||
return parseOpenRouterResponse(data, this.model);
|
return parseOpenRouterResponse(data, this.model);
|
||||||
case 'openai':
|
case 'openai':
|
||||||
return parseOpenAIResponse(data, this.model);
|
return parseOpenAIResponse(data, this.model, 'openai');
|
||||||
|
case 'custom':
|
||||||
|
return parseOpenAIResponse(data, this.model, 'custom');
|
||||||
case 'anthropic':
|
case 'anthropic':
|
||||||
return parseAnthropicResponse(data, this.model);
|
return parseAnthropicResponse(data, this.model);
|
||||||
}
|
}
|
||||||
@@ -618,12 +637,14 @@ export function createLLMClient(
|
|||||||
export function createLLMClientFromBot(
|
export function createLLMClientFromBot(
|
||||||
provider: LLMProvider,
|
provider: LLMProvider,
|
||||||
encryptedApiKey: string,
|
encryptedApiKey: string,
|
||||||
model: string
|
model: string,
|
||||||
|
endpoint?: string | null
|
||||||
): LLMClient {
|
): LLMClient {
|
||||||
return new LLMClient({
|
return new LLMClient({
|
||||||
provider,
|
provider,
|
||||||
apiKey: encryptedApiKey,
|
apiKey: encryptedApiKey,
|
||||||
model,
|
model,
|
||||||
|
endpoint,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,7 +664,7 @@ export function validateLLMConfig(config: unknown): { valid: boolean; errors: st
|
|||||||
const configObj = config as Record<string, unknown>;
|
const configObj = config as Record<string, unknown>;
|
||||||
|
|
||||||
// Validate provider
|
// Validate provider
|
||||||
const validProviders: LLMProvider[] = ['openrouter', 'openai', 'anthropic'];
|
const validProviders: LLMProvider[] = ['openrouter', 'openai', 'anthropic', 'custom'];
|
||||||
if (!configObj.provider || !validProviders.includes(configObj.provider as LLMProvider)) {
|
if (!configObj.provider || !validProviders.includes(configObj.provider as LLMProvider)) {
|
||||||
errors.push(`Provider must be one of: ${validProviders.join(', ')}`);
|
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') {
|
if (configObj.model !== undefined && typeof configObj.model !== 'string') {
|
||||||
errors.push('Model must be a 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 };
|
return { valid: errors.length === 0, errors };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -446,6 +446,7 @@ export async function processMention(mentionId: string): Promise<MentionResponse
|
|||||||
personalityConfig: JSON.parse(bot.personalityConfig),
|
personalityConfig: JSON.parse(bot.personalityConfig),
|
||||||
llmProvider: bot.llmProvider as any,
|
llmProvider: bot.llmProvider as any,
|
||||||
llmModel: bot.llmModel,
|
llmModel: bot.llmModel,
|
||||||
|
llmEndpoint: bot.llmEndpoint,
|
||||||
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
|
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+67
-103
@@ -7,12 +7,15 @@
|
|||||||
* Requirements: 5.4, 11.5, 11.6
|
* 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 { eq, and, inArray } from 'drizzle-orm';
|
||||||
import { ContentGenerator, type Bot as ContentGeneratorBot, type ContentItem } from './contentGenerator';
|
import { ContentGenerator, type Bot as ContentGeneratorBot, type ContentItem } from './contentGenerator';
|
||||||
import { canPost, recordPost } from './rateLimiter';
|
import { canPost, recordPost } from './rateLimiter';
|
||||||
import { getBotById } from './botManager';
|
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
|
// TYPES
|
||||||
@@ -139,8 +142,9 @@ function toContentGeneratorBot(bot: typeof bots.$inferSelect & { user: { handle:
|
|||||||
name: bot.name,
|
name: bot.name,
|
||||||
handle: bot.user.handle,
|
handle: bot.user.handle,
|
||||||
personalityConfig: JSON.parse(bot.personalityConfig),
|
personalityConfig: JSON.parse(bot.personalityConfig),
|
||||||
llmProvider: bot.llmProvider as 'openrouter' | 'openai' | 'anthropic',
|
llmProvider: bot.llmProvider as LLMProvider,
|
||||||
llmModel: bot.llmModel,
|
llmModel: bot.llmModel,
|
||||||
|
llmEndpoint: bot.llmEndpoint,
|
||||||
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
|
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -175,6 +179,7 @@ async function getContentItemById(contentItemId: string): Promise<ContentItem |
|
|||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
sourceId: item.sourceId,
|
sourceId: item.sourceId,
|
||||||
|
externalId: item.externalId,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
content: item.content,
|
content: item.content,
|
||||||
url: item.url,
|
url: item.url,
|
||||||
@@ -250,6 +255,7 @@ async function getNextUnprocessedContentItem(botId: string): Promise<ContentItem
|
|||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
sourceId: item.sourceId,
|
sourceId: item.sourceId,
|
||||||
|
externalId: item.externalId,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
content: item.content,
|
content: item.content,
|
||||||
url: item.url,
|
url: item.url,
|
||||||
@@ -630,62 +636,8 @@ function isRedditUrl(url: string): boolean {
|
|||||||
* Fetch link preview for Reddit URLs using their oEmbed API.
|
* Fetch link preview for Reddit URLs using their oEmbed API.
|
||||||
* Reddit blocks regular scraping but provides oEmbed for embedding.
|
* Reddit blocks regular scraping but provides oEmbed for embedding.
|
||||||
*/
|
*/
|
||||||
async function fetchRedditPreview(url: string): Promise<{
|
async function fetchRedditPreview(url: string): Promise<LinkPreviewData | null> {
|
||||||
url: string;
|
return fetchRedditRichPreview(url);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -695,12 +647,7 @@ async function fetchRedditPreview(url: string): Promise<{
|
|||||||
* @param url - The URL to fetch preview for
|
* @param url - The URL to fetch preview for
|
||||||
* @returns Link preview data or null
|
* @returns Link preview data or null
|
||||||
*/
|
*/
|
||||||
async function fetchLinkPreview(url: string): Promise<{
|
async function fetchLinkPreview(url: string): Promise<LinkPreviewData | null> {
|
||||||
url: string;
|
|
||||||
title: string | null;
|
|
||||||
description: string | null;
|
|
||||||
image: string | null;
|
|
||||||
} | null> {
|
|
||||||
// Use Reddit-specific handler
|
// Use Reddit-specific handler
|
||||||
if (isRedditUrl(url)) {
|
if (isRedditUrl(url)) {
|
||||||
return fetchRedditPreview(url);
|
return fetchRedditPreview(url);
|
||||||
@@ -708,48 +655,61 @@ async function fetchLinkPreview(url: string): Promise<{
|
|||||||
|
|
||||||
// Generic OG tag scraping for other sites
|
// Generic OG tag scraping for other sites
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
return await fetchGenericLinkPreview(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,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch link preview:', error);
|
console.error('Failed to fetch link preview:', error);
|
||||||
return null;
|
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.
|
* Create a post in the database.
|
||||||
* Posts are created under the bot's own user account.
|
* Posts are created under the bot's own user account.
|
||||||
@@ -762,7 +722,7 @@ async function fetchLinkPreview(url: string): Promise<{
|
|||||||
async function createPostInDatabase(
|
async function createPostInDatabase(
|
||||||
botId: string,
|
botId: string,
|
||||||
content: string,
|
content: string,
|
||||||
sourceUrl?: string
|
contentItem?: ContentItem | null
|
||||||
): Promise<typeof posts.$inferSelect> {
|
): Promise<typeof posts.$inferSelect> {
|
||||||
// Get bot config
|
// Get bot config
|
||||||
const bot = await db.query.bots.findFirst({
|
const bot = await db.query.bots.findFirst({
|
||||||
@@ -789,6 +749,7 @@ async function createPostInDatabase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch link preview if source URL provided
|
// Fetch link preview if source URL provided
|
||||||
|
const sourceUrl = await resolveContentItemSourceUrl(contentItem);
|
||||||
let linkPreview: Awaited<ReturnType<typeof fetchLinkPreview>> = null;
|
let linkPreview: Awaited<ReturnType<typeof fetchLinkPreview>> = null;
|
||||||
if (sourceUrl) {
|
if (sourceUrl) {
|
||||||
linkPreview = await fetchLinkPreview(sourceUrl);
|
linkPreview = await fetchLinkPreview(sourceUrl);
|
||||||
@@ -809,6 +770,9 @@ async function createPostInDatabase(
|
|||||||
linkPreviewTitle: linkPreview?.title || null,
|
linkPreviewTitle: linkPreview?.title || null,
|
||||||
linkPreviewDescription: linkPreview?.description || null,
|
linkPreviewDescription: linkPreview?.description || null,
|
||||||
linkPreviewImage: linkPreview?.image || null,
|
linkPreviewImage: linkPreview?.image || null,
|
||||||
|
linkPreviewType: linkPreview?.type || null,
|
||||||
|
linkPreviewVideoUrl: linkPreview?.videoUrl || null,
|
||||||
|
linkPreviewMediaJson: linkPreview?.media ? JSON.stringify(linkPreview.media) : null,
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
// Update bot user's post count
|
// 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)
|
// 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
|
// Record post for rate limiting
|
||||||
if (!skipRateLimitCheck) {
|
if (!skipRateLimitCheck) {
|
||||||
|
|||||||
@@ -14,8 +14,14 @@ export interface User {
|
|||||||
privateKeyEncrypted?: string;
|
privateKeyEncrypted?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthAccount extends User {
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
|
accounts: AuthAccount[];
|
||||||
|
activeAccountId: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
isIdentityUnlocked: boolean;
|
isIdentityUnlocked: boolean;
|
||||||
@@ -24,8 +30,10 @@ interface AuthContextType {
|
|||||||
handle: string | null;
|
handle: string | null;
|
||||||
checkAdmin: () => Promise<void>;
|
checkAdmin: () => Promise<void>;
|
||||||
unlockIdentity: (password: string, explicitUser?: User) => Promise<void>;
|
unlockIdentity: (password: string, explicitUser?: User) => Promise<void>;
|
||||||
login: (user: User) => void;
|
login: (user?: User) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: (userId?: string) => Promise<void>;
|
||||||
|
switchAccount: (userId: string) => Promise<void>;
|
||||||
|
refreshAuth: () => Promise<void>;
|
||||||
lockIdentity: () => Promise<void>; // New: manual lock
|
lockIdentity: () => Promise<void>; // New: manual lock
|
||||||
signUserAction: (action: string, data: any) => Promise<any>;
|
signUserAction: (action: string, data: any) => Promise<any>;
|
||||||
requiresUnlock: boolean; // True if user has encrypted key but not unlocked
|
requiresUnlock: boolean; // True if user has encrypted key but not unlocked
|
||||||
@@ -35,6 +43,8 @@ interface AuthContextType {
|
|||||||
|
|
||||||
const AuthContext = createContext<AuthContextType>({
|
const AuthContext = createContext<AuthContextType>({
|
||||||
user: null,
|
user: null,
|
||||||
|
accounts: [],
|
||||||
|
activeAccountId: null,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
loading: true,
|
loading: true,
|
||||||
isIdentityUnlocked: false,
|
isIdentityUnlocked: false,
|
||||||
@@ -43,8 +53,10 @@ const AuthContext = createContext<AuthContextType>({
|
|||||||
handle: null,
|
handle: null,
|
||||||
checkAdmin: async () => { },
|
checkAdmin: async () => { },
|
||||||
unlockIdentity: async () => { },
|
unlockIdentity: async () => { },
|
||||||
login: () => { },
|
login: async () => { },
|
||||||
logout: async () => { },
|
logout: async () => { },
|
||||||
|
switchAccount: async () => { },
|
||||||
|
refreshAuth: async () => { },
|
||||||
lockIdentity: async () => { },
|
lockIdentity: async () => { },
|
||||||
signUserAction: async () => Promise.reject('Not initialized'),
|
signUserAction: async () => Promise.reject('Not initialized'),
|
||||||
requiresUnlock: false,
|
requiresUnlock: false,
|
||||||
@@ -54,6 +66,7 @@ const AuthContext = createContext<AuthContextType>({
|
|||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [accounts, setAccounts] = useState<AuthAccount[]>([]);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showUnlockPrompt, setShowUnlockPrompt] = useState(false);
|
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
|
* Unlock the user's identity with their password
|
||||||
* Persists the key for auto-unlock on refresh
|
* 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)
|
* Manually set the user state (called after successful login)
|
||||||
*/
|
*/
|
||||||
const login = useCallback((userData: User) => {
|
const login = useCallback(async (_userData?: User) => {
|
||||||
setUser(userData);
|
await refreshAuth();
|
||||||
checkAdmin();
|
}, [refreshAuth]);
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout the user and clear their identity
|
* Logout the user and clear their identity
|
||||||
*/
|
*/
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async (userId?: string) => {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
await fetch('/api/auth/logout', {
|
||||||
await clearIdentity();
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(userId ? { userId } : {}),
|
||||||
|
});
|
||||||
setShowUnlockPrompt(false);
|
setShowUnlockPrompt(false);
|
||||||
setUser(null);
|
await refreshAuth();
|
||||||
setIsAdmin(false);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Auth] Logout failed:', error);
|
console.error('[Auth] Logout failed:', error);
|
||||||
throw 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
|
// Load auth state on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadAuth = async () => {
|
refreshAuth();
|
||||||
setLoading(true);
|
}, [refreshAuth]);
|
||||||
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]);
|
|
||||||
|
|
||||||
// Determine if unlock is required (has encrypted key but not unlocked)
|
// Determine if unlock is required (has encrypted key but not unlocked)
|
||||||
const requiresUnlock = !!user?.privateKeyEncrypted && !isUnlocked && !isRestoring;
|
const requiresUnlock = !!user?.privateKeyEncrypted && !isUnlocked && !isRestoring;
|
||||||
|
const activeAccountId = user?.id ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{
|
<AuthContext.Provider value={{
|
||||||
user,
|
user,
|
||||||
|
accounts,
|
||||||
|
activeAccountId,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
loading,
|
loading,
|
||||||
isIdentityUnlocked: isUnlocked,
|
isIdentityUnlocked: isUnlocked,
|
||||||
@@ -197,6 +229,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
unlockIdentity,
|
unlockIdentity,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
switchAccount,
|
||||||
|
refreshAuth,
|
||||||
lockIdentity,
|
lockIdentity,
|
||||||
signUserAction,
|
signUserAction,
|
||||||
requiresUnlock,
|
requiresUnlock,
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import { deserializeEncryptedKey, type EncryptedPrivateKey } from './private-key
|
|||||||
const DB_NAME = 'synapsis-identity';
|
const DB_NAME = 'synapsis-identity';
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 1;
|
||||||
const STORE_NAME = 'keys';
|
const STORE_NAME = 'keys';
|
||||||
const SESSION_KEY_ITEM = 'synapsis_session_key';
|
const SESSION_KEY_PREFIX = 'synapsis_session_key:';
|
||||||
const WRAPPED_KEY_ITEM = 'synapsis_wrapped_key';
|
const WRAPPED_KEY_PREFIX = 'synapsis_wrapped_key:';
|
||||||
|
|
||||||
interface WrappedKey {
|
interface WrappedKey {
|
||||||
wrapped: string; // Base64 of wrapped key
|
wrapped: string; // Base64 of wrapped key
|
||||||
@@ -32,6 +32,14 @@ interface SessionData {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSessionKeyItem(identifier: string): string {
|
||||||
|
return `${SESSION_KEY_PREFIX}${identifier}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWrappedKeyItem(identifier: string): string {
|
||||||
|
return `${WRAPPED_KEY_PREFIX}${identifier}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// IndexedDB Operations
|
// IndexedDB Operations
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -218,7 +226,8 @@ async function unwrapRawPrivateKey(
|
|||||||
*/
|
*/
|
||||||
export async function persistUnlockedKey(
|
export async function persistUnlockedKey(
|
||||||
privateKeyBase64: string,
|
privateKeyBase64: string,
|
||||||
password: string
|
password: string,
|
||||||
|
identifier: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Derive session key from password
|
// Derive session key from password
|
||||||
@@ -228,7 +237,7 @@ export async function persistUnlockedKey(
|
|||||||
const wrapped = await wrapRawPrivateKey(privateKeyBase64, sessionKey);
|
const wrapped = await wrapRawPrivateKey(privateKeyBase64, sessionKey);
|
||||||
|
|
||||||
// Store wrapped key in IndexedDB
|
// 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)
|
// Store session key in localStorage (so it survives refreshes)
|
||||||
const sessionKeyData = await exportSessionKey(sessionKey);
|
const sessionKeyData = await exportSessionKey(sessionKey);
|
||||||
@@ -236,7 +245,7 @@ export async function persistUnlockedKey(
|
|||||||
key: sessionKeyData,
|
key: sessionKeyData,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
};
|
};
|
||||||
localStorage.setItem(SESSION_KEY_ITEM, JSON.stringify(sessionData));
|
localStorage.setItem(getSessionKeyItem(identifier), JSON.stringify(sessionData));
|
||||||
|
|
||||||
console.log('[KeyPersistence] Key persisted successfully');
|
console.log('[KeyPersistence] Key persisted successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -250,10 +259,10 @@ export async function persistUnlockedKey(
|
|||||||
* Returns the raw key bytes if available, null otherwise
|
* Returns the raw key bytes if available, null otherwise
|
||||||
* The caller must then import these bytes as a non-extractable CryptoKey
|
* 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 {
|
try {
|
||||||
// Get session key from localStorage
|
// Get session key from localStorage
|
||||||
const sessionDataRaw = localStorage.getItem(SESSION_KEY_ITEM);
|
const sessionDataRaw = localStorage.getItem(getSessionKeyItem(identifier));
|
||||||
if (!sessionDataRaw) {
|
if (!sessionDataRaw) {
|
||||||
console.log('[KeyPersistence] No session key found');
|
console.log('[KeyPersistence] No session key found');
|
||||||
return null;
|
return null;
|
||||||
@@ -262,15 +271,15 @@ export async function tryRestoreKey(): Promise<ArrayBuffer | null> {
|
|||||||
const sessionData: SessionData = JSON.parse(sessionDataRaw);
|
const sessionData: SessionData = JSON.parse(sessionDataRaw);
|
||||||
|
|
||||||
// Check expiry (24 hours)
|
// 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) {
|
if (Date.now() - sessionData.createdAt > MAX_AGE) {
|
||||||
console.log('[KeyPersistence] Session expired');
|
console.log('[KeyPersistence] Session expired');
|
||||||
await clearPersistentKey();
|
await clearPersistentKey(identifier);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get wrapped key from IndexedDB
|
// Get wrapped key from IndexedDB
|
||||||
const wrapped = await getFromDB<WrappedKey>(WRAPPED_KEY_ITEM);
|
const wrapped = await getFromDB<WrappedKey>(getWrappedKeyItem(identifier));
|
||||||
if (!wrapped) {
|
if (!wrapped) {
|
||||||
console.log('[KeyPersistence] No wrapped key found');
|
console.log('[KeyPersistence] No wrapped key found');
|
||||||
return null;
|
return null;
|
||||||
@@ -293,10 +302,24 @@ export async function tryRestoreKey(): Promise<ArrayBuffer | null> {
|
|||||||
/**
|
/**
|
||||||
* Clear the persisted key (logout)
|
* Clear the persisted key (logout)
|
||||||
*/
|
*/
|
||||||
export async function clearPersistentKey(): Promise<void> {
|
export async function clearPersistentKey(identifier?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(SESSION_KEY_ITEM);
|
if (identifier) {
|
||||||
await removeFromDB(WRAPPED_KEY_ITEM);
|
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');
|
console.log('[KeyPersistence] Key cleared');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[KeyPersistence] Error clearing key:', 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
|
* Check if a persisted key is available
|
||||||
*/
|
*/
|
||||||
export async function hasPersistentKey(): Promise<boolean> {
|
export async function hasPersistentKey(identifier: string): Promise<boolean> {
|
||||||
const sessionData = localStorage.getItem(SESSION_KEY_ITEM);
|
const sessionData = localStorage.getItem(getSessionKeyItem(identifier));
|
||||||
if (!sessionData) return false;
|
if (!sessionData) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed: SessionData = JSON.parse(sessionData);
|
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;
|
return Date.now() - parsed.createdAt <= MAX_AGE;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -51,10 +51,14 @@ export function useUserIdentity() {
|
|||||||
setIsRestoring(false);
|
setIsRestoring(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!globalIdentity?.did) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Try to restore from persistent storage
|
// Try to restore from persistent storage
|
||||||
const restoredKeyBytes = await tryRestoreKey();
|
const restoredKeyBytes = await tryRestoreKey(globalIdentity.did);
|
||||||
if (restoredKeyBytes && globalIdentity) {
|
if (restoredKeyBytes) {
|
||||||
// Import the restored key as non-extractable
|
// Import the restored key as non-extractable
|
||||||
const cryptoKey = await importPrivateKey(restoredKeyBytes);
|
const cryptoKey = await importPrivateKey(restoredKeyBytes);
|
||||||
keyStore.setPrivateKey(cryptoKey);
|
keyStore.setPrivateKey(cryptoKey);
|
||||||
@@ -86,6 +90,8 @@ export function useUserIdentity() {
|
|||||||
publicKey: string;
|
publicKey: string;
|
||||||
privateKeyEncrypted?: string;
|
privateKeyEncrypted?: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
keyStore.clear();
|
||||||
|
|
||||||
const coreIdentity = {
|
const coreIdentity = {
|
||||||
did: userData.did,
|
did: userData.did,
|
||||||
handle: userData.handle,
|
handle: userData.handle,
|
||||||
@@ -94,7 +100,7 @@ export function useUserIdentity() {
|
|||||||
keyStore.setIdentity(coreIdentity);
|
keyStore.setIdentity(coreIdentity);
|
||||||
|
|
||||||
// Try to auto-restore if we have persisted key
|
// Try to auto-restore if we have persisted key
|
||||||
const restoredKeyBytes = await tryRestoreKey();
|
const restoredKeyBytes = await tryRestoreKey(userData.did);
|
||||||
if (restoredKeyBytes) {
|
if (restoredKeyBytes) {
|
||||||
const cryptoKey = await importPrivateKey(restoredKeyBytes);
|
const cryptoKey = await importPrivateKey(restoredKeyBytes);
|
||||||
keyStore.setPrivateKey(cryptoKey);
|
keyStore.setPrivateKey(cryptoKey);
|
||||||
@@ -150,7 +156,11 @@ export function useUserIdentity() {
|
|||||||
|
|
||||||
// PERSIST: Save raw key bytes for auto-restore on refresh
|
// PERSIST: Save raw key bytes for auto-restore on refresh
|
||||||
// We pass the raw bytes because the CryptoKey is non-extractable
|
// 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');
|
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)
|
* Lock the identity (manual lock, keeps identity info)
|
||||||
*/
|
*/
|
||||||
const lockIdentity = useCallback(async () => {
|
const lockIdentity = useCallback(async () => {
|
||||||
|
const identifier = keyStore.getIdentity()?.did;
|
||||||
keyStore.clear();
|
keyStore.clear();
|
||||||
await clearPersistentKey();
|
await clearPersistentKey(identifier);
|
||||||
setIsUnlocked(false);
|
setIsUnlocked(false);
|
||||||
setIdentity(prev => prev ? { ...prev, isUnlocked: false } : null);
|
setIdentity(prev => prev ? { ...prev, isUnlocked: false } : null);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -181,8 +192,9 @@ export function useUserIdentity() {
|
|||||||
* Clear the identity (logout)
|
* Clear the identity (logout)
|
||||||
*/
|
*/
|
||||||
const clearIdentity = useCallback(async () => {
|
const clearIdentity = useCallback(async () => {
|
||||||
|
const identifier = keyStore.getIdentity()?.did;
|
||||||
keyStore.clear();
|
keyStore.clear();
|
||||||
await clearPersistentKey();
|
await clearPersistentKey(identifier);
|
||||||
setIdentity(null);
|
setIdentity(null);
|
||||||
setIsUnlocked(false);
|
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 type { users } from '@/db';
|
||||||
import { decryptS3Credentials, type StorageProvider } from '@/lib/storage/s3';
|
import { decryptS3Credentials, type StorageProvider } from '@/lib/storage/s3';
|
||||||
|
|
||||||
const STORAGE_SESSION_COOKIE = 'synapsis_storage_session';
|
const STORAGE_SESSION_COOKIE = 'synapsis_storage_sessions';
|
||||||
const STORAGE_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
const STORAGE_SESSION_TTL_MS = 3650 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
interface StorageSessionPayload {
|
interface StorageSessionPayload {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -18,6 +18,8 @@ interface StorageSessionPayload {
|
|||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StorageSessionMap = Record<string, StorageSessionPayload>;
|
||||||
|
|
||||||
function getEncryptionKey(): Buffer {
|
function getEncryptionKey(): Buffer {
|
||||||
const secret = process.env.AUTH_SECRET;
|
const secret = process.env.AUTH_SECRET;
|
||||||
|
|
||||||
@@ -63,6 +65,55 @@ function decryptPayload(token: string): StorageSessionPayload {
|
|||||||
return JSON.parse(decrypted) as 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(
|
export async function createStorageSession(
|
||||||
user: typeof users.$inferSelect,
|
user: typeof users.$inferSelect,
|
||||||
password: string
|
password: string
|
||||||
@@ -73,7 +124,7 @@ export async function createStorageSession(
|
|||||||
!user.storageSecretKeyEncrypted ||
|
!user.storageSecretKeyEncrypted ||
|
||||||
!user.storageBucket
|
!user.storageBucket
|
||||||
) {
|
) {
|
||||||
await clearStorageSession();
|
await clearStorageSession(user.id);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,42 +146,42 @@ export async function createStorageSession(
|
|||||||
expiresAt: Date.now() + STORAGE_SESSION_TTL_MS,
|
expiresAt: Date.now() + STORAGE_SESSION_TTL_MS,
|
||||||
};
|
};
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
const existingSessions = await readStorageSessionMap();
|
||||||
cookieStore.set(STORAGE_SESSION_COOKIE, encryptPayload(payload), {
|
existingSessions[user.id] = payload;
|
||||||
httpOnly: true,
|
await writeStorageSessionMap(existingSessions);
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
path: '/',
|
|
||||||
expires: new Date(payload.expiresAt),
|
|
||||||
});
|
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStorageSession(userId: string): Promise<StorageSessionPayload | null> {
|
export async function getStorageSession(userId: string): Promise<StorageSessionPayload | null> {
|
||||||
const cookieStore = await cookies();
|
const sessions = await readStorageSessionMap();
|
||||||
const token = cookieStore.get(STORAGE_SESSION_COOKIE)?.value;
|
const payload = sessions[userId];
|
||||||
|
|
||||||
if (!token) {
|
if (!payload) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (payload.expiresAt <= Date.now()) {
|
||||||
const payload = decryptPayload(token);
|
delete sessions[userId];
|
||||||
|
await writeStorageSessionMap(sessions);
|
||||||
if (payload.userId !== userId || payload.expiresAt <= Date.now()) {
|
|
||||||
await clearStorageSession();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload;
|
|
||||||
} catch {
|
|
||||||
await clearStorageSession();
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearStorageSession(): Promise<void> {
|
export async function clearStorageSession(userId?: string): Promise<void> {
|
||||||
const cookieStore = await cookies();
|
if (!userId) {
|
||||||
cookieStore.delete(STORAGE_SESSION_COOKIE);
|
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 { getActiveSwarmNodes } from './registry';
|
||||||
import type { SwarmNodeInfo } from './types';
|
import type { SwarmNodeInfo } from './types';
|
||||||
|
import { filterBlockedDomains, isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
|
||||||
|
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@@ -141,16 +143,24 @@ export interface SwarmMentionPayload {
|
|||||||
* Check if a domain is a known Synapsis swarm node
|
* Check if a domain is a known Synapsis swarm node
|
||||||
*/
|
*/
|
||||||
export async function isSwarmNode(domain: string): Promise<boolean> {
|
export async function isSwarmNode(domain: string): Promise<boolean> {
|
||||||
|
const normalizedDomain = normalizeNodeDomain(domain);
|
||||||
|
if (await isNodeBlocked(normalizedDomain)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const nodes = await getActiveSwarmNodes(500);
|
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
|
* Get swarm node info if the domain is a swarm node
|
||||||
*/
|
*/
|
||||||
export async function getSwarmNodeInfo(domain: string): Promise<SwarmNodeInfo | null> {
|
export async function getSwarmNodeInfo(domain: string): Promise<SwarmNodeInfo | null> {
|
||||||
|
const normalizedDomain = normalizeNodeDomain(domain);
|
||||||
|
if (await isNodeBlocked(normalizedDomain)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const nodes = await getActiveSwarmNodes(500);
|
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
|
payload: unknown
|
||||||
): Promise<SwarmInteractionResponse> {
|
): Promise<SwarmInteractionResponse> {
|
||||||
try {
|
try {
|
||||||
|
const normalizedTargetDomain = normalizeNodeDomain(targetDomain);
|
||||||
|
if (await isNodeBlocked(normalizedTargetDomain)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Blocked node: ${normalizedTargetDomain}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const baseUrl = targetDomain.startsWith('http')
|
const baseUrl = targetDomain.startsWith('http')
|
||||||
? targetDomain
|
? targetDomain
|
||||||
: targetDomain.startsWith('localhost') || targetDomain.startsWith('127.0.0.1')
|
: normalizedTargetDomain.startsWith('localhost') || normalizedTargetDomain.startsWith('127.0.0.1')
|
||||||
? `http://${targetDomain}`
|
? `http://${normalizedTargetDomain}`
|
||||||
: `https://${targetDomain}`;
|
: `https://${normalizedTargetDomain}`;
|
||||||
|
|
||||||
const url = `${baseUrl}${endpoint}`;
|
const url = `${baseUrl}${endpoint}`;
|
||||||
|
|
||||||
@@ -334,17 +352,31 @@ export interface SwarmUserProfile {
|
|||||||
|
|
||||||
export interface SwarmUserPost {
|
export interface SwarmUserPost {
|
||||||
id: string;
|
id: string;
|
||||||
|
originalPostId?: string;
|
||||||
content: string;
|
content: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
isNsfw: boolean;
|
isNsfw: boolean;
|
||||||
likesCount: number;
|
likesCount: number;
|
||||||
repostsCount: number;
|
repostsCount: number;
|
||||||
repliesCount: number;
|
repliesCount: number;
|
||||||
|
nodeDomain?: string;
|
||||||
|
author?: {
|
||||||
|
handle: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
isBot?: boolean;
|
||||||
|
nodeDomain?: string;
|
||||||
|
};
|
||||||
media?: { url: string; mimeType?: string; altText?: string }[];
|
media?: { url: string; mimeType?: string; altText?: string }[];
|
||||||
linkPreviewUrl?: string;
|
linkPreviewUrl?: string;
|
||||||
linkPreviewTitle?: string;
|
linkPreviewTitle?: string;
|
||||||
linkPreviewDescription?: string;
|
linkPreviewDescription?: string;
|
||||||
linkPreviewImage?: 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 {
|
export interface SwarmProfileResponse {
|
||||||
@@ -363,11 +395,16 @@ export async function fetchSwarmUserProfile(
|
|||||||
postsLimit: number = 25
|
postsLimit: number = 25
|
||||||
): Promise<SwarmProfileResponse | null> {
|
): Promise<SwarmProfileResponse | null> {
|
||||||
try {
|
try {
|
||||||
|
const normalizedDomain = normalizeNodeDomain(domain);
|
||||||
|
if (await isNodeBlocked(normalizedDomain)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const baseUrl = domain.startsWith('http')
|
const baseUrl = domain.startsWith('http')
|
||||||
? domain
|
? domain
|
||||||
: domain.startsWith('localhost') || domain.startsWith('127.0.0.1')
|
: normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1')
|
||||||
? `http://${domain}`
|
? `http://${normalizedDomain}`
|
||||||
: `https://${domain}`;
|
: `https://${normalizedDomain}`;
|
||||||
|
|
||||||
const url = `${baseUrl}/api/swarm/users/${handle}?limit=${postsLimit}`;
|
const url = `${baseUrl}/api/swarm/users/${handle}?limit=${postsLimit}`;
|
||||||
|
|
||||||
@@ -449,6 +486,9 @@ export async function cacheSwarmUserPosts(
|
|||||||
linkPreviewTitle: post.linkPreviewTitle || null,
|
linkPreviewTitle: post.linkPreviewTitle || null,
|
||||||
linkPreviewDescription: post.linkPreviewDescription || null,
|
linkPreviewDescription: post.linkPreviewDescription || null,
|
||||||
linkPreviewImage: post.linkPreviewImage || 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,
|
mediaJson: post.media ? JSON.stringify(post.media) : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -470,11 +510,16 @@ export async function fetchSwarmPost(
|
|||||||
domain: string
|
domain: string
|
||||||
): Promise<SwarmUserPost | null> {
|
): Promise<SwarmUserPost | null> {
|
||||||
try {
|
try {
|
||||||
|
const normalizedDomain = normalizeNodeDomain(domain);
|
||||||
|
if (await isNodeBlocked(normalizedDomain)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const baseUrl = domain.startsWith('http')
|
const baseUrl = domain.startsWith('http')
|
||||||
? domain
|
? domain
|
||||||
: domain.startsWith('localhost') || domain.startsWith('127.0.0.1')
|
: normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1')
|
||||||
? `http://${domain}`
|
? `http://${normalizedDomain}`
|
||||||
: `https://${domain}`;
|
: `https://${normalizedDomain}`;
|
||||||
|
|
||||||
const url = `${baseUrl}/api/swarm/posts/${postId}`;
|
const url = `${baseUrl}/api/swarm/posts/${postId}`;
|
||||||
|
|
||||||
@@ -591,6 +636,9 @@ export interface SwarmPostDeliveryPayload {
|
|||||||
linkPreviewTitle?: string;
|
linkPreviewTitle?: string;
|
||||||
linkPreviewDescription?: string;
|
linkPreviewDescription?: string;
|
||||||
linkPreviewImage?: string;
|
linkPreviewImage?: string;
|
||||||
|
linkPreviewType?: 'card' | 'image' | 'gallery' | 'video';
|
||||||
|
linkPreviewVideoUrl?: string;
|
||||||
|
linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }>;
|
||||||
};
|
};
|
||||||
author: {
|
author: {
|
||||||
handle: string;
|
handle: string;
|
||||||
@@ -637,7 +685,7 @@ export async function getSwarmFollowerDomains(userId: string): Promise<string[]>
|
|||||||
return match ? match[1] : null;
|
return match ? match[1] : null;
|
||||||
}).filter((d): d is string => d !== null);
|
}).filter((d): d is string => d !== null);
|
||||||
|
|
||||||
return [...new Set(domains)];
|
return await filterBlockedDomains([...new Set(domains)]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Swarm] Error getting swarm follower domains:', error);
|
console.error('[Swarm] Error getting swarm follower domains:', error);
|
||||||
return [];
|
return [];
|
||||||
@@ -660,6 +708,9 @@ export async function deliverPostToSwarmFollowers(
|
|||||||
linkPreviewTitle?: string | null;
|
linkPreviewTitle?: string | null;
|
||||||
linkPreviewDescription?: string | null;
|
linkPreviewDescription?: string | null;
|
||||||
linkPreviewImage?: 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: {
|
author: {
|
||||||
handle: string;
|
handle: string;
|
||||||
@@ -693,6 +744,9 @@ export async function deliverPostToSwarmFollowers(
|
|||||||
linkPreviewTitle: post.linkPreviewTitle || undefined,
|
linkPreviewTitle: post.linkPreviewTitle || undefined,
|
||||||
linkPreviewDescription: post.linkPreviewDescription || undefined,
|
linkPreviewDescription: post.linkPreviewDescription || undefined,
|
||||||
linkPreviewImage: post.linkPreviewImage || undefined,
|
linkPreviewImage: post.linkPreviewImage || undefined,
|
||||||
|
linkPreviewType: (post.linkPreviewType as SwarmPostDeliveryPayload['post']['linkPreviewType']) || undefined,
|
||||||
|
linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined,
|
||||||
|
linkPreviewMedia: post.linkPreviewMedia || undefined,
|
||||||
},
|
},
|
||||||
author: {
|
author: {
|
||||||
handle: author.handle,
|
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 { eq, desc, and, gt, lt, sql } from 'drizzle-orm';
|
||||||
import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types';
|
import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types';
|
||||||
import { SWARM_CONFIG, DEFAULT_SEED_NODES } from './types';
|
import { SWARM_CONFIG, DEFAULT_SEED_NODES } from './types';
|
||||||
|
import { normalizeNodeDomain } from './node-blocklist';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a swarm node entry
|
* Get or create a swarm node entry
|
||||||
@@ -21,14 +22,16 @@ export async function upsertSwarmNode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existing = await db.query.swarmNodes.findFirst({
|
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;
|
const capabilities = node.capabilities ? JSON.stringify(node.capabilities) : null;
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
await db.insert(swarmNodes).values({
|
await db.insert(swarmNodes).values({
|
||||||
domain: node.domain,
|
domain: normalizedDomain,
|
||||||
name: node.name,
|
name: node.name,
|
||||||
description: node.description,
|
description: node.description,
|
||||||
logoUrl: node.logoUrl,
|
logoUrl: node.logoUrl,
|
||||||
@@ -58,10 +61,10 @@ export async function upsertSwarmNode(
|
|||||||
capabilities: capabilities ?? existing.capabilities,
|
capabilities: capabilities ?? existing.capabilities,
|
||||||
lastSeenAt: new Date(),
|
lastSeenAt: new Date(),
|
||||||
consecutiveFailures: 0,
|
consecutiveFailures: 0,
|
||||||
isActive: true,
|
isActive: existing.isBlocked ? false : true,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(swarmNodes.domain, node.domain));
|
.where(eq(swarmNodes.domain, normalizedDomain));
|
||||||
|
|
||||||
return { isNew: false };
|
return { isNew: false };
|
||||||
}
|
}
|
||||||
@@ -83,8 +86,10 @@ export async function upsertSwarmNodes(
|
|||||||
// Filter out our own domain
|
// Filter out our own domain
|
||||||
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||||
const filteredNodes = nodes.filter(n => n.domain !== ourDomain);
|
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);
|
const result = await upsertSwarmNode(node, discoveredVia);
|
||||||
if (result.isNew) {
|
if (result.isNew) {
|
||||||
added++;
|
added++;
|
||||||
@@ -105,7 +110,10 @@ export async function getActiveSwarmNodes(limit = 100): Promise<SwarmNodeInfo[]>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nodes = await db.query.swarmNodes.findMany({
|
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)],
|
orderBy: [desc(swarmNodes.lastSeenAt)],
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
@@ -125,6 +133,7 @@ export async function getNodesForGossip(count: number): Promise<SwarmNodeInfo[]>
|
|||||||
const nodes = await db.query.swarmNodes.findMany({
|
const nodes = await db.query.swarmNodes.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(swarmNodes.isActive, true),
|
eq(swarmNodes.isActive, true),
|
||||||
|
eq(swarmNodes.isBlocked, false),
|
||||||
gt(swarmNodes.trustScore, 20)
|
gt(swarmNodes.trustScore, 20)
|
||||||
),
|
),
|
||||||
orderBy: sql`RANDOM()`,
|
orderBy: sql`RANDOM()`,
|
||||||
@@ -143,7 +152,10 @@ export async function getNodesSince(since: Date, limit = 100): Promise<SwarmNode
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nodes = await db.query.swarmNodes.findMany({
|
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)],
|
orderBy: [desc(swarmNodes.updatedAt)],
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
@@ -177,7 +189,7 @@ export async function markNodeFailure(domain: string): Promise<void> {
|
|||||||
.set({
|
.set({
|
||||||
consecutiveFailures: newFailures,
|
consecutiveFailures: newFailures,
|
||||||
trustScore: newTrust,
|
trustScore: newTrust,
|
||||||
isActive,
|
isActive: node.isBlocked ? false : isActive,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(swarmNodes.domain, domain));
|
.where(eq(swarmNodes.domain, domain));
|
||||||
@@ -211,7 +223,7 @@ export async function markNodeSuccess(domain: string): Promise<void> {
|
|||||||
.set({
|
.set({
|
||||||
consecutiveFailures: 0,
|
consecutiveFailures: 0,
|
||||||
trustScore: newTrust,
|
trustScore: newTrust,
|
||||||
isActive: true,
|
isActive: node.isBlocked ? false : true,
|
||||||
lastSeenAt: new Date(),
|
lastSeenAt: new Date(),
|
||||||
lastSyncAt: new Date(),
|
lastSyncAt: new Date(),
|
||||||
updatedAt: 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 { db, users } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { canonicalize } from '@/lib/crypto/user-signing';
|
import { canonicalize } from '@/lib/crypto/user-signing';
|
||||||
|
import { isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign a payload with the node's private key
|
* 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> {
|
export async function getNodePublicKey(domain: string): Promise<string | null> {
|
||||||
try {
|
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
|
// Check if we have a cached node info
|
||||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https';
|
||||||
const response = await fetch(`${protocol}://${domain}/api/node`, {
|
const response = await fetch(`${protocol}://${normalizedDomain}/api/node`, {
|
||||||
headers: { 'Accept': 'application/json' },
|
headers: { 'Accept': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +96,14 @@ export async function verifySwarmRequest(
|
|||||||
signature: string,
|
signature: string,
|
||||||
senderDomain: string
|
senderDomain: string
|
||||||
): Promise<boolean> {
|
): 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
|
// Get the sender node's public key
|
||||||
const publicKey = await getNodePublicKey(senderDomain);
|
const publicKey = await getNodePublicKey(normalizedDomain);
|
||||||
|
|
||||||
if (!publicKey) {
|
if (!publicKey) {
|
||||||
console.error(`[Signature] Could not get public key for ${senderDomain}`);
|
console.error(`[Signature] Could not get public key for ${senderDomain}`);
|
||||||
@@ -118,8 +132,14 @@ export async function verifyUserInteraction(
|
|||||||
userDomain: string
|
userDomain: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
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
|
// Try to get cached user
|
||||||
const fullHandle = `${userHandle}@${userDomain}`;
|
const fullHandle = `${userHandle}@${normalizedDomain}`;
|
||||||
let user = await db?.query.users.findFirst({
|
let user = await db?.query.users.findFirst({
|
||||||
where: eq(users.handle, fullHandle),
|
where: eq(users.handle, fullHandle),
|
||||||
});
|
});
|
||||||
@@ -130,11 +150,14 @@ export async function verifyUserInteraction(
|
|||||||
publicKey = user.publicKey;
|
publicKey = user.publicKey;
|
||||||
} else {
|
} else {
|
||||||
// Fetch from remote node
|
// Fetch from remote node
|
||||||
const protocol = userDomain.includes('localhost') ? 'http' : 'https';
|
const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https';
|
||||||
const response = await fetch(`${protocol}://${userDomain}/api/users/${userHandle}`);
|
const response = await fetch(`${protocol}://${normalizedDomain}/api/users/${userHandle}`, {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +167,7 @@ export async function verifyUserInteraction(
|
|||||||
// Cache the user if we don't have them
|
// Cache the user if we don't have them
|
||||||
if (!user && publicKey && db) {
|
if (!user && publicKey && db) {
|
||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
did: userData.user?.did || `did:swarm:${userDomain}:${userHandle}`,
|
did: userData.user?.did || `did:swarm:${normalizedDomain}:${userHandle}`,
|
||||||
handle: fullHandle,
|
handle: fullHandle,
|
||||||
displayName: userData.user?.displayName || userHandle,
|
displayName: userData.user?.displayName || userHandle,
|
||||||
avatarUrl: userData.user?.avatarUrl,
|
avatarUrl: userData.user?.avatarUrl,
|
||||||
|
|||||||
+21
-12
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
import { getActiveSwarmNodes } from './registry';
|
import { getActiveSwarmNodes } from './registry';
|
||||||
import type { SwarmPost } from '@/app/api/swarm/timeline/route';
|
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 {
|
interface TimelineResult {
|
||||||
posts: SwarmPost[];
|
posts: SwarmPost[];
|
||||||
@@ -41,12 +43,7 @@ function extractFirstUrl(content: string): string | null {
|
|||||||
/**
|
/**
|
||||||
* Fetch link preview for a URL
|
* Fetch link preview for a URL
|
||||||
*/
|
*/
|
||||||
async function fetchLinkPreview(url: string): Promise<{
|
async function fetchLinkPreview(url: string): Promise<LinkPreviewData | null> {
|
||||||
url: string;
|
|
||||||
title: string | null;
|
|
||||||
description: string | null;
|
|
||||||
image: string | null;
|
|
||||||
} | null> {
|
|
||||||
try {
|
try {
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||||
const protocol = nodeDomain === 'localhost' ? 'http' : 'https';
|
const protocol = nodeDomain === 'localhost' ? 'http' : 'https';
|
||||||
@@ -70,6 +67,9 @@ async function fetchLinkPreview(url: string): Promise<{
|
|||||||
title: data.title || null,
|
title: data.title || null,
|
||||||
description: data.description || null,
|
description: data.description || null,
|
||||||
image: data.image || null,
|
image: data.image || null,
|
||||||
|
type: data.type || null,
|
||||||
|
videoUrl: data.videoUrl || null,
|
||||||
|
media: data.media || null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -101,6 +101,9 @@ async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise<SwarmPost[]>
|
|||||||
linkPreviewTitle: preview.title || undefined,
|
linkPreviewTitle: preview.title || undefined,
|
||||||
linkPreviewDescription: preview.description || undefined,
|
linkPreviewDescription: preview.description || undefined,
|
||||||
linkPreviewImage: preview.image || 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
|
cursor?: string
|
||||||
): Promise<{ posts: SwarmPost[]; nodeIsNsfw?: boolean; error?: string }> {
|
): Promise<{ posts: SwarmPost[]; nodeIsNsfw?: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
|
const normalizedDomain = normalizeNodeDomain(domain);
|
||||||
|
if (await isNodeBlocked(normalizedDomain)) {
|
||||||
|
return { posts: [], error: 'Blocked node' };
|
||||||
|
}
|
||||||
|
|
||||||
// Determine protocol - use http for localhost, https for everything else
|
// Determine protocol - use http for localhost, https for everything else
|
||||||
let baseUrl: string;
|
let baseUrl: string;
|
||||||
if (domain.startsWith('http')) {
|
if (domain.startsWith('http')) {
|
||||||
baseUrl = domain;
|
baseUrl = domain;
|
||||||
} else if (domain.startsWith('localhost') || domain.startsWith('127.0.0.1')) {
|
} else if (normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1')) {
|
||||||
baseUrl = `http://${domain}`;
|
baseUrl = `http://${normalizedDomain}`;
|
||||||
} else {
|
} else {
|
||||||
baseUrl = `https://${domain}`;
|
baseUrl = `https://${normalizedDomain}`;
|
||||||
}
|
}
|
||||||
const url = `${baseUrl}/api/swarm/timeline?limit=${limit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
|
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);
|
const nodes = await getActiveSwarmNodes(maxNodes);
|
||||||
|
|
||||||
// Always include our own posts
|
// 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
|
// Always query all nodes - we filter posts, not nodes
|
||||||
const nodesToQuery = [
|
const candidateDomains = [
|
||||||
ourDomain,
|
ourDomain,
|
||||||
...nodes.map(n => n.domain).filter(d => d !== 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] Querying ${nodesToQuery.length} nodes: ${nodesToQuery.join(', ')}`);
|
||||||
console.log(`[Swarm Timeline] includeNsfw: ${includeNsfw}, cursor: ${cursor || 'none'}`);
|
console.log(`[Swarm Timeline] includeNsfw: ${includeNsfw}, cursor: ${cursor || 'none'}`);
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ export interface Attachment {
|
|||||||
altText?: string | null;
|
altText?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LinkPreviewMediaItem {
|
||||||
|
url: string;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
mimeType?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Post {
|
export interface Post {
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -53,8 +60,13 @@ export interface Post {
|
|||||||
linkPreviewTitle?: string | null;
|
linkPreviewTitle?: string | null;
|
||||||
linkPreviewDescription?: string | null;
|
linkPreviewDescription?: string | null;
|
||||||
linkPreviewImage?: string | null;
|
linkPreviewImage?: string | null;
|
||||||
|
linkPreviewType?: 'card' | 'image' | 'gallery' | 'video' | null;
|
||||||
|
linkPreviewVideoUrl?: string | null;
|
||||||
|
linkPreviewMedia?: LinkPreviewMediaItem[] | null;
|
||||||
replyTo?: Post | null;
|
replyTo?: Post | null;
|
||||||
replyToId?: string | null;
|
replyToId?: string | null;
|
||||||
|
repostOf?: Post | null;
|
||||||
|
repostOfId?: string | null;
|
||||||
// Swarm reply info (when replying to a post on another node)
|
// Swarm reply info (when replying to a post on another node)
|
||||||
swarmReplyToId?: string | null;
|
swarmReplyToId?: string | null;
|
||||||
swarmReplyToContent?: string | null;
|
swarmReplyToContent?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user