From b4a9d82d057da9e4fe338472a3dc451149739fec Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Tue, 10 Mar 2026 12:40:05 -0700 Subject: [PATCH] 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). --- .github/workflows/docker.yml | 45 +- .tmp/remote-docker-doc.mdx | 247 +++++++++++ HOW_TO_WORK_ON_SYNAPIS.md | 18 +- README.md | 2 +- docker/README.md | 25 +- .../__pycache__/host-updater.cpython-314.pyc | Bin 0 -> 17485 bytes scripts/docker-publish.sh | 118 ++++-- src/app/admin/page.tsx | 62 --- src/app/api/admin/nodes/route.ts | 72 ++++ src/app/api/auth/logout/route.ts | 22 +- src/app/api/auth/me/route.ts | 8 +- src/app/api/auth/switch/route.ts | 37 ++ src/app/api/bots/[id]/api-key/route.ts | 2 +- src/app/api/bots/[id]/route.ts | 6 +- src/app/api/bots/route.ts | 6 +- src/app/api/chat/receive/route.ts | 22 +- src/app/api/chat/send/route.ts | 6 + src/app/api/media/preview/route.ts | 128 ++---- src/app/api/notifications/route.ts | 9 + src/app/api/posts/[id]/like/route.ts | 70 ++- src/app/api/posts/[id]/repost/route.ts | 109 ++++- src/app/api/posts/[id]/route.ts | 149 ++++--- src/app/api/posts/route.ts | 397 +++++++++++++----- src/app/api/posts/swarm/route.ts | 63 ++- src/app/api/search/route.ts | 26 +- .../swarm/chat/conversations/[id]/route.ts | 7 +- src/app/api/swarm/chat/delete/route.ts | 10 +- src/app/api/swarm/inbox/route.ts | 8 + .../api/swarm/interactions/follow/route.ts | 3 + src/app/api/swarm/interactions/like/route.ts | 6 +- .../api/swarm/interactions/mention/route.ts | 3 + .../api/swarm/interactions/repost/route.ts | 31 +- .../api/swarm/interactions/unrepost/route.ts | 21 +- src/app/api/swarm/posts/[id]/route.ts | 70 ++- src/app/api/swarm/replies/route.ts | 18 + src/app/api/swarm/timeline/route.ts | 186 ++++++-- src/app/api/swarm/users/[handle]/route.ts | 201 ++++++--- src/app/api/users/[handle]/follow/route.ts | 3 + src/app/api/users/[handle]/likes/route.ts | 125 +++++- src/app/api/users/[handle]/posts/route.ts | 383 +++++++++++++---- src/app/api/users/[handle]/replies/route.ts | 197 +++++++++ src/app/api/users/[handle]/route.ts | 15 + src/app/bots/[id]/edit/page.tsx | 56 ++- src/app/bots/new/page.tsx | 59 ++- src/app/globals.css | 129 +++++- src/app/login/page.tsx | 116 ++++- src/app/moderation/page.tsx | 158 ++++++- src/app/notifications/page.tsx | 30 +- src/app/u/[handle]/page.tsx | 66 ++- src/components/Compose.tsx | 34 +- src/components/PostCard.tsx | 293 +++++++++++-- src/components/Sidebar.tsx | 270 ++++++++++-- src/db/schema.ts | 56 ++- src/lib/auth/index.ts | 204 +++++++-- src/lib/bots/autonomous.ts | 5 +- src/lib/bots/botManager.ts | 104 ++++- src/lib/bots/contentFetcher.test.ts | 84 ++++ src/lib/bots/contentFetcher.ts | 65 ++- src/lib/bots/contentGenerator.ts | 3 + src/lib/bots/encryption.ts | 7 +- src/lib/bots/llmClient.ts | 37 +- src/lib/bots/mentionHandler.ts | 1 + src/lib/bots/posting.ts | 170 +++----- src/lib/contexts/AuthContext.tsx | 150 ++++--- src/lib/crypto/key-persistence.ts | 55 ++- src/lib/hooks/useUserIdentity.ts | 26 +- src/lib/media/genericPreview.ts | 62 +++ src/lib/media/linkPreview.ts | 44 ++ src/lib/media/redditPreview.ts | 62 +++ src/lib/notifications.ts | 19 + src/lib/storage/session.ts | 107 +++-- src/lib/swarm/interactions.ts | 78 +++- src/lib/swarm/node-blocklist.ts | 124 ++++++ src/lib/swarm/registry.ts | 30 +- src/lib/swarm/remote-profile-posts.ts | 62 +++ src/lib/swarm/reposts.ts | 40 ++ src/lib/swarm/signature.ts | 41 +- src/lib/swarm/timeline.ts | 33 +- src/lib/types.ts | 12 + 79 files changed, 4714 insertions(+), 1114 deletions(-) create mode 100644 .tmp/remote-docker-doc.mdx create mode 100644 docker/__pycache__/host-updater.cpython-314.pyc create mode 100644 src/app/api/admin/nodes/route.ts create mode 100644 src/app/api/auth/switch/route.ts create mode 100644 src/app/api/users/[handle]/replies/route.ts create mode 100644 src/lib/media/genericPreview.ts create mode 100644 src/lib/media/linkPreview.ts create mode 100644 src/lib/media/redditPreview.ts create mode 100644 src/lib/notifications.ts create mode 100644 src/lib/swarm/node-blocklist.ts create mode 100644 src/lib/swarm/remote-profile-posts.ts create mode 100644 src/lib/swarm/reposts.ts diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 41e2957..9edbfed 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,13 +1,12 @@ name: Build and Push Docker Image to GHCR on: - # Manual trigger only - you decide when to build workflow_dispatch: inputs: tag: - description: 'Image tag (default: latest)' + description: 'Optional explicit version tag (for example 2026.03.09.10). Leave blank to auto-increment.' required: false - default: 'latest' + default: '' # Also build on version tags push: tags: @@ -42,26 +41,22 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=sha,prefix=,suffix=,format=short - # Use input tag for manual runs, 'latest' for tag pushes - type=raw,value=${{ inputs.tag || 'latest' }} - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - file: ./docker/Dockerfile - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - no-cache: true - platforms: linux/amd64,linux/arm64 + env: + IMAGE_REPO: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + PLATFORMS: linux/amd64,linux/arm64 + PRUNE_BUILD_CACHE: '0' + APP_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || '' }} + EXTRA_TAGS: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }} + run: | + if [ -n "$EXTRA_TAGS" ]; then + EXTRA_TAGS="${EXTRA_TAGS#v}" + export EXTRA_TAGS + if [ -z "$APP_VERSION" ]; then + APP_VERSION="$EXTRA_TAGS" + export APP_VERSION + fi + fi + + chmod +x ./scripts/docker-publish.sh + ./scripts/docker-publish.sh diff --git a/.tmp/remote-docker-doc.mdx b/.tmp/remote-docker-doc.mdx new file mode 100644 index 0000000..6618fa4 --- /dev/null +++ b/.tmp/remote-docker-doc.mdx @@ -0,0 +1,247 @@ +# Quick Start with Docker + +Get your Synapsis node running in under 10 minutes with Docker. This is the **recommended and easiest** way to self-host. + +## Prerequisites + +- A Linux server with a domain pointed to it +- A domain name pointed to your server + +## Quick Start (5 Minutes) + +```bash +# 1. Bootstrap the deployment directory +curl -fsSL https://synapsis.social/install.sh | bash + +# 2. Review the generated config +nano /opt/synapsis/.env + +# 3. Start your node +cd /opt/synapsis +docker compose up -d +``` + +Done! Your node is live at `https://your-domain.com` with automatic SSL. + +## Existing nginx / reverse proxy host + +If your server already has nginx, Traefik, or another reverse proxy using `80/443`, use proxyless mode instead: + +```bash +curl -fsSL https://synapsis.social/install.sh | PROXY=none bash +nano /opt/synapsis/.env +cd /opt/synapsis +docker compose up -d +``` + +In `PROXY=none` mode: + +- Synapsis skips the bundled Caddy service +- the app binds to `127.0.0.1:${PORT}` +- your existing reverse proxy should forward traffic to that localhost port + +Example nginx upstream: + +```nginx +location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; +} +``` + +## Detailed Setup + +### 1. Bootstrap the Deployment Directory + +```bash +curl -fsSL https://synapsis.social/install.sh | bash +``` + +If Docker is missing, the installer will install it for you on supported Linux hosts. + +This downloads: + +- `docker-compose.yml` +- `Caddyfile` +- `caddy-entrypoint.sh` +- `.env.example` +- `.env` + +The installer also generates `AUTH_SECRET` and `DB_PASSWORD` if `openssl` is available. + +### 2. Configure Environment + +Edit `/opt/synapsis/.env` with your settings: + +```bash +nano /opt/synapsis/.env +``` + +**Required variables:** + +```env +# Your domain +DOMAIN=your-domain.com + +# Admin email(s) +ADMIN_EMAILS=you@example.com + +# Generate with: openssl rand -hex 32 +AUTH_SECRET=your-secret-key-here + +# Database password +DB_PASSWORD=your-secure-password +``` + +You can also set these before running the installer to prefill `.env`: + +```bash +DOMAIN=your-domain.com +ADMIN_EMAILS=you@example.com +``` + +Thatโ€™s it. Save and exit. + +### 3. Start Synapsis + +```bash +cd /opt/synapsis +docker compose up -d +``` + +This starts: +- **PostgreSQL** database +- **Synapsis** app (pre-built image from GitHub Container Registry) +- **Caddy** reverse proxy with automatic SSL + +Visit `https://your-domain.com` to create your admin account. + +## Storage Model + +### Node installation + +The node itself only needs Docker, PostgreSQL, and a domain to start. + +### User media storage + +At the moment, new user registration requires each account to provide **its own S3-compatible storage credentials** for media uploads. That requirement is enforced in the app today, so plan onboarding accordingly. + +## Configuration Reference + +### Core Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `DOMAIN` | Yes | Your domain name | +| `AUTH_SECRET` | Yes | Generate with `openssl rand -hex 32` | +| `ADMIN_EMAILS` | Yes | Comma-separated admin emails | +| `DB_PASSWORD` | Yes | Database password | + +### Optional Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `BOT_MAX_PER_USER` | 5 | Max AI bots per user | +| `PORT` | auto | Application port. Default installs use `auto` to scan 3000-3020. In `PROXY=none` mode this is the localhost port your reverse proxy should target. | +| `NEXT_PUBLIC_NODE_DOMAIN` | `DOMAIN` | Override node domain if needed | +| `NEXT_PUBLIC_APP_URL` | Derived from domain | Public URL used by background jobs | + +## Updating Synapsis + +Updating is a single command: + +```bash +cd /opt/synapsis +docker compose pull +docker compose up -d +``` + +This will: +- Pull the latest pre-built image from GitHub Container Registry +- Recreate containers if needed +- Run database migrations automatically +- Keep your data volumes intact + +### Check Update Status + +```bash +# See running containers +docker compose ps + +# View logs +docker compose logs -f app +``` + +## Troubleshooting + +### Container won't start + +```bash +# Check logs +docker compose logs app + +# Common issues: +# - Missing required env vars +# - Port 3000 already in use +``` + +### Database connection errors + +```bash +# Check database container +docker compose logs postgres + +# Verify env vars are loaded +docker compose exec app env | grep DATABASE +``` + +### Port already in use + +If `80` or `443` is already in use, your server already has another reverse proxy bound there. Use `PROXY=none` instead of the default Caddy install: + +```bash +curl -fsSL https://synapsis.social/install.sh | PROXY=none bash +``` + +For the application port itself, Synapsis uses `PORT=auto` by default, which automatically finds an available port between 3000-3020. If you need to use a specific port: + +```bash +# Edit .env and set a specific port +PORT=3001 + +# Restart +docker compose down +docker compose up -d +``` + +### Reset everything (data loss!) + +```bash +docker compose down -v # Remove containers AND volumes +docker compose up -d # Start fresh +``` + +### View all logs + +```bash +# All services +docker compose logs -f + +# Just the app +docker compose logs -f app + +# Just the database +docker compose logs -f postgres +``` + +## Next Steps + +- [Configure your node settings](/user-guide) +- [Learn about the Swarm](/swarm) +- [Learn how Synapsis differs from traditional federation](/user-guide/synapsis-difference) diff --git a/HOW_TO_WORK_ON_SYNAPIS.md b/HOW_TO_WORK_ON_SYNAPIS.md index d080b92..e228548 100644 --- a/HOW_TO_WORK_ON_SYNAPIS.md +++ b/HOW_TO_WORK_ON_SYNAPIS.md @@ -68,21 +68,21 @@ git push origin main Then build and push the multi-arch image: ```bash -docker buildx build \ - --builder colima \ - --platform linux/amd64,linux/arm64 \ - -f docker/Dockerfile \ - -t ghcr.io/gnosyslabs/synapsis:latest \ - -t ghcr.io/gnosyslabs/synapsis:$(git rev-parse --short HEAD) \ - --push \ - . +BUILDER=colima ./scripts/docker-publish.sh ``` That publishes: - `ghcr.io/gnosyslabs/synapsis:latest` +- `ghcr.io/gnosyslabs/synapsis:` - `ghcr.io/gnosyslabs/synapsis:` -If you are not on a Mac/Colima setup, swap `--builder colima` for whatever local buildx builder you use. +If you are not on a Mac/Colima setup, set `BUILDER` to your buildx builder or leave it empty to use the default builder. + +To force an explicit version instead of auto-incrementing: + +```bash +APP_VERSION=2026.03.09.10 BUILDER=colima ./scripts/docker-publish.sh +``` ## 4. Update The Server Once a new image is published, update the server with: diff --git a/README.md b/README.md index 210e1c4..4facf69 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ curl -fsSL https://synapsis.social/install.sh | PROXY=none bash **Updating (migrations run automatically):** ```bash -docker compose pull && docker compose up -d +curl -fsSL https://synapsis.social/update.sh | bash ``` **Full uninstall:** diff --git a/docker/README.md b/docker/README.md index ef19144..7d9d774 100644 --- a/docker/README.md +++ b/docker/README.md @@ -96,9 +96,20 @@ server { ## ๐Ÿ”„ Updates (migrations run automatically) +Use the updater for existing installs. It preserves `.env`, refreshes the installed compose/proxy files, pulls the latest published image, and restarts the stack. + +```bash +curl -fsSL https://synapsis.social/update.sh | bash +``` + +If the host was installed with `PROXY=none`, the updater detects that and keeps using the proxyless compose file automatically. + +Manual update still works if you only want to restart with the already-installed files: + ```bash cd /opt/synapsis -docker compose pull && docker compose up -d +docker compose pull +docker compose up -d ``` ## ๐Ÿ—‘๏ธ Full Uninstall @@ -222,6 +233,18 @@ cd synapsis/docker docker compose up -d --build ``` +To publish a stamped GHCR image with embedded app version, commit, and build date: + +```bash +cd /path/to/Synapsis +./scripts/docker-publish.sh +``` + +That script automatically publishes: +- `ghcr.io/gnosyslabs/synapsis:latest` +- `ghcr.io/gnosyslabs/synapsis:` +- `ghcr.io/gnosyslabs/synapsis:` + --- For full documentation, visit [docs.synapsis.social](https://docs.synapsis.social) diff --git a/docker/__pycache__/host-updater.cpython-314.pyc b/docker/__pycache__/host-updater.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..70eccdac2401f4c8fbee43c8ee8d43dea968bd93 GIT binary patch literal 17485 zcmdseX>c1?npgv9+$V35APMj=DUc#5N*qz5E>k>2TD;tZ=$SDk7$!gx5(&WFP0OO! zGqf`qQ?e6EW7UdYYs*rtCYEZP9j21(YLh+WvAyN0txACcY`|@9S*fTpHGi_Q#9d9a zKPKPz8r=Zf?|s^V4TUd^k?Rl{q@mF8)3)$&?$)$uyGs{8eQdS2gW;0-Qpujx1TnRpY) z)BWZ?3vY2z$5bO_($LCvq(aF{IY!Iq+SKA5Z-0Vzdd48Pb38#iBV&@=n4 znb&Tmb7BH^6$P3gA|z8lZ=1VrojL85Q3ExmuEIWS)X2+lr_vim5B1nEGRyksm5^ zl5$WwF6EVL8!hJf7SetzwD%mdv;lQs;0Ym&{sKiY=`o; zE})|t^7AzR1{)4})Pf=4=YxD`HYgYn&PT#Qk4iB0dA%c}yq}-r;I83{8oRi#CirFr02#nseCZnaEW3>vu8zBi{LnT=kn~A`||ZW^P(A z_Yc8K4Gj3Y`|J4)_N9hmLxCMg{fG?Z3ud!!#jYM2KjBzx$H_ z7AOUOM$)WGtR})LYCMfh6g8sDd?My0A{vpHa7Go0Y-*_Msv(b-#X$v4IC6@`E(OgT zKOq=HTx2rB&ieW5)O~C#6bE3tVfUva9510lbM&;JhlW0=%_8$HhaXo7V1ZgQ7cA{N zyYG_WeapL+xcRB1xnLG(&q z%8eVOoU}9y&`^7NXgT5gJ>t^xG(^~OtQjZLAhi9cbN$=T=>xi)g*Ne$y5Dyu(LhdcFX4f@Bo_~&y93?EbC+vT5 zCK&Jv_D~oY@XP+0!BBXP4y=qPBU*`HnYiI8e1SZ>7Se7eImzX8({+V@Z)d^ z7pU6?=dw3tC`~xq?`ky`-Catf)oq?X?m3zN+GEh_2vT-d8kZTCgr_(OOjE!Z%BgXg zX-TXqN{ySv8Wc4q(~}GGHPpCOdZP3zu4y~Y64G9kPc^5W)A(eKA*vdeF?Ylv*2;uc z)R;<|TU5| zai^3Y%iV)+9N8{O**!`3Eo!N6an0ORBiVRtGzC1coC>L3R0ZWCe*-V=)l^U+l}9hA z*oWFVezIxzbtK{NL?jRjPYtnKp`DlY0QA^d)Ua6>06|BHoMUUS1d&THbN-iuX>=3l zNHiE0Xd>k4nTS6i=tNyYpkItcW(3VtkQX$w{#OJIAUX^71TYUlCnBq$nxBziM;C!zHLc+w0%?%xAINn~AWKhwT+;LL$bjY)IeEpy#ko-3YLwWwP& z6rAf#7@R44(W3Q^(fanu<(=0`lE&80jjbzVYk5T%OMh0nyepnpy{LPjp-em0b(F0< zZmn48P0^+|2VWmd8Jw5Am+L-gxY7`7dirKCkN6wNir}kr~xvSAQkyeJG zn4(aG5yHC)84!~4vvFBNcnrj+u`5iu^oA_;`G8o`y%N|~%^TxN7oH-`d(J0p>!M2ur9hN^=;6@~)jt}wdX{iw$k zF`wRdsXwWI=vPhpwM!FL*cmVYRSm@=$MqOkBo%fSf_4PE5$r+GfdDCxtU6d2`U#-F zsUSx-i0sX_H0@bMB9^GH4;ppDk9!dS?2>iMTgPN_Iai;c%hsyvF2DT2{FV9Gj-H!* zynY~FJ-E;tGgq$Ct~Gbf<<6_zwOzmLxZV-lbvV%!;%GX{=MeN>{AW_H()f zU6L~AEevj!Psq;+nH2xa%O~LR<&;QdAQOOhGdc#m0`N1!&jdd+qi->QRFY*xhE%dJ zV3|v#l|w2~z#tU#vq6sYPX!-3n4mv!+RaDY;tn?p))_y?gF(si!9XWJB-rr2Hxv$W(^w!_ z@V@7j5Z@IE1O+3xW7!BRXriG2+XU~C!433^7Lf4R?*Z_rL|%ZbZUd_q zXUuw`d=P%z%K&n+`rDS0<-(Mu@Z4m|QWPukq%5f9Sjv|JDNEUMcS?MmcdqSjp3z~t zOBs!(^)adBt6_M>+5A;U)Ag4;-tahHgU_#!09 zyahHj7jI$Yt!7lj5wIUo?_281;+?UOx_ zRb+TB0|lL7G>vCM^Fg0j#}^4>j^*c~ENb${;m7?PfXx4H>#c9jzdnC%Yh3SKK9$hd zi5zYv9B=ITtRT@ikTTeotY@r1E8I-_~rMi~o<-m;93YJpd-)CnqEZq8fY%nM}XLX6_B7gM?0!Ox&nA+2%kY zO*lM~EA<2%9_%4e^Oi*QIp6P=weg$1$Z@E61Xa!IGeMgT^1o2UHm0RR{$Wb>?{}$fVYMFe@ZFjrvccrNE+Y z@&?u`10WOWT1oXguPyX0?p~z}Gn9)=%Alb>nehy1$YeNtc>GrCC zF_RQ1Mh;E*Y7X>3hk&BX?FNks-6-?=LqYIqux`QHImb^&*wDP64@JT(sv2M@X^Hn$ zQRGl*n+6FU1Ro2yU!uVYV2GJowO|c#K5#RTu7Uw}8v0p;A1CV9sk|+hik9^;yL+K; z&1}D*eQR&hd^l!4yxji&-goz2{_zcU+`0G0E33}KG5YXkCW0GJmg;{&W&uS==0ZXl zCWTTIBPJJZF<=j5Qp$0P2ps4i^fA>SEZ7)ClrqXRP(~|Sm^6+TC~`wRY7n%zF%@ne z(3-AYC(a8DpDfyAU=to;_{h}f(GRiDK}YNu0&wF~^Cj6bY7*2h5K!}_M@7H78;obz zAor;dKMevb5OjNVBIiOG$BrOC*+(QA5ojK*K%er5c-9BSf_-9+B_6LR8zG|c1+0wt zjfjvg6pT0usOo{>Moc*6=O?CpQqwTB`Zw_7`~bi)&c?EE%tqYL2+2oN|Q#1By|Slu>p9O)*Bv zf{CiZBlVP2OSww41~deg(09|!KzG{LWcm$?AVK9)qZGRKoE zXo5l>QMi2J;3*#mnjkPjK@$wW45EN2dwQM?O-%*aA@HT@Ks7_(5)Btl&;|U#*+|$^ zAkZXYrsZaW!6+dj(fX*xiI}*#7bT+6ju6k4Rg97OMoEl_Ois{^db@{@dWk6`=wbX2 z3mo|*^g!gMI2R*7%>p9^%~Q~MNtQ(l6SU+NJR0^C84;pUKFP@?wjfTfI?2gZD86yp z+?Hq+Irb&^asLj$0<~@c4e!lo-gsv5rJoG`n#k&+(o0(}cdgVXiaHieYh|^`vW8n_ z4Qtl2#o;xFGgVNrR#1^Fs9i0nO;uLkRcrEWYx#v28_qY})j@Qnldyd4s-%nV7Alm)| zuMhlm=s^u3deP1G^Ugh8W!jI+%v}u|Dc%b2LF5KHxH2g|Clw>skR*#%z+3Pk%jLQB zF9|~@fxDDb&dt1D1r|K&ek6fx0i!8Hw$LD3w3}s%C@~nMJ6*d$YztiIv3bZC1@r~p z1ev~2d(2BuBI6>>-j!?SU@(0*#i1Na)0}arm;C{BEa+za;W_`z{3b(+n*hUpj>xH6 zP&y(I5DZPYvqhOw3tUP^phIGovBQ|lA-4#+d)ZguvBxOV9TE%#f+90BBH5tWV*eF_ zpCCXSXa6+-pl+lU_DujA1ejf#gHI$6_HC%~XIKlHrM1?t&1G*Ly!7LwYv-zKXWaF@ zTdw`7f-OW8opA!;7>j`zQf;8qB-#$6C>!L~%BtS;q@1o)X+^5I45U}8(D{#fqLU!s znar!Yl~q=bGVgDwTH^If7Mw4@K5Z^SFi$wQlqG5yhO@NEu-|9MjCMnJksE6p>wXX>{p3u$Fx$5 z02nXGbQH0g>);YN`{dTS4l&7d(I|5X0NIpN&(&;`Sq>-xs@O-|9F-3)`TSiE5_uapatloZc0%fbW{M8X0&D)T@(UG1O1^igZXYoG_XTt zLs@;5V_#9-c#SwBc&vE52>VaJ!UTnQH8ca0}`eh z5}6TFAZP)=$iSgInSuted!+I^1AhqW{Q~&;9{i*w+vc8#9-q9JO#! z^1lT+p%A^FXkJBpX;sAM=S4FWNnEf_vXNQ&n1x_OT?7RDY*aAzyfP7t64MU37~zN> zJu!3zhZF(^r3V6I0gf%i&WIz{#|9^ZU;<182@@8*v7%oJtUEMCI6VLenqd+HXAUyK zv`R4(iA33pQ04Ei?mk#N(EUxeHHfR2&YI5cc-wYYU11zltrfZ7IkwoFDk#0!alRu~ z*}Bq}EZBXkVE31fEq7eimv?@sxz_v3XRbdJYwt@uGa27D72i4?J30IL*4el#9J7?K zXBuFy{P<^T?R1cb&7X zmDavjaZdMj$y2Es&j%e>I<7_HH3!ZOd|grlI!kHmTGRKEOn{ z{Ox)k<@T&os?yd})Am0ax~qZQ!v`f$@1pac*SAuRQh10Yg?V_&va1Icj^Vw7+UIVE({%$bY=Ow#P<)QmBXgC*?MT4fynv z@2N50UI_V5ZPuPj{ijYW`LsfX`AQAutIR#E#!t6l{ZHG>y=9tD588W+G=E#9h5Q5z zEvGcqQ1?Y=$1#cr?hgkq|AE&qDxPN4yq3{`hk+LIMeQ^gI2sPUG73ixf~=PuA{g@M z1cT3q2Q+*>!Q}JJMo=w)yv65x2~>yl6T8nh2?s9tnNT*AVZM$r&Ow_^e02a%Rrvwe#1jh-w#+TzvF#gQ%prb z-r;+~!8&+TfI5Z-sAseQ4U7&Tgl~A$7{!~>WbqgUefo5qVz>9f!KbvT-qPB z%raeA#LXqg!=xz#+tw(`g>s5xOe3i@@-@BGq#rq?sq`a>Lh@V|{mS!PN`Nv7hPYwN zwZ!h6w{%6|j7^wt@}7gPw~@39uteh3t9|cgKbI9 zNCX8d7Yql0A9K-2n1eG-SpLr((ELJlr*`i$D1Fzu(A>cj&scKiQjWv zCI^WKBrRKh=kM+_2f4u9^x1Rs80PgYfT=TZQcoQ@=^cVwP*xylO!NxDax@HzMB0)3 z&^Q#~-II~IZ~*K(7rZ+ApAq2B3+mw`#L>)7;+^H_5FB9Z8)o`{*wanC@`7<_*z4;Z zK04G5I@+H=CF0Ak6B(s05b+)E@d`Ex#l?FSpb~cu>Ml?zvwdmLnLW$Kgt;nZaV!m; z8M@T*{{DCOUu#<_ytXZ2Y5&rae>L*Vhy3uR7L%7y;FN8|=CVKxFOh>EB$De^KQc^-?ThS{$d$2yOuA6}=$nTOqCx zFf+nZY&yib{WIteJne?#^4Bg|;CleUognB_U&=|_H?y1M_JNQv+Ah1UASfWDbU)NPoxSfVy**s z^>kkTx>j$sedD041!>}2vlJ&Su2qZcwxjS|Am!My+>mmV#++kN-D>+MEd(7lQzP0# zz#t9xy8)6I6%=GA5JvysQe$pGm&@yjbSu%9637&tMris=!Z9jF*e(G7BBnfykb}-f zin3Q3T@_Lv;9u28)!-s2mTHa2kq)VZqQFI>9&sx7QVyaVfUKBu%+n}6i)t9NSF;Cd zihS1lC^bWlmxz5M1U_ro#ObP}=Uz?Dbu>#eV5|}!jM9%B9UNQPiLo*8#g>yc9Tv|P&sU&ZLbSHhr{CScH(B^_DFkPK!7Z~D`Xa2> zD;@yIe;h8@fsN30BWPKoY}3ex(+WJE3&7wIxK%*cw&Ps7Nxp}iU^5@;?DjD|&mQd= z^|F1~5i&zC8&E|NZ1BoxHuylT2eUvhu>Mm%)Y94ih*kFjAm$syawN}-0Xc1_x2sQZ zWU^W;X$GX^Istr(I`eDm` zv8(S~8+v#y9z1{W@_|Ibj?aRNhPyOvELbZpyLjSfCoXUKz;(s7QWCG)ldS5DRdwF* z#)}Uv9=>a$EUwE_33F2h%WqwEwyqqG?H`ZrJeIH=N1mToxl;1y&i~+y?H`G^KAXs6 z(0iU%r7pg9{e7*_r_i=&H1V0g+neqAHu(LIz5ZoTHpVZStjb*Q3 zLj?EX$29`@?VGsSMa>IC_h^;Yeov>-*XfbM9Tb}FN0E=y@|7`z-lL<@p9ov@Wytig!2Ip_z zK9SBn>1BxS*ASC*<-2n9OAGlNwUJ+%cSyLXCz=GS;j#-wRB!$1W_$3Wvo$+^hjCN z@mi@&ddtlrEBKB{*`+$EzH-gnCHUefU6=T^z(UGS%6zheH&61BhDQtE=dsT~=MY>y z4Hx!R0HHqvq#g1Ukx%DxksmVUFI3Ce|7`{p) zA&iW8F2)#0JXiGeUV&RdJ24GTW%QhZ+t~-M(jc&2{A5IA#DucbK?{1;9}2Vo9a`a$ zGHwMj;Mk&z6ZDoldgGg?UO)Bbk6!=Lg_B8r^(}q%n!$vhgnL#xk5WTyQQ26NXwi9+@WQ;Q7H$LglQOK7* zpXj!A@6&#=&)j1*qI(@*3nd4!COvILF%j4PD_kLq$jHR?V1EQf;*$OW=D>cT#1DAs zX`YXY&K5Ifo6`-DIkRsdKoyAaQZ|UWY6OVUETS;GfB;Y4WPJ%DXqf>#Me4&xClQ=O zfaE1&3Cb}sJhlUKL`m{uj$p|TG4}$37ZFS&z*8_{aGwOa5G@3o7=r-2crf%ze^_vk zSOgE6+UzJ1$qLR893yxjitrNb4kfmj6=nXKs{eb+`S(=wZJT51#F-OI;WOb_#g16pc-(eu!JLBa1^*`c zdSc+_Vd94E3))m>)%Mt)bk*FY@GV-$nGXCGEmqbV+c^|B4=)%}j{J+J^Ct8=#awN% zT}R`Nu?1^Jzstw24aaT!7R+BlbglKu#I@>QHe7G`W%Kps*q-mlet10I;EUJ25O?`w zRQYXN$x`HOByMxTeDjMJ?5UEn7*%*rr&8^>XP{Lb_iVIEch6>1<=?ATtG3-U=^&SH zQ&ruoE>xA@YbIq{Rq;KC1|C?{sy@{{lSO4(uPjj&theQ>3hy;k!I8`LK3K=H`{ z+iGds%1m/dev/null 2>&1; then @@ -24,56 +26,78 @@ require_command git CURRENT_SHA="$(git rev-parse --short HEAD)" CURRENT_FULL_SHA="$(git rev-parse HEAD)" BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - -existing_tags="$( - gh api "${PACKAGE_API}" --paginate --jq '.[].metadata.container.tags[]?' 2>/dev/null || true -)" - -max_build=0 -for tag in ${existing_tags}; do - case "${tag}" in - "${DATE_PREFIX}".*) - build_number="${tag##${DATE_PREFIX}.}" - case "${build_number}" in - ''|*[!0-9]*) - ;; - *) - if [ "${build_number}" -gt "${max_build}" ]; then - max_build="${build_number}" - fi - ;; - esac - ;; - esac -done - -next_build=$((max_build + 1)) -APP_VERSION="${DATE_PREFIX}.${next_build}" GITHUB_URL="${SOURCE_REPO}/commit/${CURRENT_FULL_SHA}" +if [ -z "${APP_VERSION}" ]; then + existing_tags="$( + gh api "${PACKAGE_API}" --paginate --jq '.[].metadata.container.tags[]?' 2>/dev/null || true + )" + + max_build=0 + for tag in ${existing_tags}; do + case "${tag}" in + "${DATE_PREFIX}".*) + build_number="${tag##${DATE_PREFIX}.}" + case "${build_number}" in + ''|*[!0-9]*) + ;; + *) + if [ "${build_number}" -gt "${max_build}" ]; then + max_build="${build_number}" + fi + ;; + esac + ;; + esac + done + + next_build=$((max_build + 1)) + APP_VERSION="${DATE_PREFIX}.${next_build}" +fi + +BUILD_ARGS=" + --platform ${PLATFORMS} + --build-arg APP_VERSION=${APP_VERSION} + --build-arg APP_COMMIT=${CURRENT_FULL_SHA} + --build-arg APP_BUILD_DATE=${BUILD_DATE} + --build-arg APP_GITHUB_URL=${GITHUB_URL} + --build-arg APP_IMAGE_REPO=${IMAGE_REPO} + --build-arg APP_SOURCE_REPO=${SOURCE_REPO} +" + +TAG_ARGS=" + -t ${IMAGE_REPO}:latest + -t ${IMAGE_REPO}:${APP_VERSION} + -t ${IMAGE_REPO}:${CURRENT_SHA} +" + +for extra_tag in ${EXTRA_TAGS}; do + TAG_ARGS="${TAG_ARGS} + -t ${IMAGE_REPO}:${extra_tag}" +done + echo "========================================" echo " Synapsis Docker Publish" echo "========================================" echo " Version: ${APP_VERSION}" echo " Commit: ${CURRENT_SHA}" +echo " Build Date: ${BUILD_DATE}" echo " Image: ${IMAGE_REPO}" +echo " Platforms: ${PLATFORMS}" echo "========================================" -docker buildx build \ - --builder "${BUILDER}" \ - --platform "${PLATFORMS}" \ - --build-arg "APP_VERSION=${APP_VERSION}" \ - --build-arg "APP_COMMIT=${CURRENT_FULL_SHA}" \ - --build-arg "APP_BUILD_DATE=${BUILD_DATE}" \ - --build-arg "APP_GITHUB_URL=${GITHUB_URL}" \ - --build-arg "APP_IMAGE_REPO=${IMAGE_REPO}" \ - --build-arg "APP_SOURCE_REPO=${SOURCE_REPO}" \ - -f docker/Dockerfile \ - -t "${IMAGE_REPO}:latest" \ - -t "${IMAGE_REPO}:${APP_VERSION}" \ - -t "${IMAGE_REPO}:${CURRENT_SHA}" \ - --push \ - . +set -- docker buildx build + +if [ -n "${BUILDER}" ]; then + set -- "$@" --builder "${BUILDER}" +fi + +# shellcheck disable=SC2086 +set -- "$@" $BUILD_ARGS -f docker/Dockerfile +# shellcheck disable=SC2086 +set -- "$@" $TAG_ARGS --push . + +"$@" echo "" echo "โœ… Published:" @@ -81,8 +105,16 @@ echo " ${IMAGE_REPO}:latest" echo " ${IMAGE_REPO}:${APP_VERSION}" echo " ${IMAGE_REPO}:${CURRENT_SHA}" +for extra_tag in ${EXTRA_TAGS}; do + echo " ${IMAGE_REPO}:${extra_tag}" +done + if [ "${PRUNE_BUILD_CACHE}" = "1" ]; then echo "" echo "๐Ÿงน Pruning BuildKit cache" - docker buildx prune --builder "${BUILDER}" -af >/dev/null + if [ -n "${BUILDER}" ]; then + docker buildx prune --builder "${BUILDER}" -af >/dev/null + else + docker buildx prune -af >/dev/null + fi fi diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 846d8d0..4d25ce8 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -387,23 +387,6 @@ export default function AdminPage() { ); } - const formatTimestamp = (value?: string | null) => { - if (!value) return 'Never'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString(); - }; - - const updateStatusLabel = (() => { - if (loadingUpdateStatus) return 'Checking...'; - if (!updateStatus) return 'Unavailable'; - if (!updateStatus.updater.available) return updateStatus.updater.message || 'Updater unavailable'; - if (updateStatus.updater.status === 'updating') return updateStatus.updater.message || 'Update in progress'; - if (updateStatus.updater.status === 'error') return updateStatus.updater.message || 'Last update failed'; - if (updateStatus.updateAvailable) return 'Update available'; - return 'Up to date'; - })(); - return ( <>
)} -
-
- Current build:{' '} - {updateStatus?.current?.version || 'Unknown'} -
-
- Latest build:{' '} - {updateStatus?.latest?.version || 'Unavailable'} -
-
- Status:{' '} - {updateStatusLabel} -
- {updateStatus?.updater.lastStartedAt && ( -
- Last started:{' '} - {formatTimestamp(updateStatus.updater.lastStartedAt)} -
- )} - {updateStatus?.updater.lastFinishedAt && ( -
- Last finished:{' '} - {formatTimestamp(updateStatus.updater.lastFinishedAt)} -
- )} - {typeof updateStatus?.updater.lastExitCode === 'number' && ( -
- Last exit code:{' '} - {updateStatus.updater.lastExitCode} -
- )} - {updateStatus?.updater.trigger && ( -
- Last trigger:{' '} - {updateStatus.updater.trigger === 'auto' ? 'Automatic update' : 'Manual update'} -
- )} - {updateStatus?.updater.lastError && ( -
- Last error:{' '} - {updateStatus.updater.lastError} -
- )} -
- {!updateStatus?.updater.available && (
({ + id: node.id, + domain: node.domain, + name: node.name, + description: node.description, + isActive: node.isActive, + isBlocked: node.isBlocked, + blockReason: node.blockReason, + blockedAt: node.blockedAt, + lastSeenAt: node.lastSeenAt, + trustScore: node.trustScore, + isNsfw: node.isNsfw, + })), + }); + } catch (error) { + console.error('Admin get nodes error:', error); + return NextResponse.json({ error: 'Failed to load nodes' }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest) { + try { + await requireAdmin(); + + const body = await request.json(); + const data = mutateNodeSchema.parse(body); + const domain = normalizeNodeDomain(data.domain); + const localDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'); + + if (domain === localDomain) { + return NextResponse.json({ error: 'Cannot block this node itself' }, { status: 400 }); + } + + const node = data.action === 'block' + ? await upsertBlockedNode(domain, data.reason || null) + : await unblockNode(domain); + + if (!node) { + return NextResponse.json({ error: 'Node not found' }, { status: 404 }); + } + + return NextResponse.json({ node }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 }); + } + console.error('Admin update nodes error:', error); + return NextResponse.json({ error: 'Failed to update node blocklist' }, { status: 500 }); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 777d598..1cfc6f2 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,11 +1,25 @@ import { NextResponse } from 'next/server'; -import { destroySession } from '@/lib/auth'; +import { destroySession, getSession } from '@/lib/auth'; import { clearStorageSession } from '@/lib/storage/session'; +import { z } from 'zod'; -export async function POST() { +const logoutSchema = z.object({ + userId: z.string().uuid().optional(), +}); + +export async function POST(request: Request) { try { - await clearStorageSession(); - await destroySession(); + const currentSession = await getSession(); + const body = await request.json().catch(() => ({})); + const data = logoutSchema.parse(body); + + const targetUserId = data.userId ?? currentSession?.user.id; + + if (targetUserId) { + await clearStorageSession(targetUserId); + } + + await destroySession(targetUserId); return NextResponse.json({ success: true }); } catch (error) { diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index a56e9c6..53eff47 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { getSession } from '@/lib/auth'; +import { getSession, getSessionAccounts } from '@/lib/auth'; import { db, users } from '@/db'; import { eq } from 'drizzle-orm'; import { z } from 'zod'; @@ -22,9 +22,10 @@ export async function GET() { } const session = await getSession(); + const accounts = await getSessionAccounts(); if (!session) { - return NextResponse.json({ user: null }); + return NextResponse.json({ user: null, accounts: [] }); } return NextResponse.json({ @@ -40,10 +41,11 @@ export async function GET() { publicKey: session.user.publicKey, privateKeyEncrypted: session.user.privateKeyEncrypted, }, + accounts, }); } catch (error) { console.error('Session check error:', error); - return NextResponse.json({ user: null }); + return NextResponse.json({ user: null, accounts: [] }); } } diff --git a/src/app/api/auth/switch/route.ts b/src/app/api/auth/switch/route.ts new file mode 100644 index 0000000..8602dd3 --- /dev/null +++ b/src/app/api/auth/switch/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server'; +import { switchSession } from '@/lib/auth'; +import { z } from 'zod'; + +const switchSchema = z.object({ + userId: z.string().uuid(), +}); + +export async function POST(request: Request) { + try { + const body = await request.json(); + const data = switchSchema.parse(body); + const session = await switchSession(data.userId); + + return NextResponse.json({ + success: true, + user: { + id: session.user.id, + handle: session.user.handle, + displayName: session.user.displayName, + avatarUrl: session.user.avatarUrl, + bio: session.user.bio, + website: session.user.website, + dmPrivacy: session.user.dmPrivacy, + did: session.user.did, + publicKey: session.user.publicKey, + privateKeyEncrypted: session.user.privateKeyEncrypted, + }, + }); + } catch (error) { + console.error('Switch session error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to switch account' }, + { status: 400 } + ); + } +} diff --git a/src/app/api/bots/[id]/api-key/route.ts b/src/app/api/bots/[id]/api-key/route.ts index 5e7b375..c88b931 100644 --- a/src/app/api/bots/[id]/api-key/route.ts +++ b/src/app/api/bots/[id]/api-key/route.ts @@ -24,7 +24,7 @@ type RouteContext = { params: Promise<{ id: string }> }; // Schema for setting API key const setApiKeySchema = z.object({ apiKey: z.string().min(1, 'API key is required'), - provider: z.enum(['openrouter', 'openai', 'anthropic']).optional(), + provider: z.enum(['openrouter', 'openai', 'anthropic', 'custom']).optional(), }); /** diff --git a/src/app/api/bots/[id]/route.ts b/src/app/api/bots/[id]/route.ts index e2eefbc..6367d7a 100644 --- a/src/app/api/bots/[id]/route.ts +++ b/src/app/api/bots/[id]/route.ts @@ -34,8 +34,9 @@ const updateBotSchema = z.object({ maxTokens: z.number().int().min(1).max(100000), responseStyle: z.string().optional(), }).optional(), - llmProvider: z.enum(['openrouter', 'openai', 'anthropic']).optional(), + llmProvider: z.enum(['openrouter', 'openai', 'anthropic', 'custom']).optional(), llmModel: z.string().min(1).optional(), + llmEndpoint: z.string().url().optional().nullable(), llmApiKey: z.string().min(1).optional(), schedule: z.object({ type: z.enum(['interval', 'times', 'cron']), @@ -91,6 +92,7 @@ export async function GET(request: Request, context: RouteContext) { personalityConfig: bot.personalityConfig, llmProvider: bot.llmProvider, llmModel: bot.llmModel, + llmEndpoint: bot.llmEndpoint, scheduleConfig: bot.scheduleConfig, autonomousMode: bot.autonomousMode, isActive: bot.isActive, @@ -162,6 +164,7 @@ async function handleUpdate(request: Request, context: RouteContext) { if (data.personality !== undefined) updateInput.personality = data.personality; if (data.llmProvider !== undefined) updateInput.llmProvider = data.llmProvider; if (data.llmModel !== undefined) updateInput.llmModel = data.llmModel; + if (data.llmEndpoint !== undefined) updateInput.llmEndpoint = data.llmEndpoint; if (data.llmApiKey !== undefined) updateInput.llmApiKey = data.llmApiKey; if (data.schedule !== undefined) updateInput.schedule = data.schedule; if (data.autonomousMode !== undefined) updateInput.autonomousMode = data.autonomousMode; @@ -183,6 +186,7 @@ async function handleUpdate(request: Request, context: RouteContext) { personalityConfig: updatedBot.personalityConfig, llmProvider: updatedBot.llmProvider, llmModel: updatedBot.llmModel, + llmEndpoint: updatedBot.llmEndpoint, scheduleConfig: updatedBot.scheduleConfig, autonomousMode: updatedBot.autonomousMode, isActive: updatedBot.isActive, diff --git a/src/app/api/bots/route.ts b/src/app/api/bots/route.ts index 1b4844a..1fdbf5c 100644 --- a/src/app/api/bots/route.ts +++ b/src/app/api/bots/route.ts @@ -33,8 +33,9 @@ const createBotSchema = z.object({ maxTokens: z.number().int().min(1).max(100000), responseStyle: z.string().optional(), }), - llmProvider: z.enum(['openrouter', 'openai', 'anthropic']), + llmProvider: z.enum(['openrouter', 'openai', 'anthropic', 'custom']), llmModel: z.string().min(1), + llmEndpoint: z.string().url().optional(), llmApiKey: z.string().min(1), schedule: z.object({ type: z.enum(['interval', 'times', 'cron']), @@ -96,6 +97,7 @@ export async function POST(request: Request) { personality: data.personality, llmProvider: data.llmProvider, llmModel: data.llmModel, + llmEndpoint: data.llmEndpoint, llmApiKey: data.llmApiKey, schedule: data.schedule, autonomousMode: data.autonomousMode, @@ -114,6 +116,7 @@ export async function POST(request: Request) { personalityConfig: bot.personalityConfig, llmProvider: bot.llmProvider, llmModel: bot.llmModel, + llmEndpoint: bot.llmEndpoint, scheduleConfig: bot.scheduleConfig, autonomousMode: bot.autonomousMode, isActive: bot.isActive, @@ -189,6 +192,7 @@ export async function GET() { personalityConfig: bot.personalityConfig, llmProvider: bot.llmProvider, llmModel: bot.llmModel, + llmEndpoint: bot.llmEndpoint, scheduleConfig: bot.scheduleConfig, autonomousMode: bot.autonomousMode, isActive: bot.isActive, diff --git a/src/app/api/chat/receive/route.ts b/src/app/api/chat/receive/route.ts index 14f3b91..3843bba 100644 --- a/src/app/api/chat/receive/route.ts +++ b/src/app/api/chat/receive/route.ts @@ -6,6 +6,7 @@ import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-sign import { verifySwarmRequest } from '@/lib/swarm/signature'; import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache'; import { z } from 'zod'; +import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist'; const signedChatActionSchema = z.object({ action: z.string().min(1), @@ -76,6 +77,11 @@ export async function POST(request: NextRequest) { let fullSenderHandle: string | null = null; if (swarmSignature && sourceDomain && body.userAction) { + const normalizedSourceDomain = normalizeNodeDomain(sourceDomain); + if (await isNodeBlocked(normalizedSourceDomain)) { + return NextResponse.json({ error: 'Blocked node' }, { status: 403 }); + } + // Federated envelope format - validate and verify node signature const envelopeValidation = federatedEnvelopeSchema.safeParse(body); if (!envelopeValidation.success) { @@ -117,6 +123,12 @@ export async function POST(request: NextRequest) { // Use full handle if provided in envelope, otherwise fall back to signed handle const senderHandle = fullSenderHandle || handle; + const senderDomainFromHandle = senderHandle.includes('@') + ? normalizeNodeDomain(senderHandle.split('@').pop() || '') + : null; + if (senderDomainFromHandle && await isNodeBlocked(senderDomainFromHandle)) { + return NextResponse.json({ error: 'Blocked node' }, { status: 403 }); + } console.log(`[Chat Receive] From: ${senderHandle} (DID: ${did}), To: ${recipientDid}`); // 1. Resolve Sender Public Key @@ -134,21 +146,25 @@ export async function POST(request: NextRequest) { // Derive domain from full sender handle if possible if (senderHandle.includes('@')) { const parts = senderHandle.split('@'); - senderNodeDomain = parts[parts.length - 1]; + senderNodeDomain = normalizeNodeDomain(parts[parts.length - 1]); } else { // Try to get from header first const sourceDomainHeader = request.headers.get('X-Swarm-Source-Domain'); if (sourceDomainHeader) { - senderNodeDomain = sourceDomainHeader; + senderNodeDomain = normalizeNodeDomain(sourceDomainHeader); } else { // Try handle registry (though we likely don't have it if we don't have the user) const registryEntry = await db.query.handleRegistry.findFirst({ where: eq(handleRegistry.did, did) }); - if (registryEntry) senderNodeDomain = registryEntry.nodeDomain; + if (registryEntry) senderNodeDomain = normalizeNodeDomain(registryEntry.nodeDomain); } } + if (senderNodeDomain && await isNodeBlocked(senderNodeDomain)) { + return NextResponse.json({ error: 'Blocked node' }, { status: 403 }); + } + if (senderNodeDomain) { try { const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https'; diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 1b2bbb4..0fc2c0e 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -6,6 +6,7 @@ import { eq, and } from 'drizzle-orm'; import { z } from 'zod'; import { createSignedPayload } from '@/lib/swarm/signature'; import { federatedHandleSchema } from '@/lib/utils/federation'; +import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist'; const chatSendSchema = z.object({ recipientDid: z.string().min(1).regex(/^did:/, 'Must be a valid DID (did:key:... or did:synapsis:...)'), @@ -167,6 +168,11 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Recipient node not found' }, { status: 404 }); } + targetDomain = normalizeNodeDomain(targetDomain); + if (await isNodeBlocked(targetDomain)) { + return NextResponse.json({ error: 'This node is blocked by the server administrator' }, { status: 403 }); + } + console.log(`[Remote Send] Sending to ${targetHandle} at ${targetDomain}`); // 2. Send to Remote Node (Forward the Signed Action) diff --git a/src/app/api/media/preview/route.ts b/src/app/api/media/preview/route.ts index ad57f7f..38cff04 100644 --- a/src/app/api/media/preview/route.ts +++ b/src/app/api/media/preview/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; +import type { LinkPreviewData } from '@/lib/media/linkPreview'; +import { fetchRedditRichPreview } from '@/lib/media/redditPreview'; +import { fetchGenericLinkPreview } from '@/lib/media/genericPreview'; -/** - * Check if a URL is from Reddit. - */ function isRedditUrl(url: string): boolean { try { const parsed = new URL(url); @@ -12,58 +12,16 @@ function isRedditUrl(url: string): boolean { } } -/** - * Fetch preview for Reddit URLs using their oEmbed API. - */ -async function fetchRedditPreview(url: string): Promise<{ - url: string; - title: string | null; - description: string | null; - image: string | null; -} | null> { - try { - const oembedUrl = `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}`; - - const response = await fetch(oembedUrl, { - headers: { 'Accept': 'application/json' }, - signal: AbortSignal.timeout(5000), - }); - - if (!response.ok) { - return null; - } - - const data = await response.json(); - - // Extract title - try title field first, then parse from HTML - let title = data.title || null; - if (!title && data.html) { - const titleMatch = data.html.match(/href="[^"]+">([^<]+)<\/a>/); - if (titleMatch && titleMatch[1] && titleMatch[1] !== 'Comment') { - title = titleMatch[1]; - } - } - - // Build description from subreddit info - let description = null; - if (data.author_name) { - description = `Posted by ${data.author_name}`; - } else if (data.html) { - const subredditMatch = data.html.match(/r\/([a-zA-Z0-9_]+)/); - if (subredditMatch) { - description = `r/${subredditMatch[1]}`; - } - } - - return { - url, - title, - description, - image: data.thumbnail_url || null, - }; - } catch { - return null; - } +function buildBasicPreview(url: string, title?: string | null, description?: string | null, image?: string | null): LinkPreviewData { + return { + url, + title: title || url, + description: description || null, + image: image || null, + type: image ? 'image' : 'card', + videoUrl: null, + media: image ? [{ url: image }] : null, + }; } export async function GET(req: NextRequest) { @@ -75,68 +33,30 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'No URL provided' }, { status: 400 }); } - // Normalize URL if (!url.startsWith('http://') && !url.startsWith('https://')) { url = 'https://' + url; } - // Use Reddit-specific handler if (isRedditUrl(url)) { - const preview = await fetchRedditPreview(url); + const preview = await fetchRedditRichPreview(url); if (preview) { return NextResponse.json(preview); } - // Fall back to URL-only response if oEmbed fails - return NextResponse.json({ - url, - title: 'Reddit', - description: null, - image: null, - }); + + return NextResponse.json(buildBasicPreview(url, 'Reddit')); } - // Generic OG tag scraping for other sites - let response; - try { - response = await fetch(url, { - headers: { - 'User-Agent': 'Mozilla/5.0 (compatible; SynapsisBot/1.0; +https://synapsis.social)', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.5', - }, - signal: AbortSignal.timeout(5000), - }); - } catch (fetchError) { - console.warn(`Fetch failed for URL: ${url}`, fetchError); + const preview = await fetchGenericLinkPreview(url); + if (!preview) { return NextResponse.json({ error: 'Could not reach the URL' }, { status: 404 }); } - if (!response.ok) { - return NextResponse.json({ error: `URL returned status ${response.status}` }, { status: 404 }); - } - - const html = await response.text(); - - const getMeta = (property: string) => { - const regex = new RegExp(`]+(?:property|name)=["'](?:og:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i'); - const match = html.match(regex); - if (match) return match[1]; - - const regexRev = new RegExp(`]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:)?${property}["']`, 'i'); - const matchRev = html.match(regexRev); - return matchRev ? matchRev[1] : null; - }; - - const title = getMeta('title') || html.match(/([^<]+)<\/title>/i)?.[1]; - const description = getMeta('description'); - const image = getMeta('image'); - - return NextResponse.json({ - url, - title: title?.trim() || url, - description: description?.trim() || null, - image: image?.trim() || null, - }); + return NextResponse.json(buildBasicPreview( + preview.url, + preview.title?.trim() || url, + preview.description?.trim() || null, + preview.image?.trim() || null, + )); } catch (error) { console.error('Link preview error:', error); return NextResponse.json({ error: 'Failed to fetch preview' }, { status: 500 }); diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index 97533db..b31e26a 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -99,6 +99,15 @@ export async function GET(request: Request) { avatarUrl: freshProfile?.avatarUrl || row.actorAvatarUrl, nodeDomain: row.actorNodeDomain, }, + target: row.targetHandle ? { + handle: row.targetNodeDomain + ? `${row.targetHandle}@${row.targetNodeDomain}` + : row.targetHandle, + displayName: row.targetDisplayName, + avatarUrl: row.targetAvatarUrl, + nodeDomain: row.targetNodeDomain, + isBot: row.targetIsBot, + } : null, post: row.postId ? { id: row.postId, content: row.postContent, diff --git a/src/app/api/posts/[id]/like/route.ts b/src/app/api/posts/[id]/like/route.ts index 55e56d2..f49210f 100644 --- a/src/app/api/posts/[id]/like/route.ts +++ b/src/app/api/posts/[id]/like/route.ts @@ -1,9 +1,12 @@ import { NextResponse } from 'next/server'; import { db, posts, likes, users, notifications, userSwarmLikes } from '@/db'; +import { requireAuth } from '@/lib/auth'; import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature'; import { eq, and, sql } from 'drizzle-orm'; import { z } from 'zod'; import crypto from 'crypto'; +import { buildNotificationTarget } from '@/lib/notifications'; +import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview'; type RouteContext = { params: Promise<{ id: string }> }; @@ -13,6 +16,25 @@ const postIdSchema = z.union([ z.string().regex(/^swarm:[^:]+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, 'Invalid swarm post ID format'), ]); +function isSignedActionPayload(payload: unknown): payload is SignedAction { + if (!payload || typeof payload !== 'object') return false; + const value = payload as Record<string, unknown>; + return typeof value.action === 'string' + && typeof value.did === 'string' + && typeof value.handle === 'string' + && typeof value.ts === 'number' + && typeof value.nonce === 'string' + && typeof value.sig === 'string' + && typeof value.data === 'object' + && value.data !== null; +} + +async function readOptionalJson(request: Request) { + const rawBody = await request.text(); + if (!rawBody.trim()) return null; + return JSON.parse(rawBody); +} + /** * Extract domain from a swarm post ID (swarm:domain:postId) */ @@ -71,6 +93,9 @@ async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) { linkPreviewTitle: post.linkPreviewTitle || null, linkPreviewDescription: post.linkPreviewDescription || null, linkPreviewImage: post.linkPreviewImage || null, + linkPreviewType: post.linkPreviewType || null, + linkPreviewVideoUrl: post.linkPreviewVideoUrl || null, + linkPreviewMediaJson: serializeLinkPreviewMedia(post.linkPreviewMedia), mediaJson: post.media ? JSON.stringify(post.media) : null, }; } catch { @@ -81,15 +106,19 @@ async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) { // Like a post export async function POST(request: Request, context: RouteContext) { try { - // Parse the signed action from the request body - const signedAction: SignedAction = await request.json(); + const body = await readOptionalJson(request); + const { id: paramId } = await context.params; + const decodedParamId = decodeURIComponent(paramId); - // Verify the signature and get the user - const user = await requireSignedAction(signedAction); + const user = isSignedActionPayload(body) + ? await requireSignedAction(body) + : await requireAuth(); - // Extract and validate postId from the signed action data - const { postId: rawId } = signedAction.data; - const decodedId = decodeURIComponent(rawId); + if (isSignedActionPayload(body) && body.data?.postId && body.data.postId !== decodedParamId) { + return NextResponse.json({ error: 'Post ID mismatch' }, { status: 400 }); + } + + const decodedId = decodedParamId; const postId = postIdSchema.parse(decodedId); const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; @@ -183,6 +212,10 @@ export async function POST(request: Request, context: RouteContext) { .where(eq(posts.id, postId)); if (post.userId !== user.id) { + const postAuthor = await db.query.users.findFirst({ + where: eq(users.id, post.userId), + }); + // Create notification with actor info stored directly await db.insert(notifications).values({ userId: post.userId, @@ -193,13 +226,11 @@ export async function POST(request: Request, context: RouteContext) { actorNodeDomain: null, // Local user postId, postContent: post.content?.slice(0, 200) || null, + ...(postAuthor?.isBot ? buildNotificationTarget(postAuthor) : {}), type: 'like', }); // Also notify bot owner if this is a bot's post - const postAuthor = await db.query.users.findFirst({ - where: eq(users.id, post.userId), - }); if (postAuthor?.isBot && postAuthor.botOwnerId) { await db.insert(notifications).values({ userId: postAuthor.botOwnerId, @@ -210,6 +241,7 @@ export async function POST(request: Request, context: RouteContext) { actorNodeDomain: null, postId, postContent: post.content?.slice(0, 200) || null, + ...buildNotificationTarget(postAuthor), type: 'like', }); } @@ -277,15 +309,19 @@ export async function POST(request: Request, context: RouteContext) { // Unlike a post export async function DELETE(request: Request, context: RouteContext) { try { - // Parse the signed action from the request body - const signedAction: SignedAction = await request.json(); + const body = await readOptionalJson(request); + const { id: paramId } = await context.params; + const decodedParamId = decodeURIComponent(paramId); - // Verify the signature and get the user - const user = await requireSignedAction(signedAction); + const user = isSignedActionPayload(body) + ? await requireSignedAction(body) + : await requireAuth(); - // Extract and validate postId from the signed action data - const { postId: rawId } = signedAction.data; - const decodedId = decodeURIComponent(rawId); + if (isSignedActionPayload(body) && body.data?.postId && body.data.postId !== decodedParamId) { + return NextResponse.json({ error: 'Post ID mismatch' }, { status: 400 }); + } + + const decodedId = decodedParamId; const postId = postIdSchema.parse(decodedId); const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; diff --git a/src/app/api/posts/[id]/repost/route.ts b/src/app/api/posts/[id]/repost/route.ts index 8b9e922..fd358ce 100644 --- a/src/app/api/posts/[id]/repost/route.ts +++ b/src/app/api/posts/[id]/repost/route.ts @@ -1,9 +1,11 @@ import { NextResponse } from 'next/server'; -import { db, posts, users, notifications } from '@/db'; +import { db, posts, users, notifications, userSwarmReposts } from '@/db'; import { requireAuth } from '@/lib/auth'; import { eq, and, sql } from 'drizzle-orm'; import { z } from 'zod'; import crypto from 'crypto'; +import { buildNotificationTarget } from '@/lib/notifications'; +import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview'; type RouteContext = { params: Promise<{ id: string }> }; @@ -40,6 +42,47 @@ function extractSwarmPostId(apId: string): string | null { return apId.substring(lastColonIndex + 1); } +async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) { + try { + const protocol = domain.includes('localhost') ? 'http' : 'https'; + const res = await fetch(`${protocol}://${domain}/api/swarm/posts/${originalPostId}`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + + if (!res.ok) { + return null; + } + + const data = await res.json(); + const post = data.post; + if (!post) { + return null; + } + + return { + authorHandle: post.author?.handle || 'unknown', + authorDisplayName: post.author?.displayName || post.author?.handle || 'Unknown', + authorAvatarUrl: post.author?.avatarUrl || null, + content: post.content || '', + postCreatedAt: new Date(post.createdAt || new Date().toISOString()), + likesCount: post.likesCount || 0, + repostsCount: post.repostsCount || 0, + repliesCount: post.repliesCount || 0, + linkPreviewUrl: post.linkPreviewUrl || null, + linkPreviewTitle: post.linkPreviewTitle || null, + linkPreviewDescription: post.linkPreviewDescription || null, + linkPreviewImage: post.linkPreviewImage || null, + linkPreviewType: post.linkPreviewType || null, + linkPreviewVideoUrl: post.linkPreviewVideoUrl || null, + linkPreviewMediaJson: serializeLinkPreviewMedia(post.linkPreviewMedia), + mediaJson: post.media ? JSON.stringify(post.media) : null, + }; + } catch { + return null; + } +} + // Repost a post export async function POST(request: Request, context: RouteContext) { try { @@ -62,6 +105,18 @@ export async function POST(request: Request, context: RouteContext) { return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 }); } + const existingRepost = await db.query.userSwarmReposts.findFirst({ + where: and( + eq(userSwarmReposts.userId, user.id), + eq(userSwarmReposts.nodeDomain, targetDomain), + eq(userSwarmReposts.originalPostId, originalPostId), + ), + }); + + if (existingRepost) { + return NextResponse.json({ error: 'Already reposted' }, { status: 400 }); + } + // Deliver repost directly to the origin node const { deliverSwarmRepost } = await import('@/lib/swarm/interactions'); @@ -83,6 +138,27 @@ export async function POST(request: Request, context: RouteContext) { return NextResponse.json({ error: 'Failed to deliver repost to remote node' }, { status: 502 }); } + const snapshot = await fetchSwarmPostSnapshot(targetDomain, originalPostId); + if (snapshot) { + await db.insert(userSwarmReposts).values({ + userId: user.id, + nodeDomain: targetDomain, + originalPostId, + ...snapshot, + repostedAt: new Date(), + }).onConflictDoUpdate({ + target: [userSwarmReposts.userId, userSwarmReposts.nodeDomain, userSwarmReposts.originalPostId], + set: { + ...snapshot, + repostedAt: new Date(), + }, + }); + } + + await db.update(users) + .set({ postsCount: sql`${users.postsCount} + 1` }) + .where(eq(users.id, user.id)); + console.log(`[Swarm] Repost delivered to ${targetDomain} for post ${originalPostId}`); return NextResponse.json({ success: true, reposted: true }); } @@ -133,6 +209,10 @@ export async function POST(request: Request, context: RouteContext) { .where(eq(users.id, user.id)); if (originalPost.userId !== user.id) { + const postAuthor = await db.query.users.findFirst({ + where: eq(users.id, originalPost.userId), + }); + // Create notification with actor info stored directly await db.insert(notifications).values({ userId: originalPost.userId, @@ -143,13 +223,11 @@ export async function POST(request: Request, context: RouteContext) { actorNodeDomain: null, // Local user postId, postContent: originalPost.content?.slice(0, 200) || null, + ...(postAuthor?.isBot ? buildNotificationTarget(postAuthor) : {}), type: 'repost', }); // Also notify bot owner if this is a bot's post - const postAuthor = await db.query.users.findFirst({ - where: eq(users.id, originalPost.userId), - }); if (postAuthor?.isBot && postAuthor.botOwnerId) { await db.insert(notifications).values({ userId: postAuthor.botOwnerId, @@ -160,6 +238,7 @@ export async function POST(request: Request, context: RouteContext) { actorNodeDomain: null, postId, postContent: originalPost.content?.slice(0, 200) || null, + ...buildNotificationTarget(postAuthor), type: 'repost', }); } @@ -236,6 +315,18 @@ export async function DELETE(request: Request, context: RouteContext) { return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 }); } + const existingRepost = await db.query.userSwarmReposts.findFirst({ + where: and( + eq(userSwarmReposts.userId, user.id), + eq(userSwarmReposts.nodeDomain, targetDomain), + eq(userSwarmReposts.originalPostId, originalPostId), + ), + }); + + if (!existingRepost) { + return NextResponse.json({ error: 'Not reposted' }, { status: 400 }); + } + // Deliver unrepost directly to the origin node const { deliverSwarmUnrepost } = await import('@/lib/swarm/interactions'); @@ -254,6 +345,16 @@ export async function DELETE(request: Request, context: RouteContext) { return NextResponse.json({ error: 'Failed to deliver unrepost to remote node' }, { status: 502 }); } + await db.delete(userSwarmReposts).where(and( + eq(userSwarmReposts.userId, user.id), + eq(userSwarmReposts.nodeDomain, targetDomain), + eq(userSwarmReposts.originalPostId, originalPostId), + )); + + await db.update(users) + .set({ postsCount: sql`GREATEST(0, ${users.postsCount} - 1)` }) + .where(eq(users.id, user.id)); + console.log(`[Swarm] Unrepost delivered to ${targetDomain} for post ${originalPostId}`); return NextResponse.json({ success: true, reposted: false }); } diff --git a/src/app/api/posts/[id]/route.ts b/src/app/api/posts/[id]/route.ts index a9dfc00..4da2e65 100644 --- a/src/app/api/posts/[id]/route.ts +++ b/src/app/api/posts/[id]/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { db, posts, users, media, remotePosts } from '@/db'; import { eq, desc, and, sql, inArray } from 'drizzle-orm'; import { z } from 'zod'; +import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview'; // Schema for local post ID (UUID) const localPostIdSchema = z.string().uuid('Invalid post ID format'); @@ -15,6 +16,69 @@ const swarmPostIdSchema = z.string().regex( // Combined schema that accepts either format const postIdSchema = z.union([localPostIdSchema, swarmPostIdSchema]); +const embeddedPostRelations = { + author: true, + bot: true, + media: true, + replyTo: { + with: { + author: true, + bot: true, + media: true, + }, + }, +} as const; + +const postDetailRelations = { + ...embeddedPostRelations, + repostOf: { + with: embeddedPostRelations, + }, +} as const; + +function mapSwarmDetailPost(post: any, fallbackDomain: string): any { + const effectiveDomain = post.nodeDomain || fallbackDomain; + const rawId = post.originalPostId || post.id; + + return { + id: post.id?.startsWith('swarm:') ? post.id : `swarm:${effectiveDomain}:${rawId}`, + originalPostId: rawId, + content: post.content, + createdAt: post.createdAt, + likesCount: post.likesCount || 0, + repostsCount: post.repostsCount || 0, + repliesCount: post.repliesCount || 0, + isSwarm: true, + nodeDomain: effectiveDomain, + repostOfId: post.repostOf + ? (post.repostOf.id?.startsWith('swarm:') + ? post.repostOf.id + : `swarm:${post.repostOf.nodeDomain || effectiveDomain}:${post.repostOf.originalPostId || post.repostOf.id}`) + : (post.repostOfId ? `swarm:${effectiveDomain}:${post.repostOfId}` : null), + repostOf: post.repostOf ? mapSwarmDetailPost(post.repostOf, post.repostOf.nodeDomain || effectiveDomain) : null, + author: post.author ? { + id: `swarm:${effectiveDomain}:${post.author.handle}`, + handle: post.author.handle.includes('@') ? post.author.handle : `${post.author.handle}@${effectiveDomain}`, + displayName: post.author.displayName, + avatarUrl: post.author.avatarUrl, + isSwarm: true, + nodeDomain: effectiveDomain, + } : null, + media: post.media?.map((m: any, idx: number) => ({ + id: m.id || `swarm:${effectiveDomain}:${rawId}:media:${idx}`, + url: m.url, + altText: m.altText || null, + })) || [], + linkPreviewUrl: post.linkPreviewUrl, + linkPreviewTitle: post.linkPreviewTitle, + linkPreviewDescription: post.linkPreviewDescription, + linkPreviewImage: post.linkPreviewImage, + linkPreviewType: post.linkPreviewType || null, + linkPreviewVideoUrl: post.linkPreviewVideoUrl || null, + linkPreviewMedia: post.linkPreviewMedia || null, + }; +} + export async function GET( request: Request, { params }: { params: Promise<{ id: string }> } @@ -47,67 +111,25 @@ export async function GET( if (res.ok) { const data = await res.json(); - // Transform to match expected format - mainPost = { - id: id, - originalPostId: originalPostId, - content: data.post.content, - createdAt: data.post.createdAt, - likesCount: data.post.likesCount || 0, - repostsCount: data.post.repostsCount || 0, - repliesCount: data.post.repliesCount || 0, - isSwarm: true, + mainPost = mapSwarmDetailPost({ + ...data.post, + id, + originalPostId, nodeDomain: originDomain, - author: { - id: `swarm:${originDomain}:${data.post.author.handle}`, - handle: data.post.author.handle, - displayName: data.post.author.displayName, - avatarUrl: data.post.author.avatarUrl, - isSwarm: true, - nodeDomain: originDomain, - }, - media: data.post.media?.map((m: any, idx: number) => ({ - id: `swarm:${originDomain}:${originalPostId}:media:${idx}`, - url: m.url, - altText: m.altText || null, - })) || [], - linkPreviewUrl: data.post.linkPreviewUrl, - linkPreviewTitle: data.post.linkPreviewTitle, - linkPreviewDescription: data.post.linkPreviewDescription, - linkPreviewImage: data.post.linkPreviewImage, - }; + }, originDomain); // Transform replies from the origin node - replyPosts = (data.replies || []).map((r: any) => ({ - id: `swarm:${originDomain}:${r.id}`, - originalPostId: r.id, - content: r.content, - createdAt: r.createdAt, - likesCount: r.likesCount || 0, - repostsCount: r.repostsCount || 0, - repliesCount: r.repliesCount || 0, - isSwarm: true, + replyPosts = (data.replies || []).map((reply: any) => mapSwarmDetailPost({ + ...reply, nodeDomain: originDomain, - author: { - id: `swarm:${originDomain}:${r.author.handle}`, - handle: r.author.handle, - displayName: r.author.displayName, - avatarUrl: r.author.avatarUrl, - isSwarm: true, - nodeDomain: originDomain, - }, - media: r.media?.map((m: any, idx: number) => ({ - id: `swarm:${originDomain}:${r.id}:media:${idx}`, - url: m.url, - altText: m.altText || null, - })) || [], - })); + }, originDomain)); mainPost.repliesCount = replyPosts.length; // Check if current user has liked this post try { const { requireAuth } = await import('@/lib/auth'); + const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts'); const viewer = await requireAuth(); const likeCheckRes = await fetch( @@ -119,6 +141,15 @@ export async function GET( const likeData = await likeCheckRes.json(); mainPost.isLiked = likeData.isLiked; } + + const repostedIds = await getViewerSwarmRepostedPostIds([ + { + id, + nodeDomain: originDomain, + originalPostId, + }, + ], viewer.id); + mainPost.isReposted = repostedIds.has(id); } catch { // Not logged in or timeout } @@ -138,13 +169,7 @@ export async function GET( const post = await db.query.posts.findFirst({ where: eq(posts.id, id), - with: { - author: true, - media: true, - replyTo: { - with: { author: true }, - }, - }, + with: postDetailRelations, }); if (post) { @@ -155,10 +180,7 @@ export async function GET( eq(posts.replyToId, id), eq(posts.isRemoved, false) ), - with: { - author: true, - media: true, - }, + with: postDetailRelations, orderBy: [desc(posts.createdAt)], }); @@ -235,6 +257,9 @@ export async function GET( linkPreviewTitle: cached.linkPreviewTitle, linkPreviewDescription: cached.linkPreviewDescription, linkPreviewImage: cached.linkPreviewImage, + linkPreviewType: cached.linkPreviewType, + linkPreviewVideoUrl: cached.linkPreviewVideoUrl, + linkPreviewMedia: parseLinkPreviewMediaJson(cached.linkPreviewMediaJson) || null, isLiked: false, isReposted: false, }; diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 9c1aaf3..a9a6a4e 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -1,10 +1,12 @@ import { NextResponse } from 'next/server'; -import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts } from '@/db'; +import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts, userSwarmReposts, notifications } from '@/db'; import { requireAuth } from '@/lib/auth'; import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature'; import { eq, desc, and, inArray, isNull, isNotNull, or, lt, sql } from 'drizzle-orm'; import type { SQL } from 'drizzle-orm'; import { z } from 'zod'; +import { buildNotificationTarget } from '@/lib/notifications'; +import { serializeLinkPreviewMedia, parseLinkPreviewMediaJson } from '@/lib/media/linkPreview'; const POST_MAX_LENGTH = 600; const CURATION_WINDOW_HOURS = 72; @@ -17,6 +19,138 @@ const buildWhere = (...conditions: Array<SQL | undefined>) => { return and(...filtered); }; +type FeedPostWithChildren = { + id: string; + repostOf?: FeedPostWithChildren | null; + replyTo?: FeedPostWithChildren | null; + isLiked?: boolean; + isReposted?: boolean; + nodeDomain?: string | null; + originalPostId?: string | null; +}; + +function mapUserSwarmRepostToFeedPost( + row: typeof userSwarmReposts.$inferSelect, + author: Pick<typeof users.$inferSelect, 'id' | 'handle' | 'displayName' | 'avatarUrl' | 'isBot'> +): FeedPostWithChildren { + const remoteAuthorHandle = row.authorHandle.includes('@') + ? row.authorHandle + : `${row.authorHandle}@${row.nodeDomain}`; + const remoteOriginalId = `swarm:${row.nodeDomain}:${row.originalPostId}`; + + return { + id: `swarm-repost:${row.id}`, + content: '', + createdAt: row.repostedAt.toISOString(), + likesCount: 0, + repostsCount: 0, + repliesCount: 0, + author: { + id: author.id, + handle: author.handle, + displayName: author.displayName, + avatarUrl: author.avatarUrl, + isBot: author.isBot, + }, + repostOfId: remoteOriginalId, + repostOf: { + id: remoteOriginalId, + originalPostId: row.originalPostId, + content: row.content, + createdAt: row.postCreatedAt.toISOString(), + likesCount: row.likesCount, + repostsCount: row.repostsCount, + repliesCount: row.repliesCount, + isSwarm: true, + nodeDomain: row.nodeDomain, + author: { + id: `swarm:${row.nodeDomain}:${row.authorHandle}`, + handle: remoteAuthorHandle, + displayName: row.authorDisplayName || row.authorHandle, + avatarUrl: row.authorAvatarUrl, + isRemote: true, + nodeDomain: row.nodeDomain, + }, + media: row.mediaJson ? JSON.parse(row.mediaJson) : [], + linkPreviewUrl: row.linkPreviewUrl, + linkPreviewTitle: row.linkPreviewTitle, + linkPreviewDescription: row.linkPreviewDescription, + linkPreviewImage: row.linkPreviewImage, + linkPreviewType: row.linkPreviewType, + linkPreviewVideoUrl: row.linkPreviewVideoUrl, + linkPreviewMedia: parseLinkPreviewMediaJson(row.linkPreviewMediaJson) || null, + }, + } as any; +} + +async function getMixedFeedCursorDate(cursor: string | null) { + if (!cursor) { + return null; + } + + if (cursor.startsWith('swarm-repost:')) { + const repostRow = await db.query.userSwarmReposts.findFirst({ + where: eq(userSwarmReposts.id, cursor.replace('swarm-repost:', '')), + }); + return repostRow?.repostedAt ?? null; + } + + const cursorPost = await db.query.posts.findFirst({ + where: eq(posts.id, cursor), + }); + return cursorPost?.createdAt ?? null; +} + +function collectNestedPosts(posts: FeedPostWithChildren[]): FeedPostWithChildren[] { + const collected: FeedPostWithChildren[] = []; + const seen = new Set<string>(); + + const visit = (post: FeedPostWithChildren | null | undefined) => { + if (!post || seen.has(post.id)) return; + seen.add(post.id); + collected.push(post); + visit(post.repostOf); + visit(post.replyTo); + }; + + posts.forEach(visit); + return collected; +} + +function applyInteractionFlags( + posts: FeedPostWithChildren[], + likedIds: Set<string>, + repostedIds: Set<string> +): FeedPostWithChildren[] { + return posts.map((post) => ({ + ...post, + isLiked: likedIds.has(post.id), + isReposted: repostedIds.has(post.id), + repostOf: post.repostOf ? applyInteractionFlags([post.repostOf], likedIds, repostedIds)[0] : post.repostOf, + replyTo: post.replyTo ? applyInteractionFlags([post.replyTo], likedIds, repostedIds)[0] : post.replyTo, + })); +} + +const embeddedPostRelations = { + author: true, + bot: true, + media: true, + replyTo: { + with: { + author: true, + bot: true, + media: true, + }, + }, +} as const; + +const feedPostRelations = { + ...embeddedPostRelations, + repostOf: { + with: embeddedPostRelations, + }, +} as const; + const createPostSchema = z.object({ content: z.string().min(1).max(POST_MAX_LENGTH), replyToId: z.string().uuid().optional(), // Must be UUID (swarm replies use separate field) @@ -38,21 +172,40 @@ const createPostSchema = z.object({ title: z.string().optional(), description: z.string().optional(), image: z.string().url().optional().nullable(), + type: z.enum(['card', 'image', 'gallery', 'video']).optional().nullable(), + videoUrl: z.string().url().optional().nullable(), + media: z.array(z.object({ + url: z.string().url(), + width: z.number().optional().nullable(), + height: z.number().optional().nullable(), + mimeType: z.string().optional().nullable(), + })).optional().nullable(), }).optional().nullable(), }); +function isSignedActionPayload(payload: unknown): payload is SignedAction { + if (!payload || typeof payload !== 'object') return false; + const value = payload as Record<string, unknown>; + return typeof value.action === 'string' + && typeof value.did === 'string' + && typeof value.handle === 'string' + && typeof value.ts === 'number' + && typeof value.nonce === 'string' + && typeof value.sig === 'string' + && typeof value.data === 'object' + && value.data !== null; +} + // Create a new post export async function POST(request: Request) { try { - // Parse the signed action from the request body - const signedAction: SignedAction = await request.json(); - - // Strictly verify the signature and get the user - // This replaces requireAuth() - the signature proves identity AND intent - const user = await requireSignedAction(signedAction); - - // Extract post data from the signed action - const data = createPostSchema.parse(signedAction.data); + const requestBody = await request.json(); + const user = isSignedActionPayload(requestBody) + ? await requireSignedAction(requestBody) + : await requireAuth(); + const data = createPostSchema.parse( + isSignedActionPayload(requestBody) ? requestBody.data : requestBody + ); if (user.isSuspended || user.isSilenced) { return NextResponse.json({ error: 'Account restricted' }, { status: 403 }); @@ -84,6 +237,9 @@ export async function POST(request: Request) { linkPreviewTitle: data.linkPreview?.title, linkPreviewDescription: data.linkPreview?.description, linkPreviewImage: data.linkPreview?.image, + linkPreviewType: data.linkPreview?.type, + linkPreviewVideoUrl: data.linkPreview?.videoUrl, + linkPreviewMediaJson: serializeLinkPreviewMedia(data.linkPreview?.media), }).returning(); let unattachedMedia: typeof media.$inferSelect[] = []; @@ -181,12 +337,56 @@ export async function POST(request: Request) { }); } + if (data.replyToId) { + try { + const parentPost = await db.query.posts.findFirst({ + where: eq(posts.id, data.replyToId), + with: { + author: true, + }, + }); + + if (parentPost && parentPost.userId !== user.id) { + const parentAuthor = parentPost.author as typeof users.$inferSelect | undefined; + + await db.insert(notifications).values({ + userId: parentPost.userId, + actorId: user.id, + actorHandle: user.handle, + actorDisplayName: user.displayName, + actorAvatarUrl: user.avatarUrl, + actorNodeDomain: null, + postId: parentPost.id, + postContent: post.content?.slice(0, 200) || null, + ...(parentAuthor?.isBot ? buildNotificationTarget(parentAuthor) : {}), + type: 'reply', + }); + + if (parentAuthor?.isBot && parentAuthor.botOwnerId) { + await db.insert(notifications).values({ + userId: parentAuthor.botOwnerId, + actorId: user.id, + actorHandle: user.handle, + actorDisplayName: user.displayName, + actorAvatarUrl: user.avatarUrl, + actorNodeDomain: null, + postId: parentPost.id, + postContent: post.content?.slice(0, 200) || null, + ...buildNotificationTarget(parentAuthor), + type: 'reply', + }); + } + } + } catch (err) { + console.error('[Posts] Error creating reply notifications:', err); + console.error('[Posts] Context:', { postId: post.id, replyToId: data.replyToId, userId: user.id }); + } + } + // Handle local mentions (create notifications for users on this node) (async () => { try { const { extractMentions } = await import('@/lib/swarm/interactions'); - const { notifications } = await import('@/db'); - const mentions = extractMentions(data.content); for (const mention of mentions) { @@ -209,6 +409,7 @@ export async function POST(request: Request) { actorNodeDomain: null, // Local user postId: post.id, postContent: post.content?.slice(0, 200) || null, + ...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}), type: 'mention', }); @@ -223,6 +424,7 @@ export async function POST(request: Request) { actorNodeDomain: null, postId: post.id, postContent: post.content?.slice(0, 200) || null, + ...buildNotificationTarget(mentionedUser), type: 'mention', }); } @@ -374,6 +576,9 @@ const transformRemotePosts = (remotePostsData: typeof remotePosts.$inferSelect[] linkPreviewTitle: rp.linkPreviewTitle, linkPreviewDescription: rp.linkPreviewDescription, linkPreviewImage: rp.linkPreviewImage, + linkPreviewType: rp.linkPreviewType, + linkPreviewVideoUrl: rp.linkPreviewVideoUrl, + linkPreviewMedia: parseLinkPreviewMediaJson(rp.linkPreviewMediaJson) || null, author: { id: rp.authorActorUrl, handle: rp.authorHandle, @@ -437,14 +642,7 @@ export async function GET(request: Request) { feedPosts = await db.query.posts.findMany({ where: whereCondition, - with: { - author: true, - bot: true, - media: true, - replyTo: { - with: { author: true, media: true }, - }, - }, + with: feedPostRelations, orderBy: [desc(posts.createdAt)], limit, }); @@ -452,14 +650,7 @@ export async function GET(request: Request) { // Public timeline - all local posts + all cached remote posts const localPosts = await db.query.posts.findMany({ where: baseFilter, - with: { - author: true, - bot: true, - media: true, - replyTo: { - with: { author: true, media: true }, - }, - }, + with: feedPostRelations, orderBy: [desc(posts.createdAt)], limit: limit * 2, }); @@ -492,14 +683,7 @@ export async function GET(request: Request) { feedPosts = await db.query.posts.findMany({ where: whereCondition, - with: { - author: true, - bot: true, - media: true, - replyTo: { - with: { author: true, media: true }, - }, - }, + with: feedPostRelations, orderBy: [desc(posts.createdAt)], limit, }); @@ -519,14 +703,7 @@ export async function GET(request: Request) { feedPosts = await db.query.posts.findMany({ where: whereCondition, - with: { - author: true, - bot: true, - media: true, - replyTo: { - with: { author: true, media: true }, - }, - }, + with: feedPostRelations, orderBy: [desc(posts.createdAt)], limit, }); @@ -554,7 +731,7 @@ export async function GET(request: Request) { includeNsfw, }); - const swarmPosts = swarmResult.posts.map(sp => ({ + const mapSwarmPostToFeedPost = (sp: (typeof swarmResult.posts)[number]): any => ({ id: `swarm:${sp.nodeDomain}:${sp.id}`, originalPostId: sp.id, // Keep the original ID for replies content: sp.content, @@ -564,6 +741,8 @@ export async function GET(request: Request) { repliesCount: sp.replyCount, isSwarm: true, nodeDomain: sp.nodeDomain, + repostOfId: sp.repostOfId ? `swarm:${sp.nodeDomain}:${sp.repostOfId}` : null, + repostOf: sp.repostOf ? mapSwarmPostToFeedPost(sp.repostOf) : null, author: { id: `swarm:${sp.nodeDomain}:${sp.author.handle}`, handle: sp.author.handle, @@ -583,8 +762,13 @@ export async function GET(request: Request) { linkPreviewTitle: sp.linkPreviewTitle || null, linkPreviewDescription: sp.linkPreviewDescription || null, linkPreviewImage: sp.linkPreviewImage || null, + linkPreviewType: sp.linkPreviewType || null, + linkPreviewVideoUrl: sp.linkPreviewVideoUrl || null, + linkPreviewMedia: sp.linkPreviewMedia || null, replyTo: null, - })); + }); + + const swarmPosts = swarmResult.posts.map(mapSwarmPostToFeedPost); let mutedIds = new Set<string>(); let blockedIds = new Set<string>(); @@ -674,31 +858,48 @@ export async function GET(request: Request) { // Build where condition with cursor support let whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds)); + const cursorDate = await getMixedFeedCursorDate(cursor); - if (cursor) { - const cursorPost = await db.query.posts.findFirst({ - where: eq(posts.id, cursor), - }); - if (cursorPost) { - whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds), lt(posts.createdAt, cursorPost.createdAt)); - } + if (cursorDate) { + whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds), lt(posts.createdAt, cursorDate)); } // Get local posts from people the user follows + their own posts const localPosts = await db.query.posts.findMany({ where: whereCondition, - with: { - author: true, - bot: true, - media: true, - replyTo: { - with: { author: true, media: true }, - }, - }, + with: feedPostRelations, orderBy: [desc(posts.createdAt)], limit: cursor ? limit : limit * 2, // Get more on first load to account for mixing with remote }); + const swarmRepostWhere = cursorDate + ? and( + inArray(userSwarmReposts.userId, allowedUserIds), + lt(userSwarmReposts.repostedAt, cursorDate) + ) + : inArray(userSwarmReposts.userId, allowedUserIds); + const swarmRepostRows = await db.query.userSwarmReposts.findMany({ + where: swarmRepostWhere, + orderBy: [desc(userSwarmReposts.repostedAt)], + limit: cursor ? limit : limit * 2, + }); + + const swarmRepostAuthors = swarmRepostRows.length > 0 + ? await db.query.users.findMany({ + where: inArray(users.id, Array.from(new Set(swarmRepostRows.map((row) => row.userId)))), + }) + : []; + const swarmRepostAuthorMap = new Map(swarmRepostAuthors.map((author) => [author.id, author])); + const localRepostEvents = swarmRepostRows + .map((row) => { + const author = swarmRepostAuthorMap.get(row.userId); + if (!author) { + return null; + } + return mapUserSwarmRepostToFeedPost(row, author); + }) + .filter(Boolean); + // Get handles of remote users we follow const followedRemoteUsers = await db.query.remoteFollows.findMany({ where: eq(remoteFollows.followerId, user.id), @@ -708,6 +909,7 @@ export async function GET(request: Request) { let liveRemotePosts: any[] = []; if (followedRemoteUsers.length > 0) { const { fetchSwarmUserProfile, isSwarmNode } = await import('@/lib/swarm/interactions'); + const { mapRemoteProfilePost } = await import('@/lib/swarm/remote-profile-posts'); // Wrap each fetch with a timeout to prevent slow nodes from blocking const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => { @@ -737,34 +939,15 @@ export async function GET(request: Request) { return profileData.posts .filter((post: any) => !post.replyToId && !post.swarmReplyToId && !post.isReply) - .map(post => ({ - id: `swarm:${domain}:${post.id}`, - content: post.content, - createdAt: new Date(post.createdAt), - likesCount: post.likesCount || 0, - repostsCount: post.repostsCount || 0, - repliesCount: post.repliesCount || 0, - isRemote: true, - isNsfw: post.isNsfw, - linkPreviewUrl: post.linkPreviewUrl, - linkPreviewTitle: post.linkPreviewTitle, - linkPreviewDescription: post.linkPreviewDescription, - linkPreviewImage: post.linkPreviewImage, - author: { - id: `swarm:${domain}:${handle}`, - handle: follow.targetHandle, - displayName: follow.displayName || profileData.profile?.displayName || handle, - avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl, - isRemote: true, - isBot: profileData.profile?.isBot, - }, - media: post.media?.map((m: any, idx: number) => ({ - id: `swarm:${domain}:${post.id}:media:${idx}`, - url: m.url, - altText: m.altText || null, - })) || [], - replyTo: null, - })); + .map((post: any) => mapRemoteProfilePost({ + ...post, + author: post.author || { + handle, + displayName: follow.displayName || profileData.profile?.displayName || handle, + avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl, + isBot: profileData.profile?.isBot, + }, + }, domain)); } catch (error) { console.error(`[Home] Error fetching posts from ${follow.targetHandle}:`, error); return []; @@ -776,7 +959,7 @@ export async function GET(request: Request) { } // Merge and sort by date - const allPosts = [...localPosts, ...liveRemotePosts] + const allPosts = [...localPosts, ...localRepostEvents, ...liveRemotePosts] .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, limit); @@ -785,14 +968,7 @@ export async function GET(request: Request) { // Not authenticated, return public timeline feedPosts = await db.query.posts.findMany({ where: baseFilter, - with: { - author: true, - bot: true, - media: true, - replyTo: { - with: { author: true, media: true }, - }, - }, + with: feedPostRelations, orderBy: [desc(posts.createdAt)], limit, }); @@ -807,12 +983,13 @@ export async function GET(request: Request) { if (session?.user && feedPosts && feedPosts.length > 0) { const viewer = session.user; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; + const allFeedPosts = collectNestedPosts(feedPosts as FeedPostWithChildren[]); // Separate local and swarm posts const localPostIds: string[] = []; const swarmPosts: Array<{ id: string; domain: string; originalId: string }> = []; - for (const p of feedPosts as Array<{ id: string }>) { + for (const p of allFeedPosts) { if (p.id.startsWith('swarm:')) { const parts = p.id.split(':'); if (parts.length >= 3) { @@ -852,6 +1029,8 @@ export async function GET(request: Request) { // Check swarm likes in real-time (query origin nodes) if (swarmPosts.length > 0) { + const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts'); + const checkPromises = swarmPosts.map(async (sp) => { try { const protocol = sp.domain.includes('localhost') ? 'http' : 'https'; @@ -874,13 +1053,23 @@ export async function GET(request: Request) { }); await Promise.all(checkPromises); + + const swarmRepostedIds = await getViewerSwarmRepostedPostIds( + swarmPosts.map((sp) => ({ + id: sp.id, + nodeDomain: sp.domain, + originalPostId: sp.originalId, + })), + viewer.id + ); + swarmRepostedIds.forEach((id) => repostedPostIds.add(id)); } - feedPosts = feedPosts.map((p: { id: string }) => ({ - ...p, - isLiked: likedPostIds.has(p.id), - isReposted: repostedPostIds.has(p.id), - })) as any; + feedPosts = applyInteractionFlags( + feedPosts as FeedPostWithChildren[], + likedPostIds, + repostedPostIds + ) as any; } } catch (error) { console.error('Error populating interaction flags:', error); diff --git a/src/app/api/posts/swarm/route.ts b/src/app/api/posts/swarm/route.ts index cf8b3aa..1bb9fff 100644 --- a/src/app/api/posts/swarm/route.ts +++ b/src/app/api/posts/swarm/route.ts @@ -8,6 +8,51 @@ import { NextRequest, NextResponse } from 'next/server'; import { fetchSwarmTimeline } from '@/lib/swarm/timeline'; import { getSession } from '@/lib/auth'; import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes'; +import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts'; + +type SwarmFeedPost = { + id: string; + nodeDomain: string; + repostOf?: SwarmFeedPost | null; + replyTo?: SwarmFeedPost | null; + isLiked?: boolean; + isReposted?: boolean; +}; + +function collectNestedSwarmPosts(posts: SwarmFeedPost[]): SwarmFeedPost[] { + const collected: SwarmFeedPost[] = []; + const seen = new Set<string>(); + + const visit = (post: SwarmFeedPost | null | undefined) => { + if (!post) return; + const key = `${post.nodeDomain}:${post.id}`; + if (seen.has(key)) return; + seen.add(key); + collected.push(post); + visit(post.repostOf); + visit(post.replyTo); + }; + + posts.forEach(visit); + return collected; +} + +function applyInteractionFlags( + posts: SwarmFeedPost[], + likedIds: Set<string>, + repostedIds: Set<string> +): SwarmFeedPost[] { + return posts.map((post) => { + const normalizedId = `swarm:${post.nodeDomain}:${post.id}`; + return { + ...post, + isLiked: likedIds.has(normalizedId), + isReposted: repostedIds.has(normalizedId), + repostOf: post.repostOf ? applyInteractionFlags([post.repostOf], likedIds, repostedIds)[0] : post.repostOf, + replyTo: post.replyTo ? applyInteractionFlags([post.replyTo], likedIds, repostedIds)[0] : post.replyTo, + }; + }); +} /** * GET /api/posts/swarm @@ -35,9 +80,10 @@ export async function GET(request: NextRequest) { const session = await getSession().catch(() => null); const viewer = session?.user; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; + const allTimelinePosts = collectNestedSwarmPosts(timeline.posts as SwarmFeedPost[]); const likedPostIds = viewer ? await getViewerSwarmLikedPostIds( - timeline.posts.map(post => ({ + allTimelinePosts.map(post => ({ id: `swarm:${post.nodeDomain}:${post.id}`, nodeDomain: post.nodeDomain, originalPostId: post.id, @@ -46,12 +92,19 @@ export async function GET(request: NextRequest) { nodeDomain ) : new Set<string>(); + const repostedPostIds = viewer + ? await getViewerSwarmRepostedPostIds( + allTimelinePosts.map(post => ({ + id: `swarm:${post.nodeDomain}:${post.id}`, + nodeDomain: post.nodeDomain, + originalPostId: post.id, + })), + viewer.id + ) + : new Set<string>(); return NextResponse.json({ - posts: timeline.posts.map(post => ({ - ...post, - isLiked: likedPostIds.has(`swarm:${post.nodeDomain}:${post.id}`), - })), + posts: applyInteractionFlags(timeline.posts as SwarmFeedPost[], likedPostIds, repostedPostIds), sources: timeline.sources, cached: false, fetchedAt: timeline.fetchedAt, diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index bde85f8..34ebbcb 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -4,6 +4,26 @@ import { ilike, or, desc, and, notInArray, eq, inArray } from 'drizzle-orm'; import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions'; import { discoverNode } from '@/lib/swarm/discovery'; +const embeddedPostRelations = { + author: true, + bot: true, + media: true, + replyTo: { + with: { + author: true, + bot: true, + media: true, + }, + }, +} as const; + +const searchPostRelations = { + ...embeddedPostRelations, + repostOf: { + with: embeddedPostRelations, + }, +} as const; + type SearchUser = { id: string; handle: string; @@ -176,11 +196,7 @@ export async function GET(request: Request) { } const postResults = await db.query.posts.findMany({ where: and(...postConditions), - with: { - author: true, - media: true, - bot: true, - }, + with: searchPostRelations, orderBy: [desc(posts.createdAt)], limit, }); diff --git a/src/app/api/swarm/chat/conversations/[id]/route.ts b/src/app/api/swarm/chat/conversations/[id]/route.ts index 133e71a..d8ba110 100644 --- a/src/app/api/swarm/chat/conversations/[id]/route.ts +++ b/src/app/api/swarm/chat/conversations/[id]/route.ts @@ -8,6 +8,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db, chatConversations, chatMessages, users } from '@/db'; import { eq, and } from 'drizzle-orm'; import { getSession } from '@/lib/auth'; +import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist'; import { z } from 'zod'; // Schema for conversation ID parameter @@ -73,10 +74,14 @@ export async function DELETE( if (isRemote) { // Extract domain from handle (format: handle@domain) - const domain = participant2Handle.split('@')[1]; + const domain = normalizeNodeDomain(participant2Handle.split('@')[1]); const handle = participant2Handle.split('@')[0]; try { + if (await isNodeBlocked(domain)) { + return NextResponse.json({ success: true }); + } + const protocol = domain.includes('localhost') ? 'http' : 'https'; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; diff --git a/src/app/api/swarm/chat/delete/route.ts b/src/app/api/swarm/chat/delete/route.ts index 25bd2a5..be3c2ce 100644 --- a/src/app/api/swarm/chat/delete/route.ts +++ b/src/app/api/swarm/chat/delete/route.ts @@ -12,6 +12,7 @@ import { db, users, chatConversations } from '@/db'; import { eq, and } from 'drizzle-orm'; import { z } from 'zod'; import { verifyUserInteraction } from '@/lib/swarm/signature'; +import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist'; const deletionSchema = z.object({ senderHandle: z.string(), @@ -30,6 +31,11 @@ export async function POST(request: NextRequest) { const body = await request.json(); const data = deletionSchema.parse(body); + const senderNodeDomain = normalizeNodeDomain(data.senderNodeDomain); + + if (await isNodeBlocked(senderNodeDomain)) { + return NextResponse.json({ error: 'Blocked node' }, { status: 403 }); + } // SECURITY: Verify the signature const { signature, ...payload } = data; @@ -37,7 +43,7 @@ export async function POST(request: NextRequest) { payload, signature, data.senderHandle, - data.senderNodeDomain + senderNodeDomain ); if (!isValid) { @@ -55,7 +61,7 @@ export async function POST(request: NextRequest) { } // Find the conversation with the sender - const senderFullHandle = `${data.senderHandle}@${data.senderNodeDomain}`; + const senderFullHandle = `${data.senderHandle}@${senderNodeDomain}`; const conversation = await db.query.chatConversations.findFirst({ where: and( eq(chatConversations.participant1Id, recipient.id), diff --git a/src/app/api/swarm/inbox/route.ts b/src/app/api/swarm/inbox/route.ts index 0c7bee0..832d056 100644 --- a/src/app/api/swarm/inbox/route.ts +++ b/src/app/api/swarm/inbox/route.ts @@ -29,6 +29,14 @@ const swarmPostSchema = z.object({ linkPreviewTitle: z.string().optional(), linkPreviewDescription: z.string().optional(), linkPreviewImage: z.string().optional(), + linkPreviewType: z.enum(['card', 'image', 'gallery', 'video']).optional(), + linkPreviewVideoUrl: z.string().optional(), + linkPreviewMedia: z.array(z.object({ + url: z.string(), + width: z.number().nullable().optional(), + height: z.number().nullable().optional(), + mimeType: z.string().nullable().optional(), + })).optional(), }), author: z.object({ handle: z.string(), diff --git a/src/app/api/swarm/interactions/follow/route.ts b/src/app/api/swarm/interactions/follow/route.ts index c440694..903c761 100644 --- a/src/app/api/swarm/interactions/follow/route.ts +++ b/src/app/api/swarm/interactions/follow/route.ts @@ -15,6 +15,7 @@ import { eq, and, sql } from 'drizzle-orm'; import { z } from 'zod'; import { verifySwarmRequest } from '@/lib/swarm/signature'; import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation'; +import { buildNotificationTarget } from '@/lib/notifications'; const swarmFollowSchema = z.object({ targetHandle: localHandleSchema, @@ -107,6 +108,7 @@ export async function POST(request: NextRequest) { actorDisplayName: data.follow.followerDisplayName, actorAvatarUrl: data.follow.followerAvatarUrl || null, actorNodeDomain: data.follow.followerNodeDomain, + ...(targetUser.isBot ? buildNotificationTarget(targetUser) : {}), type: 'follow', }); console.log(`[Swarm] Created follow notification for @${data.targetHandle} from ${data.follow.followerHandle}@${data.follow.followerNodeDomain}`); @@ -125,6 +127,7 @@ export async function POST(request: NextRequest) { actorDisplayName: data.follow.followerDisplayName, actorAvatarUrl: data.follow.followerAvatarUrl || null, actorNodeDomain: data.follow.followerNodeDomain, + ...buildNotificationTarget(targetUser), type: 'follow', }); } catch (err) { diff --git a/src/app/api/swarm/interactions/like/route.ts b/src/app/api/swarm/interactions/like/route.ts index e498958..221f8bd 100644 --- a/src/app/api/swarm/interactions/like/route.ts +++ b/src/app/api/swarm/interactions/like/route.ts @@ -12,6 +12,7 @@ import { eq, and } from 'drizzle-orm'; import { z } from 'zod'; import { verifySwarmRequest } from '@/lib/swarm/signature'; import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation'; +import { buildNotificationTarget } from '@/lib/notifications'; const swarmLikeSchema = z.object({ postId: z.string().uuid(), @@ -88,6 +89,8 @@ export async function POST(request: NextRequest) { .set({ likesCount: post.likesCount + 1 }) .where(eq(posts.id, data.postId)); + const author = post.author as { isBot?: boolean; botOwnerId?: string; handle?: string; displayName?: string | null; avatarUrl?: string | null } | null; + // Create notification with actor info stored directly try { await db.insert(notifications).values({ @@ -98,6 +101,7 @@ export async function POST(request: NextRequest) { actorNodeDomain: data.like.actorNodeDomain, postId: data.postId, postContent: post.content?.slice(0, 200) || null, + ...(author?.isBot ? buildNotificationTarget(author as any) : {}), type: 'like', }); console.log(`[Swarm] Created like notification for post ${data.postId} from ${data.like.actorHandle}@${data.like.actorNodeDomain}`); @@ -108,7 +112,6 @@ export async function POST(request: NextRequest) { } // Also notify bot owner if this is a bot's post - const author = post.author as { isBot?: boolean; botOwnerId?: string } | null; if (author?.isBot && author.botOwnerId) { try { await db.insert(notifications).values({ @@ -119,6 +122,7 @@ export async function POST(request: NextRequest) { actorNodeDomain: data.like.actorNodeDomain, postId: data.postId, postContent: post.content?.slice(0, 200) || null, + ...buildNotificationTarget(author as any), type: 'like', }); } catch (err) { diff --git a/src/app/api/swarm/interactions/mention/route.ts b/src/app/api/swarm/interactions/mention/route.ts index 1c2faa2..1f1e68e 100644 --- a/src/app/api/swarm/interactions/mention/route.ts +++ b/src/app/api/swarm/interactions/mention/route.ts @@ -12,6 +12,7 @@ import { eq } from 'drizzle-orm'; import { z } from 'zod'; import { verifySwarmRequest } from '@/lib/swarm/signature'; import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation'; +import { buildNotificationTarget } from '@/lib/notifications'; const swarmMentionSchema = z.object({ mentionedHandle: localHandleSchema, @@ -73,6 +74,7 @@ export async function POST(request: NextRequest) { actorAvatarUrl: data.mention.actorAvatarUrl || null, actorNodeDomain: data.mention.actorNodeDomain, postContent: data.mention.postContent.slice(0, 200), + ...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}), type: 'mention', }); console.log(`[Swarm] Created mention notification for @${data.mentionedHandle} from ${data.mention.actorHandle}@${data.mention.actorNodeDomain}`); @@ -90,6 +92,7 @@ export async function POST(request: NextRequest) { actorAvatarUrl: data.mention.actorAvatarUrl || null, actorNodeDomain: data.mention.actorNodeDomain, postContent: data.mention.postContent.slice(0, 200), + ...buildNotificationTarget(mentionedUser), type: 'mention', }); } catch (err) { diff --git a/src/app/api/swarm/interactions/repost/route.ts b/src/app/api/swarm/interactions/repost/route.ts index fb480a6..e3f4fcd 100644 --- a/src/app/api/swarm/interactions/repost/route.ts +++ b/src/app/api/swarm/interactions/repost/route.ts @@ -7,11 +7,12 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { db, posts, users, notifications } from '@/db'; -import { eq, sql } from 'drizzle-orm'; +import { db, posts, users, notifications, remoteReposts } from '@/db'; +import { eq, sql, and } from 'drizzle-orm'; import { z } from 'zod'; import { verifySwarmRequest } from '@/lib/swarm/signature'; import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation'; +import { buildNotificationTarget } from '@/lib/notifications'; const swarmRepostSchema = z.object({ postId: z.string().uuid(), @@ -64,11 +65,34 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Post not found' }, { status: 404 }); } + const existingRepost = await db.query.remoteReposts.findFirst({ + where: and( + eq(remoteReposts.postId, data.postId), + eq(remoteReposts.actorHandle, data.repost.actorHandle), + eq(remoteReposts.actorNodeDomain, data.repost.actorNodeDomain), + ), + }); + + if (existingRepost) { + return NextResponse.json({ + success: true, + message: 'Repost already recorded', + }); + } + // Increment repost count await db.update(posts) .set({ repostsCount: sql`${posts.repostsCount} + 1` }) .where(eq(posts.id, data.postId)); + await db.insert(remoteReposts).values({ + postId: data.postId, + actorHandle: data.repost.actorHandle, + actorNodeDomain: data.repost.actorNodeDomain, + }); + + const author = post.author as { isBot?: boolean; botOwnerId?: string; handle?: string; displayName?: string | null; avatarUrl?: string | null } | null; + // Create notification with actor info stored directly try { await db.insert(notifications).values({ @@ -79,6 +103,7 @@ export async function POST(request: NextRequest) { actorNodeDomain: data.repost.actorNodeDomain, postId: data.postId, postContent: post.content?.slice(0, 200) || null, + ...(author?.isBot ? buildNotificationTarget(author as any) : {}), type: 'repost', }); console.log(`[Swarm] Created repost notification for post ${data.postId} from ${data.repost.actorHandle}@${data.repost.actorNodeDomain}`); @@ -87,7 +112,6 @@ export async function POST(request: NextRequest) { } // Also notify bot owner if this is a bot's post - const author = post.author as { isBot?: boolean; botOwnerId?: string } | null; if (author?.isBot && author.botOwnerId) { try { await db.insert(notifications).values({ @@ -98,6 +122,7 @@ export async function POST(request: NextRequest) { actorNodeDomain: data.repost.actorNodeDomain, postId: data.postId, postContent: post.content?.slice(0, 200) || null, + ...buildNotificationTarget(author as any), type: 'repost', }); } catch (err) { diff --git a/src/app/api/swarm/interactions/unrepost/route.ts b/src/app/api/swarm/interactions/unrepost/route.ts index ae0c915..5e199eb 100644 --- a/src/app/api/swarm/interactions/unrepost/route.ts +++ b/src/app/api/swarm/interactions/unrepost/route.ts @@ -7,8 +7,8 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { db, posts } from '@/db'; -import { eq, sql } from 'drizzle-orm'; +import { db, posts, remoteReposts } from '@/db'; +import { eq, sql, and } from 'drizzle-orm'; import { z } from 'zod'; import { verifySwarmRequest } from '@/lib/swarm/signature'; import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation'; @@ -60,11 +60,28 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Post not found' }, { status: 404 }); } + const existingRepost = await db.query.remoteReposts.findFirst({ + where: and( + eq(remoteReposts.postId, data.postId), + eq(remoteReposts.actorHandle, data.unrepost.actorHandle), + eq(remoteReposts.actorNodeDomain, data.unrepost.actorNodeDomain), + ), + }); + + if (!existingRepost) { + return NextResponse.json({ + success: true, + message: 'Repost already removed', + }); + } + // Decrement repost count await db.update(posts) .set({ repostsCount: sql`GREATEST(0, ${posts.repostsCount} - 1)` }) .where(eq(posts.id, data.postId)); + await db.delete(remoteReposts).where(eq(remoteReposts.id, existingRepost.id)); + console.log(`[Swarm] Received unrepost from ${data.unrepost.actorHandle}@${data.unrepost.actorNodeDomain} on post ${data.postId}`); return NextResponse.json({ diff --git a/src/app/api/swarm/posts/[id]/route.ts b/src/app/api/swarm/posts/[id]/route.ts index d816432..9795e54 100644 --- a/src/app/api/swarm/posts/[id]/route.ts +++ b/src/app/api/swarm/posts/[id]/route.ts @@ -5,9 +5,10 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { db, posts } from '@/db'; +import { db, posts, userSwarmReposts, users } from '@/db'; import { eq, desc, and } from 'drizzle-orm'; import { z } from 'zod'; +import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview'; type RouteContext = { params: Promise<{ id: string }> }; @@ -43,7 +44,62 @@ export async function GET(request: NextRequest, context: RouteContext) { }); if (!post || post.isRemoved) { - return NextResponse.json({ error: 'Post not found' }, { status: 404 }); + const remoteRepost = await db.query.userSwarmReposts.findFirst({ + where: eq(userSwarmReposts.id, postId), + }); + + if (!remoteRepost) { + return NextResponse.json({ error: 'Post not found' }, { status: 404 }); + } + + const repostAuthor = await db.query.users.findFirst({ + where: eq(users.id, remoteRepost.userId), + }); + + return NextResponse.json({ + post: { + id: remoteRepost.id, + apId: null, + content: '', + createdAt: remoteRepost.repostedAt.toISOString(), + likesCount: 0, + repostsCount: 0, + repliesCount: 0, + author: repostAuthor ? { + handle: repostAuthor.handle, + displayName: repostAuthor.displayName, + avatarUrl: repostAuthor.avatarUrl, + } : null, + media: [], + repostOfId: remoteRepost.originalPostId, + repostOf: { + id: remoteRepost.originalPostId, + originalPostId: remoteRepost.originalPostId, + content: remoteRepost.content, + createdAt: remoteRepost.postCreatedAt.toISOString(), + likesCount: remoteRepost.likesCount, + repostsCount: remoteRepost.repostsCount, + repliesCount: remoteRepost.repliesCount, + nodeDomain: remoteRepost.nodeDomain, + author: { + handle: remoteRepost.authorHandle.includes('@') + ? remoteRepost.authorHandle + : `${remoteRepost.authorHandle}@${remoteRepost.nodeDomain}`, + displayName: remoteRepost.authorDisplayName, + avatarUrl: remoteRepost.authorAvatarUrl, + }, + media: remoteRepost.mediaJson ? JSON.parse(remoteRepost.mediaJson) : [], + linkPreviewUrl: remoteRepost.linkPreviewUrl, + linkPreviewTitle: remoteRepost.linkPreviewTitle, + linkPreviewDescription: remoteRepost.linkPreviewDescription, + linkPreviewImage: remoteRepost.linkPreviewImage, + linkPreviewType: remoteRepost.linkPreviewType, + linkPreviewVideoUrl: remoteRepost.linkPreviewVideoUrl, + linkPreviewMedia: parseLinkPreviewMediaJson(remoteRepost.linkPreviewMediaJson) || [], + }, + }, + replies: [], + }); } // Get replies @@ -84,6 +140,9 @@ export async function GET(request: NextRequest, context: RouteContext) { linkPreviewTitle: post.linkPreviewTitle, linkPreviewDescription: post.linkPreviewDescription, linkPreviewImage: post.linkPreviewImage, + linkPreviewType: post.linkPreviewType, + linkPreviewVideoUrl: post.linkPreviewVideoUrl, + linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson) || [], }, replies: replies.map(r => { const replyAuthor = r.author as any; @@ -103,6 +162,13 @@ export async function GET(request: NextRequest, context: RouteContext) { url: m.url, altText: m.altText, })) || [], + linkPreviewUrl: r.linkPreviewUrl, + linkPreviewTitle: r.linkPreviewTitle, + linkPreviewDescription: r.linkPreviewDescription, + linkPreviewImage: r.linkPreviewImage, + linkPreviewType: r.linkPreviewType, + linkPreviewVideoUrl: r.linkPreviewVideoUrl, + linkPreviewMedia: parseLinkPreviewMediaJson(r.linkPreviewMediaJson) || [], }; }), }); diff --git a/src/app/api/swarm/replies/route.ts b/src/app/api/swarm/replies/route.ts index bd260e1..1c4a5ba 100644 --- a/src/app/api/swarm/replies/route.ts +++ b/src/app/api/swarm/replies/route.ts @@ -11,6 +11,7 @@ import { eq, desc, and, sql } from 'drizzle-orm'; import { z } from 'zod'; import { verifySwarmRequest } from '@/lib/swarm/signature'; import { upsertRemoteUser } from '@/lib/swarm/user-cache'; +import { buildNotificationTarget } from '@/lib/notifications'; // Schema for incoming swarm reply const swarmReplySchema = z.object({ @@ -145,6 +146,8 @@ export async function POST(request: NextRequest) { await syncParentReplyCount(data.postId); + const parentAuthor = parentPost.author as { isBot?: boolean; botOwnerId?: string; handle?: string; displayName?: string | null; avatarUrl?: string | null } | null; + if (parentPost.userId !== remoteUser.id) { await db.insert(notifications).values({ userId: parentPost.userId, @@ -154,8 +157,23 @@ export async function POST(request: NextRequest) { actorNodeDomain: sourceDomain, postId: data.postId, postContent: data.reply.content.slice(0, 200), + ...(parentAuthor?.isBot ? buildNotificationTarget(parentAuthor as any) : {}), type: 'reply', }); + + if (parentAuthor?.isBot && parentAuthor.botOwnerId) { + await db.insert(notifications).values({ + userId: parentAuthor.botOwnerId, + actorHandle: data.reply.author.handle, + actorDisplayName: data.reply.author.displayName || data.reply.author.handle, + actorAvatarUrl: data.reply.author.avatarUrl || null, + actorNodeDomain: sourceDomain, + postId: data.postId, + postContent: data.reply.content.slice(0, 200), + ...buildNotificationTarget(parentAuthor as any), + type: 'reply', + }); + } } return NextResponse.json({ success: true, message: 'Reply received' }); diff --git a/src/app/api/swarm/timeline/route.ts b/src/app/api/swarm/timeline/route.ts index 7d7d887..b071a71 100644 --- a/src/app/api/swarm/timeline/route.ts +++ b/src/app/api/swarm/timeline/route.ts @@ -6,7 +6,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { db, posts, users, media, nodes } from '@/db'; -import { eq, desc, and, isNull, lt, sql } from 'drizzle-orm'; +import { eq, desc, and, isNull, lt, sql, inArray } from 'drizzle-orm'; +import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview'; export interface SwarmPost { id: string; @@ -15,6 +16,8 @@ export interface SwarmPost { isReply?: boolean; replyToId?: string | null; swarmReplyToId?: string | null; + repostOfId?: string | null; + repostOf?: SwarmPost | null; author: { handle: string; displayName: string; @@ -34,6 +37,74 @@ export interface SwarmPost { linkPreviewTitle?: string; linkPreviewDescription?: string; linkPreviewImage?: string; + linkPreviewType?: 'card' | 'image' | 'gallery' | 'video'; + linkPreviewVideoUrl?: string; + linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }>; +} + +interface TimelinePostRow { + id: string; + content: string; + createdAt: Date; + replyToId: string | null; + swarmReplyToId: string | null; + repostOfId: string | null; + isNsfw: boolean; + likesCount: number; + repostsCount: number; + repliesCount: number; + linkPreviewUrl: string | null; + linkPreviewTitle: string | null; + linkPreviewDescription: string | null; + linkPreviewImage: string | null; + linkPreviewType: string | null; + linkPreviewVideoUrl: string | null; + linkPreviewMediaJson: string | null; + authorHandle: string; + authorDisplayName: string | null; + authorAvatarUrl: string | null; + authorIsNsfw: boolean; + authorIsBot: boolean | null; +} + +function buildSwarmPost( + post: TimelinePostRow, + mediaByPostId: Map<string, Array<{ url: string; mimeType?: string; altText?: string }>>, + repostById: Map<string, SwarmPost>, + nodeDomain: string, + nodeIsNsfw: boolean +): SwarmPost { + return { + id: post.id, + content: post.content, + createdAt: post.createdAt.toISOString(), + isReply: Boolean(post.replyToId || post.swarmReplyToId), + replyToId: post.replyToId, + swarmReplyToId: post.swarmReplyToId, + repostOfId: post.repostOfId, + repostOf: post.repostOfId ? repostById.get(post.repostOfId) || null : null, + author: { + handle: post.authorHandle, + displayName: post.authorDisplayName || post.authorHandle, + avatarUrl: post.authorAvatarUrl || undefined, + isNsfw: post.authorIsNsfw, + isBot: post.authorIsBot || undefined, + }, + nodeDomain, + nodeIsNsfw, + isNsfw: post.isNsfw || post.authorIsNsfw, + likeCount: post.likesCount, + repostCount: post.repostsCount, + replyCount: post.repliesCount, + media: mediaByPostId.get(post.id), + linkPreviewUrl: post.linkPreviewUrl || undefined, + linkPreviewTitle: post.linkPreviewTitle || undefined, + linkPreviewDescription: post.linkPreviewDescription || undefined, + linkPreviewImage: post.linkPreviewImage || undefined, + linkPreviewType: (post.linkPreviewType as SwarmPost['linkPreviewType']) || undefined, + linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined, + linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson), + }; } /** @@ -89,6 +160,7 @@ export async function GET(request: NextRequest) { createdAt: posts.createdAt, replyToId: posts.replyToId, swarmReplyToId: posts.swarmReplyToId, + repostOfId: posts.repostOfId, isNsfw: posts.isNsfw, likesCount: posts.likesCount, repostsCount: posts.repostsCount, @@ -97,6 +169,9 @@ export async function GET(request: NextRequest) { linkPreviewTitle: posts.linkPreviewTitle, linkPreviewDescription: posts.linkPreviewDescription, linkPreviewImage: posts.linkPreviewImage, + linkPreviewType: posts.linkPreviewType, + linkPreviewVideoUrl: posts.linkPreviewVideoUrl, + linkPreviewMediaJson: posts.linkPreviewMediaJson, authorHandle: users.handle, authorDisplayName: users.displayName, authorAvatarUrl: users.avatarUrl, @@ -112,47 +187,84 @@ export async function GET(request: NextRequest) { console.log(`[Swarm Timeline API] Found ${recentPosts.length} posts for ${nodeDomain}`); - // Fetch media for each post - const swarmPosts: SwarmPost[] = []; + const repostIds = Array.from(new Set( + recentPosts + .map(post => post.repostOfId) + .filter((id): id is string => Boolean(id)) + )); - for (const post of recentPosts) { - const postMedia = await db - .select({ url: media.url, mimeType: media.mimeType, altText: media.altText }) - .from(media) - .where(eq(media.postId, post.id)); + const repostTargets = repostIds.length > 0 + ? await db + .select({ + id: posts.id, + content: posts.content, + createdAt: posts.createdAt, + replyToId: posts.replyToId, + swarmReplyToId: posts.swarmReplyToId, + repostOfId: posts.repostOfId, + isNsfw: posts.isNsfw, + likesCount: posts.likesCount, + repostsCount: posts.repostsCount, + repliesCount: posts.repliesCount, + linkPreviewUrl: posts.linkPreviewUrl, + linkPreviewTitle: posts.linkPreviewTitle, + linkPreviewDescription: posts.linkPreviewDescription, + linkPreviewImage: posts.linkPreviewImage, + linkPreviewType: posts.linkPreviewType, + linkPreviewVideoUrl: posts.linkPreviewVideoUrl, + linkPreviewMediaJson: posts.linkPreviewMediaJson, + authorHandle: users.handle, + authorDisplayName: users.displayName, + authorAvatarUrl: users.avatarUrl, + authorIsNsfw: users.isNsfw, + authorIsBot: users.isBot, + }) + .from(posts) + .innerJoin(users, eq(posts.userId, users.id)) + .where(and( + inArray(posts.id, repostIds), + eq(posts.isRemoved, false), + )) + : []; - swarmPosts.push({ - id: post.id, - content: post.content, - createdAt: post.createdAt.toISOString(), - isReply: Boolean(post.replyToId || post.swarmReplyToId), - replyToId: post.replyToId, - swarmReplyToId: post.swarmReplyToId, - author: { - handle: post.authorHandle, - displayName: post.authorDisplayName || post.authorHandle, - avatarUrl: post.authorAvatarUrl || undefined, - isNsfw: post.authorIsNsfw, - isBot: post.authorIsBot, - }, - nodeDomain, - nodeIsNsfw, - isNsfw: post.isNsfw || post.authorIsNsfw, // Post-level NSFW (not node-level) - likeCount: post.likesCount, - repostCount: post.repostsCount, - replyCount: post.repliesCount, - media: postMedia.length > 0 ? postMedia.map(m => ({ - url: m.url, - mimeType: m.mimeType || undefined, - altText: m.altText || undefined, - })) : undefined, - linkPreviewUrl: post.linkPreviewUrl || undefined, - linkPreviewTitle: post.linkPreviewTitle || undefined, - linkPreviewDescription: post.linkPreviewDescription || undefined, - linkPreviewImage: post.linkPreviewImage || undefined, + const mediaPostIds = Array.from(new Set([ + ...recentPosts.map(post => post.id), + ...repostTargets.map(post => post.id), + ])); + + const mediaRows = mediaPostIds.length > 0 + ? await db + .select({ + postId: media.postId, + url: media.url, + mimeType: media.mimeType, + altText: media.altText, + }) + .from(media) + .where(inArray(media.postId, mediaPostIds)) + : []; + + const mediaByPostId = new Map<string, Array<{ url: string; mimeType?: string; altText?: string }>>(); + for (const item of mediaRows) { + if (!item.postId) continue; + const bucket = mediaByPostId.get(item.postId) || []; + bucket.push({ + url: item.url, + mimeType: item.mimeType || undefined, + altText: item.altText || undefined, }); + mediaByPostId.set(item.postId, bucket); } + const repostById = new Map<string, SwarmPost>(); + for (const post of repostTargets) { + repostById.set(post.id, buildSwarmPost(post, mediaByPostId, repostById, nodeDomain, nodeIsNsfw)); + } + + const swarmPosts = recentPosts.map(post => + buildSwarmPost(post, mediaByPostId, repostById, nodeDomain, nodeIsNsfw) + ); + return NextResponse.json({ posts: swarmPosts, nodeDomain, diff --git a/src/app/api/swarm/users/[handle]/route.ts b/src/app/api/swarm/users/[handle]/route.ts index b9375ab..a516c6e 100644 --- a/src/app/api/swarm/users/[handle]/route.ts +++ b/src/app/api/swarm/users/[handle]/route.ts @@ -5,8 +5,9 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { db, posts, users, media, nodes } from '@/db'; +import { db, posts, users, userSwarmReposts } from '@/db'; import { eq, desc, and, isNull } from 'drizzle-orm'; +import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview'; export interface SwarmUserProfile { handle: string; @@ -28,21 +29,144 @@ export interface SwarmUserProfile { export interface SwarmUserPost { id: string; + originalPostId?: string; content: string; createdAt: string; isNsfw: boolean; likesCount: number; repostsCount: number; repliesCount: number; + nodeDomain?: string; + author?: { + handle: string; + displayName?: string; + avatarUrl?: string; + isBot?: boolean; + nodeDomain?: string; + }; media?: { url: string; mimeType?: string; altText?: string }[]; linkPreviewUrl?: string; linkPreviewTitle?: string; linkPreviewDescription?: string; linkPreviewImage?: string; + linkPreviewType?: 'card' | 'image' | 'gallery' | 'video'; + linkPreviewVideoUrl?: string; + linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }>; + repostOfId?: string; + repostOf?: SwarmUserPost | null; } type RouteContext = { params: Promise<{ handle: string }> }; +const profilePostRelations = { + author: true, + media: true, + repostOf: { + with: { + author: true, + media: true, + }, + }, +} as const; + +function parseMediaJson(mediaJson: string | null) { + if (!mediaJson) { + return []; + } + + try { + return JSON.parse(mediaJson); + } catch { + return []; + } +} + +function mapLocalPostToSwarmPost(post: any, nodeDomain: string): SwarmUserPost { + return { + id: post.id, + originalPostId: post.id, + content: post.content, + createdAt: post.createdAt.toISOString(), + isNsfw: post.isNsfw, + likesCount: post.likesCount, + repostsCount: post.repostsCount, + repliesCount: post.repliesCount, + nodeDomain, + author: post.author ? { + handle: post.author.handle, + displayName: post.author.displayName || post.author.handle, + avatarUrl: post.author.avatarUrl || undefined, + isBot: post.author.isBot || undefined, + nodeDomain, + } : undefined, + media: (post.media || []).map((item: any) => ({ + url: item.url, + mimeType: item.mimeType || undefined, + altText: item.altText || undefined, + })), + linkPreviewUrl: post.linkPreviewUrl || undefined, + linkPreviewTitle: post.linkPreviewTitle || undefined, + linkPreviewDescription: post.linkPreviewDescription || undefined, + linkPreviewImage: post.linkPreviewImage || undefined, + linkPreviewType: post.linkPreviewType || undefined, + linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined, + linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson), + repostOfId: post.repostOfId || undefined, + repostOf: post.repostOf ? mapLocalPostToSwarmPost(post.repostOf, nodeDomain) : undefined, + }; +} + +function mapUserSwarmRepostToSwarmPost( + row: typeof userSwarmReposts.$inferSelect, + author: typeof users.$inferSelect, + nodeDomain: string +): SwarmUserPost { + return { + id: row.id, + originalPostId: row.id, + content: '', + createdAt: row.repostedAt.toISOString(), + isNsfw: false, + likesCount: 0, + repostsCount: 0, + repliesCount: 0, + nodeDomain, + author: { + handle: author.handle, + displayName: author.displayName || author.handle, + avatarUrl: author.avatarUrl || undefined, + isBot: author.isBot || undefined, + nodeDomain, + }, + repostOfId: row.originalPostId, + repostOf: { + id: row.originalPostId, + originalPostId: row.originalPostId, + content: row.content, + createdAt: row.postCreatedAt.toISOString(), + isNsfw: false, + likesCount: row.likesCount, + repostsCount: row.repostsCount, + repliesCount: row.repliesCount, + nodeDomain: row.nodeDomain, + author: { + handle: row.authorHandle.includes('@') ? row.authorHandle : `${row.authorHandle}@${row.nodeDomain}`, + displayName: row.authorDisplayName || row.authorHandle, + avatarUrl: row.authorAvatarUrl || undefined, + nodeDomain: row.nodeDomain, + }, + media: parseMediaJson(row.mediaJson), + linkPreviewUrl: row.linkPreviewUrl || undefined, + linkPreviewTitle: row.linkPreviewTitle || undefined, + linkPreviewDescription: row.linkPreviewDescription || undefined, + linkPreviewImage: row.linkPreviewImage || undefined, + linkPreviewType: (row.linkPreviewType as SwarmUserPost['linkPreviewType']) || undefined, + linkPreviewVideoUrl: row.linkPreviewVideoUrl || undefined, + linkPreviewMedia: parseLinkPreviewMediaJson(row.linkPreviewMediaJson), + }, + }; +} + /** * GET /api/swarm/users/[handle] * @@ -97,61 +221,30 @@ export async function GET(request: NextRequest, context: RouteContext) { did: user.did || undefined, }; - // Get user's recent posts - const userPosts = await db - .select({ - id: posts.id, - content: posts.content, - createdAt: posts.createdAt, - isNsfw: posts.isNsfw, - likesCount: posts.likesCount, - repostsCount: posts.repostsCount, - repliesCount: posts.repliesCount, - linkPreviewUrl: posts.linkPreviewUrl, - linkPreviewTitle: posts.linkPreviewTitle, - linkPreviewDescription: posts.linkPreviewDescription, - linkPreviewImage: posts.linkPreviewImage, - }) - .from(posts) - .where( - and( - eq(posts.userId, user.id), - eq(posts.isRemoved, false), - isNull(posts.replyToId), - isNull(posts.swarmReplyToId) - ) - ) - .orderBy(desc(posts.createdAt)) - .limit(limit); + const localPosts = await db.query.posts.findMany({ + where: and( + eq(posts.userId, user.id), + eq(posts.isRemoved, false), + isNull(posts.replyToId), + isNull(posts.swarmReplyToId) + ), + with: profilePostRelations, + orderBy: [desc(posts.createdAt)], + limit: limit * 2, + }); - // Fetch media for each post - const swarmPosts: SwarmUserPost[] = []; + const remoteRepostRows = await db.query.userSwarmReposts.findMany({ + where: eq(userSwarmReposts.userId, user.id), + orderBy: [desc(userSwarmReposts.repostedAt)], + limit: limit * 2, + }); - for (const post of userPosts) { - const postMedia = await db - .select({ url: media.url, mimeType: media.mimeType, altText: media.altText }) - .from(media) - .where(eq(media.postId, post.id)); - - swarmPosts.push({ - id: post.id, - content: post.content, - createdAt: post.createdAt.toISOString(), - isNsfw: post.isNsfw, - likesCount: post.likesCount, - repostsCount: post.repostsCount, - repliesCount: post.repliesCount, - media: postMedia.length > 0 ? postMedia.map(m => ({ - url: m.url, - mimeType: m.mimeType || undefined, - altText: m.altText || undefined, - })) : undefined, - linkPreviewUrl: post.linkPreviewUrl || undefined, - linkPreviewTitle: post.linkPreviewTitle || undefined, - linkPreviewDescription: post.linkPreviewDescription || undefined, - linkPreviewImage: post.linkPreviewImage || undefined, - }); - } + const swarmPosts: SwarmUserPost[] = [ + ...localPosts.map((post) => mapLocalPostToSwarmPost(post, nodeDomain)), + ...remoteRepostRows.map((row) => mapUserSwarmRepostToSwarmPost(row, user, nodeDomain)), + ] + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, limit); return NextResponse.json({ profile, diff --git a/src/app/api/users/[handle]/follow/route.ts b/src/app/api/users/[handle]/follow/route.ts index a7d15d0..7d38a1d 100644 --- a/src/app/api/users/[handle]/follow/route.ts +++ b/src/app/api/users/[handle]/follow/route.ts @@ -6,6 +6,7 @@ import { requireAuth } from '@/lib/auth'; import { requireSignedAction } from '@/lib/auth/verify-signature'; import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions'; import { discoverNode } from '@/lib/swarm/discovery'; +import { buildNotificationTarget } from '@/lib/notifications'; type RouteContext = { params: Promise<{ handle: string }> }; @@ -220,6 +221,7 @@ export async function POST(request: Request, context: RouteContext) { actorDisplayName: currentUser.displayName, actorAvatarUrl: currentUser.avatarUrl, actorNodeDomain: null, + ...(targetUser.isBot ? buildNotificationTarget(targetUser) : {}), type: 'follow', }); @@ -232,6 +234,7 @@ export async function POST(request: Request, context: RouteContext) { actorDisplayName: currentUser.displayName, actorAvatarUrl: currentUser.avatarUrl, actorNodeDomain: null, + ...buildNotificationTarget(targetUser), type: 'follow', }); } diff --git a/src/app/api/users/[handle]/likes/route.ts b/src/app/api/users/[handle]/likes/route.ts index 718cef6..762ee87 100644 --- a/src/app/api/users/[handle]/likes/route.ts +++ b/src/app/api/users/[handle]/likes/route.ts @@ -1,9 +1,34 @@ import { NextResponse } from 'next/server'; import { db, likes, posts, users, userSwarmLikes } from '@/db'; import { eq, desc, and, inArray } from 'drizzle-orm'; +import { discoverNode } from '@/lib/swarm/discovery'; +import { isSwarmNode } from '@/lib/swarm/interactions'; +import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts'; +import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts'; +import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview'; type RouteContext = { params: Promise<{ handle: string }> }; +const embeddedPostRelations = { + author: true, + bot: true, + media: true, + replyTo: { + with: { + author: true, + bot: true, + media: true, + }, + }, +} as const; + +const likedPostRelations = { + ...embeddedPostRelations, + repostOf: { + with: embeddedPostRelations, + }, +} as const; + const parseMediaJson = (mediaJson: string | null) => { if (!mediaJson) { return []; @@ -22,11 +47,88 @@ export async function GET(request: Request, context: RouteContext) { const cleanHandle = handle.toLowerCase().replace(/^@/, ''); const { searchParams } = new URL(request.url); const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50); + const remote = parseRemoteHandle(handle); + + const fetchRemoteLikesRoute = async () => { + if (!remote) { + return NextResponse.json({ posts: [], nextCursor: null }); + } + + const baseUrl = getRemoteBaseUrl(remote.domain); + const res = await fetch(`${baseUrl}/api/users/${remote.handle}/likes?limit=${limit}`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + + if (!res.ok) { + return NextResponse.json({ posts: [], nextCursor: null }); + } + + const data = await res.json(); + const { getSession } = await import('@/lib/auth'); + const session = await getSession(); + const viewer = session?.user; + const mappedPosts = (data.posts || []).map((post: any) => mapRemoteProfilePost(post, remote.domain)); + const repostedIds = viewer + ? await getViewerSwarmRepostedPostIds( + mappedPosts.map((post: any) => ({ + id: post.id, + nodeDomain: remote.domain, + originalPostId: post.originalPostId || post.id.split(':').pop(), + })), + viewer.id + ) + : new Set<string>(); + return NextResponse.json({ + posts: mappedPosts.map((post: any) => ({ + ...post, + isReposted: repostedIds.has(post.id), + })), + nextCursor: null, + }); + }; + + if (!db) { + if (!remote) { + return NextResponse.json({ posts: [], nextCursor: null }); + } + + let swarm = await isSwarmNode(remote.domain); + if (!swarm) { + const discovery = await discoverNode(remote.domain); + swarm = discovery.success; + } + + if (!swarm) { + return NextResponse.json({ posts: [], nextCursor: null }); + } + + return await fetchRemoteLikesRoute(); + } // Find the user const user = await db.query.users.findFirst({ where: eq(users.handle, cleanHandle), }); + const isRemotePlaceholder = user && cleanHandle.includes('@'); + + if (!user || isRemotePlaceholder) { + if (!remote) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + let swarm = await isSwarmNode(remote.domain); + if (!swarm) { + const discovery = await discoverNode(remote.domain); + swarm = discovery.success; + } + + if (!swarm) { + return NextResponse.json({ posts: [], nextCursor: null }); + } + + return await fetchRemoteLikesRoute(); + } if (!user) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); @@ -42,11 +144,7 @@ export async function GET(request: Request, context: RouteContext) { where: eq(likes.userId, user.id), with: { post: { - with: { - author: true, - media: true, - bot: true, - }, + with: likedPostRelations, }, }, orderBy: [desc(likes.createdAt)], @@ -82,6 +180,9 @@ export async function GET(request: Request, context: RouteContext) { linkPreviewTitle: like.linkPreviewTitle, linkPreviewDescription: like.linkPreviewDescription, linkPreviewImage: like.linkPreviewImage, + linkPreviewType: like.linkPreviewType, + linkPreviewVideoUrl: like.linkPreviewVideoUrl, + linkPreviewMedia: parseLinkPreviewMediaJson(like.linkPreviewMediaJson) || null, isSwarm: true, nodeDomain: like.nodeDomain, likedAt: like.likedAt.toISOString(), @@ -110,6 +211,17 @@ export async function GET(request: Request, context: RouteContext) { .filter((post: any) => !post.isSwarm) .map((post: any) => post.id) .filter(Boolean); + const swarmTargets = likedPosts + .filter((post: any) => post.isSwarm) + .map((post: any) => ({ + id: post.id, + nodeDomain: post.nodeDomain, + originalPostId: post.originalPostId, + })) + .filter((post: any) => post.nodeDomain && post.originalPostId); + const swarmRepostedIds = swarmTargets.length > 0 + ? await getViewerSwarmRepostedPostIds(swarmTargets as any, viewer.id) + : new Set<string>(); if (localPostIds.length > 0) { const viewerLikes = await db.query.likes.findMany({ @@ -132,12 +244,13 @@ export async function GET(request: Request, context: RouteContext) { likedPosts = likedPosts.map(p => ({ ...p!, isLiked: p!.isSwarm ? isOwnLikesView : likedPostIds.has(p!.id), - isReposted: repostedPostIds.has(p!.id), + isReposted: p!.isSwarm ? swarmRepostedIds.has(p!.id) : repostedPostIds.has(p!.id), })) as any; } else { likedPosts = likedPosts.map(p => ({ ...p!, isLiked: p!.isSwarm ? isOwnLikesView : p!.isLiked, + isReposted: p!.isSwarm ? swarmRepostedIds.has(p!.id) : p!.isReposted, })) as any; } } diff --git a/src/app/api/users/[handle]/posts/route.ts b/src/app/api/users/[handle]/posts/route.ts index 0381a80..ea0a6f0 100644 --- a/src/app/api/users/[handle]/posts/route.ts +++ b/src/app/api/users/[handle]/posts/route.ts @@ -1,24 +1,169 @@ import { NextResponse } from 'next/server'; -import { db, posts, users, likes } from '@/db'; +import { db, posts, users, likes, userSwarmReposts } from '@/db'; import { eq, desc, and, inArray, lt, sql, isNull } from 'drizzle-orm'; import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions'; import { discoverNode } from '@/lib/swarm/discovery'; import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes'; +import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts'; +import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview'; + +const embeddedPostRelations = { + author: true, + bot: true, + media: true, + replyTo: { + with: { + author: true, + bot: true, + media: true, + }, + }, +} as const; + +const userPostRelations = { + ...embeddedPostRelations, + repostOf: { + with: embeddedPostRelations, + }, +} as const; type RouteContext = { params: Promise<{ handle: string }> }; -const parseRemoteHandle = (handle: string) => { - const clean = handle.toLowerCase().replace(/^@/, ''); - const parts = clean.split('@').filter(Boolean); - if (parts.length === 2) { - return { handle: parts[0], domain: parts[1] }; - } - return null; +type FeedPostWithChildren = { + id: string; + createdAt?: string | Date; + repostOf?: FeedPostWithChildren | null; + replyTo?: FeedPostWithChildren | null; + isLiked?: boolean; + isReposted?: boolean; + nodeDomain?: string | null; + originalPostId?: string | null; }; +function parseMediaJson(mediaJson: string | null) { + if (!mediaJson) { + return []; + } + + try { + return JSON.parse(mediaJson); + } catch { + return []; + } +} + +function mapUserSwarmRepostToFeedPost( + row: typeof userSwarmReposts.$inferSelect, + author: Pick<typeof users.$inferSelect, 'id' | 'handle' | 'displayName' | 'avatarUrl' | 'isBot'> +): FeedPostWithChildren { + const remoteAuthorHandle = row.authorHandle.includes('@') + ? row.authorHandle + : `${row.authorHandle}@${row.nodeDomain}`; + const remoteOriginalId = `swarm:${row.nodeDomain}:${row.originalPostId}`; + + return { + id: `swarm-repost:${row.id}`, + content: '', + createdAt: row.repostedAt.toISOString(), + likesCount: 0, + repostsCount: 0, + repliesCount: 0, + author: { + id: author.id, + handle: author.handle, + displayName: author.displayName, + avatarUrl: author.avatarUrl, + isBot: author.isBot, + }, + repostOfId: remoteOriginalId, + repostOf: { + id: remoteOriginalId, + originalPostId: row.originalPostId, + content: row.content, + createdAt: row.postCreatedAt.toISOString(), + likesCount: row.likesCount, + repostsCount: row.repostsCount, + repliesCount: row.repliesCount, + isSwarm: true, + nodeDomain: row.nodeDomain, + author: { + id: `swarm:${row.nodeDomain}:${row.authorHandle}`, + handle: remoteAuthorHandle, + displayName: row.authorDisplayName || row.authorHandle, + avatarUrl: row.authorAvatarUrl, + isRemote: true, + nodeDomain: row.nodeDomain, + }, + media: parseMediaJson(row.mediaJson), + linkPreviewUrl: row.linkPreviewUrl, + linkPreviewTitle: row.linkPreviewTitle, + linkPreviewDescription: row.linkPreviewDescription, + linkPreviewImage: row.linkPreviewImage, + linkPreviewType: row.linkPreviewType, + linkPreviewVideoUrl: row.linkPreviewVideoUrl, + linkPreviewMedia: parseLinkPreviewMediaJson(row.linkPreviewMediaJson) || null, + }, + } as any; +} + +function collectNestedPosts(posts: FeedPostWithChildren[]): FeedPostWithChildren[] { + const collected: FeedPostWithChildren[] = []; + const seen = new Set<string>(); + + const visit = (post: FeedPostWithChildren | null | undefined) => { + if (!post || seen.has(post.id)) return; + seen.add(post.id); + collected.push(post); + visit(post.repostOf); + visit(post.replyTo); + }; + + posts.forEach(visit); + return collected; +} + +function applyInteractionFlags( + posts: FeedPostWithChildren[], + likedIds: Set<string>, + repostedIds: Set<string> +): FeedPostWithChildren[] { + return posts.map((post) => ({ + ...post, + isLiked: likedIds.has(post.id), + isReposted: repostedIds.has(post.id), + repostOf: post.repostOf ? applyInteractionFlags([post.repostOf], likedIds, repostedIds)[0] : post.repostOf, + replyTo: post.replyTo ? applyInteractionFlags([post.replyTo], likedIds, repostedIds)[0] : post.replyTo, + })); +} + +function getPostTimestamp(post: { createdAt?: string | Date }) { + if (!post.createdAt) { + return 0; + } + + return new Date(post.createdAt).getTime(); +} + +async function getMixedProfileCursorDate(cursor: string | null) { + if (!cursor) { + return null; + } + + if (cursor.startsWith('swarm-repost:')) { + const repostRow = await db.query.userSwarmReposts.findFirst({ + where: eq(userSwarmReposts.id, cursor.replace('swarm-repost:', '')), + }); + return repostRow?.repostedAt ?? null; + } + + const cursorPost = await db.query.posts.findFirst({ + where: eq(posts.id, cursor), + }); + return cursorPost?.createdAt ?? null; +} + async function populateViewerLikeState( - remotePosts: any[], - domain: string + remotePosts: any[] ) { if (!remotePosts.length) { return remotePosts; @@ -34,20 +179,32 @@ async function populateViewerLikeState( return remotePosts; } + const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts'); + + const allRemotePosts = collectNestedPosts(remotePosts as FeedPostWithChildren[]); + const swarmTargets = allRemotePosts + .filter((post) => post.id.startsWith('swarm:') && post.originalPostId && post.nodeDomain) + .map((post) => ({ + id: post.id, + nodeDomain: post.nodeDomain!, + originalPostId: post.originalPostId!, + })); + const likedIds = await getViewerSwarmLikedPostIds( - remotePosts.map((post) => ({ - id: `swarm:${domain}:${post.originalPostId}`, - nodeDomain: domain, - originalPostId: post.originalPostId, - })), + swarmTargets, viewer.handle, nodeDomain ); + const repostedIds = await getViewerSwarmRepostedPostIds( + swarmTargets, + viewer.id + ); - return remotePosts.map((post) => ({ - ...post, - isLiked: likedIds.has(`swarm:${domain}:${post.originalPostId}`), - })); + return applyInteractionFlags( + remotePosts as FeedPostWithChildren[], + likedIds, + repostedIds + ); } catch { return remotePosts; } @@ -62,6 +219,31 @@ export async function GET(request: Request, context: RouteContext) { const cursor = searchParams.get('cursor'); const remote = parseRemoteHandle(handle); + const fetchRemotePostsRoute = async () => { + if (!remote) { + return NextResponse.json({ posts: [], nextCursor: null }); + } + + const baseUrl = getRemoteBaseUrl(remote.domain); + const res = await fetch( + `${baseUrl}/api/users/${remote.handle}/posts?limit=${limit}`, + { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(10000), + } + ); + + if (!res.ok) { + return NextResponse.json({ posts: [], nextCursor: null }); + } + + const data = await res.json(); + const mappedPosts = (data.posts || []).map((post: any) => mapRemoteProfilePost(post, remote.domain)); + return NextResponse.json({ + posts: await populateViewerLikeState(mappedPosts), + nextCursor: null, + }); + }; if (!db) { if (!remote) { @@ -104,11 +286,14 @@ export async function GET(request: Request, context: RouteContext) { linkPreviewTitle: post.linkPreviewTitle || null, linkPreviewDescription: post.linkPreviewDescription || null, linkPreviewImage: post.linkPreviewImage || null, + linkPreviewType: post.linkPreviewType || null, + linkPreviewVideoUrl: post.linkPreviewVideoUrl || null, + linkPreviewMedia: post.linkPreviewMedia || null, isSwarm: true, nodeDomain: remote.domain, })); - return NextResponse.json({ posts: await populateViewerLikeState(remotePosts, remote.domain), nextCursor: null }); + return NextResponse.json({ posts: await populateViewerLikeState(remotePosts), nextCursor: null }); } return NextResponse.json({ posts: [] }); @@ -118,8 +303,9 @@ export async function GET(request: Request, context: RouteContext) { const user = await db.query.users.findFirst({ where: eq(users.handle, cleanHandle), }); + const isRemotePlaceholder = user && cleanHandle.includes('@'); - if (!user) { + if (!user || isRemotePlaceholder) { if (!remote) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } @@ -135,39 +321,7 @@ export async function GET(request: Request, context: RouteContext) { return NextResponse.json({ posts: [], message: 'Only Synapsis swarm nodes are supported' }); } - const profileData = await fetchSwarmUserProfile(remote.handle, remote.domain, limit); - if (profileData?.posts) { - const profile = profileData.profile; - const authorHandle = `${profile.handle}@${remote.domain}`; - const author = { - id: `swarm:${remote.domain}:${profile.handle}`, - handle: authorHandle, - displayName: profile.displayName || profile.handle, - avatarUrl: profile.avatarUrl, - }; - - const remotePosts = profileData.posts.map((post: any) => ({ - id: post.id, - originalPostId: post.id, - content: post.content, - createdAt: post.createdAt, - likesCount: post.likesCount || 0, - repostsCount: post.repostsCount || 0, - repliesCount: post.repliesCount || 0, - author, - media: post.media || [], - linkPreviewUrl: post.linkPreviewUrl || null, - linkPreviewTitle: post.linkPreviewTitle || null, - linkPreviewDescription: post.linkPreviewDescription || null, - linkPreviewImage: post.linkPreviewImage || null, - isSwarm: true, - nodeDomain: remote.domain, - })); - - return NextResponse.json({ posts: await populateViewerLikeState(remotePosts, remote.domain), nextCursor: null }); - } - - return NextResponse.json({ posts: [] }); + return await fetchRemotePostsRoute(); } if (user.isSuspended) { @@ -175,43 +329,49 @@ export async function GET(request: Request, context: RouteContext) { } // Get user's posts with cursor-based pagination + const cursorDate = await getMixedProfileCursorDate(cursor); let whereConditions = and( eq(posts.userId, user.id), eq(posts.isRemoved, false), isNull(posts.replyToId), isNull(posts.swarmReplyToId) ); - - // If cursor provided, get posts older than the cursor - if (cursor) { - const cursorPost = await db.query.posts.findFirst({ - where: eq(posts.id, cursor), - }); - if (cursorPost) { - whereConditions = and( - eq(posts.userId, user.id), - eq(posts.isRemoved, false), - isNull(posts.replyToId), - isNull(posts.swarmReplyToId), - lt(posts.createdAt, cursorPost.createdAt) - ); - } + + if (cursorDate) { + whereConditions = and( + eq(posts.userId, user.id), + eq(posts.isRemoved, false), + isNull(posts.replyToId), + isNull(posts.swarmReplyToId), + lt(posts.createdAt, cursorDate) + ); } - - let userPosts: any[] = await db.query.posts.findMany({ + + const localPosts = await db.query.posts.findMany({ where: whereConditions, - with: { - author: true, - media: true, - bot: true, - replyTo: { - with: { author: true }, - }, - }, + with: userPostRelations, orderBy: [desc(posts.createdAt)], - limit, + limit: cursor ? limit : limit * 2, }); + const swarmRepostWhere = cursorDate + ? and( + eq(userSwarmReposts.userId, user.id), + lt(userSwarmReposts.repostedAt, cursorDate) + ) + : eq(userSwarmReposts.userId, user.id); + const swarmRepostRows = await db.query.userSwarmReposts.findMany({ + where: swarmRepostWhere, + orderBy: [desc(userSwarmReposts.repostedAt)], + limit: cursor ? limit : limit * 2, + }); + let userPosts: any[] = [ + ...localPosts, + ...swarmRepostRows.map((row) => mapUserSwarmRepostToFeedPost(row, user)), + ] + .sort((a, b) => getPostTimestamp(b) - getPostTimestamp(a)) + .slice(0, limit); + // Populate isLiked and isReposted for authenticated users try { const { getSession } = await import('@/lib/auth'); @@ -219,32 +379,71 @@ export async function GET(request: Request, context: RouteContext) { if (session?.user && userPosts.length > 0) { const viewer = session.user; - const postIds = userPosts.map(p => p.id).filter(Boolean); + const allProfilePosts = collectNestedPosts(userPosts as FeedPostWithChildren[]); + const localPostIds: string[] = []; + const swarmTargets: Array<{ id: string; nodeDomain: string; originalPostId: string }> = []; - if (postIds.length > 0) { + for (const post of allProfilePosts) { + if (post.id.startsWith('swarm:') && post.nodeDomain && post.originalPostId) { + swarmTargets.push({ + id: post.id, + nodeDomain: post.nodeDomain, + originalPostId: post.originalPostId, + }); + } else if (!post.id.startsWith('swarm-repost:')) { + localPostIds.push(post.id); + } + } + + const likedPostIds = new Set<string>(); + const repostedPostIds = new Set<string>(); + + if (localPostIds.length > 0) { const viewerLikes = await db.query.likes.findMany({ where: and( eq(likes.userId, viewer.id), - inArray(likes.postId, postIds) + inArray(likes.postId, localPostIds) ), }); - const likedPostIds = new Set(viewerLikes.map(l => l.postId)); + viewerLikes.forEach((like) => likedPostIds.add(like.postId)); const viewerReposts = await db.query.posts.findMany({ where: and( eq(posts.userId, viewer.id), - inArray(posts.repostOfId, postIds), + inArray(posts.repostOfId, localPostIds), eq(posts.isRemoved, false) ), }); - const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId)); - - userPosts = userPosts.map(p => ({ - ...p, - isLiked: likedPostIds.has(p.id), - isReposted: repostedPostIds.has(p.id), - })); + viewerReposts.forEach((repost) => { + if (repost.repostOfId) { + repostedPostIds.add(repost.repostOfId); + } + }); } + + if (swarmTargets.length > 0) { + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; + const likedIds = await getViewerSwarmLikedPostIds( + swarmTargets.map((post) => ({ + id: post.id, + nodeDomain: post.nodeDomain, + originalPostId: post.originalPostId, + })), + viewer.handle, + nodeDomain + ); + likedIds.forEach((id) => likedPostIds.add(id)); + + const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts'); + const repostedIds = await getViewerSwarmRepostedPostIds(swarmTargets, viewer.id); + repostedIds.forEach((id) => repostedPostIds.add(id)); + } + + userPosts = applyInteractionFlags( + userPosts as FeedPostWithChildren[], + likedPostIds, + repostedPostIds + ) as any; } } catch (error) { console.error('Error populating interaction flags:', error); diff --git a/src/app/api/users/[handle]/replies/route.ts b/src/app/api/users/[handle]/replies/route.ts new file mode 100644 index 0000000..65e8c19 --- /dev/null +++ b/src/app/api/users/[handle]/replies/route.ts @@ -0,0 +1,197 @@ +import { NextResponse } from 'next/server'; +import { db, likes, posts, users } from '@/db'; +import { and, desc, eq, inArray, lt, not, or, isNotNull } from 'drizzle-orm'; +import { discoverNode } from '@/lib/swarm/discovery'; +import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts'; +import { isSwarmNode } from '@/lib/swarm/interactions'; +import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts'; + +const embeddedPostRelations = { + author: true, + bot: true, + media: true, + replyTo: { + with: { + author: true, + bot: true, + media: true, + }, + }, +} as const; + +const replyRelations = { + ...embeddedPostRelations, + repostOf: { + with: embeddedPostRelations, + }, +} as const; + +type RouteContext = { params: Promise<{ handle: string }> }; + +export async function GET(request: Request, context: RouteContext) { + try { + const { handle } = await context.params; + const cleanHandle = handle.toLowerCase().replace(/^@/, ''); + const { searchParams } = new URL(request.url); + const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50); + const cursor = searchParams.get('cursor'); + const remote = parseRemoteHandle(handle); + + const fetchRemoteReplies = async () => { + if (!remote) { + return NextResponse.json({ posts: [], nextCursor: null }); + } + + const baseUrl = getRemoteBaseUrl(remote.domain); + const res = await fetch(`${baseUrl}/api/users/${remote.handle}/replies?limit=${limit}`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + + if (!res.ok) { + return NextResponse.json({ posts: [], nextCursor: null }); + } + + const data = await res.json(); + const { getSession } = await import('@/lib/auth'); + const session = await getSession(); + const viewer = session?.user; + const mappedPosts = (data.posts || []).map((post: any) => mapRemoteProfilePost(post, remote.domain)); + const repostedIds = viewer + ? await getViewerSwarmRepostedPostIds( + mappedPosts.map((post: any) => ({ + id: post.id, + nodeDomain: remote.domain, + originalPostId: post.originalPostId || post.id.split(':').pop(), + })), + viewer.id + ) + : new Set<string>(); + return NextResponse.json({ + posts: mappedPosts.map((post: any) => ({ + ...post, + isReposted: repostedIds.has(post.id), + })), + nextCursor: null, + }); + }; + + if (!db) { + if (!remote) { + return NextResponse.json({ posts: [], nextCursor: null }); + } + + let swarm = await isSwarmNode(remote.domain); + if (!swarm) { + const discovery = await discoverNode(remote.domain); + swarm = discovery.success; + } + + if (!swarm) { + return NextResponse.json({ posts: [], nextCursor: null }); + } + + return await fetchRemoteReplies(); + } + + const user = await db.query.users.findFirst({ + where: eq(users.handle, cleanHandle), + }); + const isRemotePlaceholder = user && cleanHandle.includes('@'); + + if (!user || isRemotePlaceholder) { + if (!remote) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + let swarm = await isSwarmNode(remote.domain); + if (!swarm) { + const discovery = await discoverNode(remote.domain); + swarm = discovery.success; + } + + if (!swarm) { + return NextResponse.json({ posts: [], nextCursor: null }); + } + + return await fetchRemoteReplies(); + } + + if (user.isSuspended) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + let whereConditions = and( + eq(posts.userId, user.id), + eq(posts.isRemoved, false), + or(isNotNull(posts.replyToId), isNotNull(posts.swarmReplyToId)), + ); + + if (cursor) { + const cursorPost = await db.query.posts.findFirst({ + where: eq(posts.id, cursor), + }); + if (cursorPost) { + whereConditions = and( + eq(posts.userId, user.id), + eq(posts.isRemoved, false), + or(isNotNull(posts.replyToId), isNotNull(posts.swarmReplyToId)), + lt(posts.createdAt, cursorPost.createdAt), + ); + } + } + + let replyPosts: any[] = await db.query.posts.findMany({ + where: whereConditions, + with: replyRelations, + orderBy: [desc(posts.createdAt)], + limit, + }); + + try { + const { getSession } = await import('@/lib/auth'); + const session = await getSession(); + + if (session?.user && replyPosts.length > 0) { + const viewer = session.user; + const postIds = replyPosts.map((post) => post.id).filter(Boolean); + + const viewerLikes = postIds.length > 0 + ? await db.query.likes.findMany({ + where: and( + eq(likes.userId, viewer.id), + inArray(likes.postId, postIds), + ), + }) + : []; + const likedPostIds = new Set(viewerLikes.map((like) => like.postId)); + + const viewerReposts = postIds.length > 0 + ? await db.query.posts.findMany({ + where: and( + eq(posts.userId, viewer.id), + inArray(posts.repostOfId, postIds), + eq(posts.isRemoved, false), + ), + }) + : []; + const repostedPostIds = new Set(viewerReposts.map((post) => post.repostOfId)); + + replyPosts = replyPosts.map((post) => ({ + ...post, + isLiked: likedPostIds.has(post.id), + isReposted: repostedPostIds.has(post.id), + })); + } + } catch { + } + + return NextResponse.json({ + posts: replyPosts, + nextCursor: replyPosts.length === limit ? replyPosts[replyPosts.length - 1]?.id : null, + }); + } catch (error) { + console.error('Get user replies error:', error); + return NextResponse.json({ error: 'Failed to get replies' }, { status: 500 }); + } +} diff --git a/src/app/api/users/[handle]/route.ts b/src/app/api/users/[handle]/route.ts index 5221ce0..f62ad55 100644 --- a/src/app/api/users/[handle]/route.ts +++ b/src/app/api/users/[handle]/route.ts @@ -51,6 +51,15 @@ export async function GET(request: Request, context: RouteContext) { const profileData = await fetchSwarmUserProfile(remoteHandle, remoteDomain, 0); if (profileData?.profile) { const profile = profileData.profile; + const rawBotOwnerHandle = profile.botOwnerHandle?.toLowerCase().replace(/^@/, '') || null; + const normalizedBotOwnerHandle = rawBotOwnerHandle + ? rawBotOwnerHandle.includes('@') + ? rawBotOwnerHandle + : `${rawBotOwnerHandle}@${remoteDomain}` + : null; + const botOwnerLocalHandle = rawBotOwnerHandle + ? rawBotOwnerHandle.split('@')[0] + : null; // CACHE: Upsert the remote user into our local database const { upsertRemoteUser } = await import('@/lib/swarm/user-cache'); @@ -80,6 +89,12 @@ export async function GET(request: Request, context: RouteContext) { isSwarm: true, nodeDomain: remoteDomain, isBot: profile.isBot || false, + botOwner: normalizedBotOwnerHandle && botOwnerLocalHandle ? { + id: `swarm:${remoteDomain}:${botOwnerLocalHandle}`, + handle: normalizedBotOwnerHandle, + displayName: botOwnerLocalHandle, + avatarUrl: null, + } : null, did: profile.did, } }); diff --git a/src/app/bots/[id]/edit/page.tsx b/src/app/bots/[id]/edit/page.tsx index 3ddc27b..89e96e1 100644 --- a/src/app/bots/[id]/edit/page.tsx +++ b/src/app/bots/[id]/edit/page.tsx @@ -59,6 +59,7 @@ export default function EditBotPage() { systemPrompt: '', llmProvider: 'openai', llmModel: 'gpt-4', + llmEndpoint: '', llmApiKey: '', autonomousMode: false, @@ -137,6 +138,7 @@ export default function EditBotPage() { systemPrompt: personalityConfig.systemPrompt || '', llmProvider: bot.llmProvider || 'openai', llmModel: bot.llmModel || 'gpt-4', + llmEndpoint: bot.llmEndpoint || '', llmApiKey: '', // Don't pre-fill API key for security autonomousMode: bot.autonomousMode || false, postingFrequency, @@ -288,6 +290,7 @@ export default function EditBotPage() { }, llmProvider: formData.llmProvider, llmModel: formData.llmModel, + llmEndpoint: formData.llmProvider === 'custom' ? formData.llmEndpoint : null, autonomousMode: formData.autonomousMode, schedule: formData.autonomousMode ? { type: 'interval', @@ -482,9 +485,29 @@ export default function EditBotPage() { <option value="openai">OpenAI</option> <option value="anthropic">Anthropic</option> <option value="openrouter">OpenRouter</option> + <option value="custom">Custom (OpenAI Compatible)</option> </select> </div> + {formData.llmProvider === 'custom' && ( + <div> + <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> + OpenAI-Compatible Endpoint + </label> + <input + type="url" + value={formData.llmEndpoint} + onChange={(e) => setFormData({ ...formData, llmEndpoint: e.target.value })} + className="input" + placeholder="https://api.example.com/v1/chat/completions" + required + /> + <p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}> + Enter the full chat completions endpoint for a public OpenAI-compatible API. + </p> + </div> + )} + <div> <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> Model @@ -850,22 +873,23 @@ export default function EditBotPage() { </p> </div> - <div> - <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> - Posting Frequency - </label> - <select - value={formData.postingFrequency} - onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })} - className="input" - disabled={!formData.autonomousMode} - > - <option value="every_2_hours">Every 2 Hours</option> - <option value="every_4_hours">Every 4 Hours</option> - <option value="every_6_hours">Every 6 Hours</option> - <option value="daily">Once Daily</option> - </select> - </div> + {formData.autonomousMode && ( + <div> + <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> + Posting Frequency + </label> + <select + value={formData.postingFrequency} + onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })} + className="input" + > + <option value="every_2_hours">Every 2 Hours</option> + <option value="every_4_hours">Every 4 Hours</option> + <option value="every_6_hours">Every 6 Hours</option> + <option value="daily">Once Daily</option> + </select> + </div> + )} diff --git a/src/app/bots/new/page.tsx b/src/app/bots/new/page.tsx index 18a5ba3..d8ef038 100644 --- a/src/app/bots/new/page.tsx +++ b/src/app/bots/new/page.tsx @@ -52,6 +52,7 @@ export default function NewBotPage() { systemPrompt: '', llmProvider: 'openai', llmModel: 'gpt-4', + llmEndpoint: '', llmApiKey: '', autonomousMode: false, postingFrequency: 'every_4_hours', @@ -88,6 +89,7 @@ export default function NewBotPage() { ...prev, llmProvider: lastBot.llmProvider || 'openai', llmModel: lastBot.llmModel || 'gpt-4', + llmEndpoint: lastBot.llmEndpoint || '', })); } } @@ -237,6 +239,7 @@ export default function NewBotPage() { }, llmProvider: formData.llmProvider, llmModel: formData.llmModel, + llmEndpoint: formData.llmProvider === 'custom' ? formData.llmEndpoint : undefined, llmApiKey: formData.llmApiKey, autonomousMode: formData.autonomousMode, schedule: formData.autonomousMode ? { @@ -430,9 +433,28 @@ export default function NewBotPage() { <option value="openai">OpenAI</option> <option value="anthropic">Anthropic</option> <option value="openrouter">OpenRouter</option> + <option value="custom">Custom (OpenAI Compatible)</option> </select> </div> + {formData.llmProvider === 'custom' && ( + <div> + <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> + OpenAI-Compatible Endpoint + </label> + <input + type="url" + value={formData.llmEndpoint} + onChange={(e) => setFormData({ ...formData, llmEndpoint: e.target.value })} + className="input" + placeholder="https://api.example.com/v1/chat/completions" + /> + <p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}> + Enter the full chat completions endpoint for a public OpenAI-compatible API. + </p> + </div> + )} + <div> <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> Model @@ -445,7 +467,7 @@ export default function NewBotPage() { placeholder="gpt-4" /> <p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}> - e.g., gpt-4, claude-3-opus, etc. + e.g., gpt-4, claude-3-opus, mistral-large, etc. </p> </div> @@ -782,22 +804,23 @@ export default function NewBotPage() { </p> </div> - <div> - <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> - Posting Frequency - </label> - <select - value={formData.postingFrequency} - onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })} - className="input" - disabled={!formData.autonomousMode} - > - <option value="every_2_hours">Every 2 Hours</option> - <option value="every_4_hours">Every 4 Hours</option> - <option value="every_6_hours">Every 6 Hours</option> - <option value="daily">Once Daily</option> - </select> - </div> + {formData.autonomousMode && ( + <div> + <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> + Posting Frequency + </label> + <select + value={formData.postingFrequency} + onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })} + className="input" + > + <option value="every_2_hours">Every 2 Hours</option> + <option value="every_4_hours">Every 4 Hours</option> + <option value="every_6_hours">Every 6 Hours</option> + <option value="daily">Once Daily</option> + </select> + </div> + )} @@ -895,7 +918,7 @@ export default function NewBotPage() { className="btn btn-primary" disabled={ (step === 'identity' && (!formData.name || !formData.handle)) || - (step === 'personality' && (!formData.systemPrompt || !formData.llmApiKey)) + (step === 'personality' && (!formData.systemPrompt || !formData.llmModel || !formData.llmApiKey || (formData.llmProvider === 'custom' && !formData.llmEndpoint))) } > Next diff --git a/src/app/globals.css b/src/app/globals.css index a0bfd64..86741bb 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -246,10 +246,18 @@ a.btn-primary:visited { flex-shrink: 0; border-right: 1px solid var(--border); padding: 16px; + display: flex; + flex-direction: column; position: sticky; top: 0; height: 100vh; overflow-y: auto; + z-index: 20; +} + +.sidebar nav { + display: flex; + flex-direction: column; } .main { @@ -258,6 +266,8 @@ a.btn-primary:visited { min-width: 0; min-height: 100vh; border-right: 1px solid var(--border); + position: relative; + z-index: 1; } .layout.hide-right-sidebar .main { @@ -320,6 +330,22 @@ a.btn-primary:visited { padding-top: 8px; } +.post.embedded { + margin-top: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--background); +} + +.post.embedded:hover { + background: var(--background-secondary); +} + +.post.embedded .post-link-overlay { + border-radius: inherit; +} + /* Post */ .post { padding: 16px; @@ -359,6 +385,34 @@ a.btn-primary:visited { word-wrap: break-word; } +.repost-event-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + color: var(--accent); + font-size: 13px; +} + +.repost-event-icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.repost-event-text a { + color: inherit; + font-weight: 600; +} + +.repost-event-copy { + color: var(--foreground-secondary); +} + +.repost-embed { + margin-top: 4px; +} + .post-reasons { display: flex; flex-wrap: wrap; @@ -2402,6 +2456,54 @@ a.btn-primary:visited { border-bottom: 1px solid var(--border); } +.link-preview-video video { + width: 100%; + max-height: 480px; + display: block; + background: #000; + border-bottom: 1px solid var(--border); +} + +.link-preview-gallery { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2px; + background: var(--border); +} + +.link-preview-gallery.compact { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.link-preview-gallery-item { + position: relative; + background: var(--background-tertiary); + min-height: 140px; +} + +.link-preview-gallery.compact .link-preview-gallery-item { + min-height: 80px; +} + +.link-preview-gallery-item img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.link-preview-gallery-more { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); + color: #fff; + font-size: 20px; + font-weight: 700; +} + .link-preview-info { padding: 12px; } @@ -2455,22 +2557,43 @@ a.btn-primary:visited { .link-preview-card.mini { display: flex; - max-height: 80px; + max-height: none; } .link-preview-card.mini .link-preview-image { width: 80px; min-width: 80px; height: 80px; + overflow: hidden; } -.link-preview-card.mini .link-preview-image img { +.link-preview-card.mini .link-preview-image img, +.link-preview-card.mini .link-preview-image video { height: 80px; + width: 80px; object-fit: cover; border-bottom: none; border-right: 1px solid var(--border); } +.link-preview-card.mini .link-preview-video { + width: 120px; + min-width: 120px; +} + +.link-preview-card.mini .link-preview-video video { + height: 100%; + min-height: 80px; + border-bottom: none; + border-right: 1px solid var(--border); +} + +.link-preview-card.mini .link-preview-gallery { + width: 120px; + min-width: 120px; + border-right: 1px solid var(--border); +} + .link-preview-card.mini .link-preview-info { padding: 8px 12px; display: flex; @@ -2589,4 +2712,4 @@ a.btn-primary:visited { .layout.hide-right-sidebar .main { max-width: none; border-right: none; -} \ No newline at end of file +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 5423b0e..e81774d 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import Image from 'next/image'; -import { TriangleAlert } from 'lucide-react'; +import { TriangleAlert, X } from 'lucide-react'; import { decryptPrivateKey } from '@/lib/crypto/private-key-client'; import { keyStore, importPrivateKey } from '@/lib/crypto/user-signing'; import { useAuth } from '@/lib/contexts/AuthContext'; @@ -24,7 +24,13 @@ declare global { } } -export default function LoginPage() { +interface AuthScreenProps { + modal?: boolean; + onClose?: () => void; + onSuccess?: () => void; +} + +export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProps) { const router = useRouter(); const [mode, setMode] = useState<'login' | 'register' | 'import'>('login'); const [email, setEmail] = useState(''); @@ -204,7 +210,12 @@ export default function LoginPage() { setImportSuccess(data.message); // Soft navigation to preserve AuthContext/KeyStore state setTimeout(() => { - router.push('/'); + router.refresh(); + if (onSuccess) { + onSuccess(); + } else { + router.push('/'); + } }, 2000); } catch (err) { @@ -319,7 +330,12 @@ export default function LoginPage() { } // Soft navigation to preserve AuthContext/KeyStore state - router.push('/'); + router.refresh(); + if (onSuccess) { + onSuccess(); + } else { + router.push('/'); + } } catch (err) { setError(err instanceof Error ? err.message : 'An error occurred'); // Reset Turnstile on error @@ -332,15 +348,17 @@ export default function LoginPage() { } }; - return ( + const content = ( <div style={{ - minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', - padding: '24px', + padding: modal ? '0' : '24px', }}> - <div style={{ width: '100%', maxWidth: mode === 'register' ? '680px' : '400px' }}> + <div style={{ + width: '100%', + maxWidth: mode === 'register' ? '680px' : '400px', + }}> {/* Logo */} <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '32px', minHeight: '120px' }}> {nodeInfoLoaded && ( @@ -984,10 +1002,86 @@ export default function LoginPage() { </form> )} - <p style={{ textAlign: 'center', marginTop: '24px', color: 'var(--foreground-tertiary)', fontSize: '14px' }}> - <Link href="/">โ† Back to home</Link> - </p> + {!modal && ( + <p style={{ textAlign: 'center', marginTop: '24px', color: 'var(--foreground-tertiary)', fontSize: '14px' }}> + <Link href="/">โ† Back to home</Link> + </p> + )} </div> </div> ); + + if (modal) { + return ( + <div + style={{ + position: 'fixed', + inset: 0, + background: 'rgba(0, 0, 0, 0.72)', + backdropFilter: 'blur(10px)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '24px', + zIndex: 120, + }} + onClick={onClose} + > + <div + style={{ + position: 'relative', + width: 'min(760px, 100%)', + }} + onClick={(event) => event.stopPropagation()} + > + {onClose && ( + <button + onClick={onClose} + aria-label="Close" + style={{ + position: 'absolute', + top: '18px', + right: '18px', + width: '44px', + height: '44px', + borderRadius: '999px', + border: '1px solid var(--border)', + background: 'rgba(0, 0, 0, 0.78)', + color: 'var(--foreground)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + zIndex: 3, + }} + > + <X size={20} /> + </button> + )} + <div + className="card" + style={{ + maxHeight: 'calc(100vh - 48px)', + overflowY: 'auto', + padding: '28px', + background: 'rgba(10, 10, 10, 0.98)', + boxShadow: '0 30px 90px rgba(0, 0, 0, 0.55)', + }} + > + {content} + </div> + </div> + </div> + ); + } + + return ( + <div style={{ minHeight: '100vh' }}> + {content} + </div> + ); +} + +export default function LoginPage() { + return <AuthScreen />; } diff --git a/src/app/moderation/page.tsx b/src/app/moderation/page.tsx index 6bb9f99..6ad6ee4 100644 --- a/src/app/moderation/page.tsx +++ b/src/app/moderation/page.tsx @@ -45,6 +45,20 @@ type Report = { target?: AdminPost | AdminUser | null; }; +type AdminNode = { + id: string; + domain: string; + name?: string | null; + description?: string | null; + isActive: boolean; + isBlocked: boolean; + blockReason?: string | null; + blockedAt?: string | null; + lastSeenAt?: string | null; + trustScore?: number | null; + isNsfw?: boolean; +}; + const formatDate = (value: string) => { const date = new Date(value); return date.toLocaleString(); @@ -52,12 +66,15 @@ const formatDate = (value: string) => { export default function ModerationPage() { const [isAdmin, setIsAdmin] = useState<boolean | null>(null); - const [tab, setTab] = useState<'reports' | 'posts' | 'users'>('reports'); + const [tab, setTab] = useState<'reports' | 'posts' | 'users' | 'nodes'>('reports'); const [reports, setReports] = useState<Report[]>([]); const [posts, setPosts] = useState<AdminPost[]>([]); const [users, setUsers] = useState<AdminUser[]>([]); + const [nodes, setNodes] = useState<AdminNode[]>([]); const [loading, setLoading] = useState(false); const [reportStatus, setReportStatus] = useState<'open' | 'resolved' | 'all'>('open'); + const [nodeDomain, setNodeDomain] = useState(''); + const [nodeReason, setNodeReason] = useState(''); useEffect(() => { fetch('/api/admin/me') @@ -105,11 +122,25 @@ export default function ModerationPage() { } }; + const loadNodes = async () => { + setLoading(true); + try { + const res = await fetch('/api/admin/nodes'); + const data = await res.json(); + setNodes(data.nodes || []); + } catch { + setNodes([]); + } finally { + setLoading(false); + } + }; + useEffect(() => { if (!isAdmin) return; if (tab === 'reports') loadReports(); if (tab === 'posts') loadPosts(); if (tab === 'users') loadUsers(); + if (tab === 'nodes') loadNodes(); }, [tab, isAdmin, reportStatus]); const handleReportResolve = async (id: string, status: 'open' | 'resolved') => { @@ -147,6 +178,17 @@ export default function ModerationPage() { loadUsers(); }; + const handleNodeAction = async (action: 'block' | 'unblock', domain: string, reason?: string) => { + await fetch('/api/admin/nodes', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, domain, reason }), + }); + setNodeDomain(''); + setNodeReason(''); + loadNodes(); + }; + const reportCounts = useMemo(() => { return { open: reports.filter((r) => r.status === 'open').length, @@ -209,6 +251,12 @@ export default function ModerationPage() { > Users </button> + <button + className={`btn btn-sm ${tab === 'nodes' ? 'btn-primary' : 'btn-ghost'}`} + onClick={() => setTab('nodes')} + > + Nodes + </button> </div> {tab === 'reports' && ( @@ -470,6 +518,114 @@ export default function ModerationPage() { )} </div> )} + {tab === 'nodes' && ( + <div style={{ padding: '16px' }}> + <div className="card" style={{ padding: '16px', marginBottom: '16px' }}> + <h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px' }}>Block node</h2> + <p style={{ color: 'var(--foreground-secondary)', marginBottom: '12px' }}> + Blocked nodes cannot deliver swarm interactions, appear in swarm feeds, or exchange chat with this node. + </p> + <div style={{ display: 'grid', gap: '12px' }}> + <input + value={nodeDomain} + onChange={(e) => setNodeDomain(e.target.value)} + placeholder="node.example.com" + className="input" + /> + <textarea + value={nodeReason} + onChange={(e) => setNodeReason(e.target.value)} + placeholder="Reason for blocking this node (optional)" + className="input" + style={{ minHeight: '88px', resize: 'vertical' }} + /> + <div> + <button + className="btn btn-primary btn-sm" + onClick={() => handleNodeAction('block', nodeDomain, nodeReason)} + disabled={!nodeDomain.trim()} + > + Block node + </button> + </div> + </div> + </div> + + {loading ? ( + <div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>Loading nodes...</div> + ) : nodes.length === 0 ? ( + <div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>No known nodes.</div> + ) : ( + <div style={{ display: 'grid', gap: '12px' }}> + {nodes.map((node) => ( + <div key={node.id} className="card" style={{ padding: '16px' }}> + <div style={{ display: 'flex', justifyContent: 'space-between', gap: '16px', alignItems: 'flex-start' }}> + <div style={{ flex: 1, minWidth: 0 }}> + <div style={{ display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '8px', flexWrap: 'wrap' }}> + <span style={{ + fontSize: '11px', + padding: '2px 8px', + borderRadius: '4px', + background: node.isBlocked ? 'rgba(239, 68, 68, 0.1)' : 'rgba(34, 197, 94, 0.1)', + color: node.isBlocked ? 'rgb(239, 68, 68)' : 'rgb(34, 197, 94)', + fontWeight: 600, + textTransform: 'uppercase', + }}> + {node.isBlocked ? 'blocked' : 'allowed'} + </span> + {!node.isBlocked && !node.isActive && ( + <span style={{ fontSize: '11px', color: 'var(--foreground-tertiary)' }}> + inactive + </span> + )} + {node.isNsfw && ( + <span style={{ fontSize: '11px', color: 'rgb(245, 158, 11)' }}> + NSFW + </span> + )} + </div> + <div style={{ fontWeight: 600, marginBottom: '4px' }}> + {node.name || node.domain} + </div> + <div style={{ color: 'var(--foreground-secondary)', marginBottom: '8px' }}> + {node.domain} + </div> + {node.description && ( + <div style={{ fontSize: '14px', color: 'var(--foreground-secondary)', marginBottom: '8px' }}> + {node.description} + </div> + )} + <div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}> + {node.blockedAt ? `Blocked ${formatDate(node.blockedAt)}` : node.lastSeenAt ? `Last seen ${formatDate(node.lastSeenAt)}` : 'Never seen'} + {typeof node.trustScore === 'number' && <span> โ€ข Trust {node.trustScore}</span>} + </div> + {node.blockReason && ( + <div style={{ fontSize: '13px', color: 'var(--foreground-secondary)', marginTop: '6px' }}> + Reason: {node.blockReason} + </div> + )} + </div> + <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}> + {node.isBlocked ? ( + <button className="btn btn-ghost btn-sm" onClick={() => handleNodeAction('unblock', node.domain)}> + Unblock + </button> + ) : ( + <button + className="btn btn-primary btn-sm" + onClick={() => handleNodeAction('block', node.domain, window.prompt('Reason (optional):') || '')} + > + Block + </button> + )} + </div> + </div> + </div> + ))} + </div> + )} + </div> + )} </> ); } diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 0b401f3..7ed071d 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -13,6 +13,14 @@ interface NotificationActor { avatarUrl: string | null; } +interface NotificationTarget { + handle: string; + displayName: string | null; + avatarUrl: string | null; + nodeDomain?: string | null; + isBot?: boolean | null; +} + interface NotificationPost { id: string; content: string; @@ -24,6 +32,7 @@ interface Notification { createdAt: string; readAt: string | null; actor: NotificationActor | null; + target: NotificationTarget | null; post: NotificationPost | null; } @@ -77,17 +86,28 @@ export default function NotificationsPage() { }; const getNotificationText = (notification: Notification) => { + const targetName = notification.target?.displayName || notification.target?.handle; switch (notification.type) { case 'follow': - return 'followed you'; + return notification.target?.isBot && targetName + ? `followed your bot ${targetName}` + : 'followed you'; case 'like': - return 'liked your post'; + return notification.target?.isBot && targetName + ? `liked a post from ${targetName}` + : 'liked your post'; case 'repost': - return 'reposted your post'; + return notification.target?.isBot && targetName + ? `reposted a post from ${targetName}` + : 'reposted your post'; case 'mention': - return 'mentioned you'; + return notification.target?.isBot && targetName + ? `mentioned your bot ${targetName}` + : 'mentioned you'; case 'reply': - return 'replied to your post'; + return notification.target?.isBot && targetName + ? `replied to a post from ${targetName}` + : 'replied to your post'; default: return 'interacted with you'; } diff --git a/src/app/u/[handle]/page.tsx b/src/app/u/[handle]/page.tsx index e6b805c..0f693f9 100644 --- a/src/app/u/[handle]/page.tsx +++ b/src/app/u/[handle]/page.tsx @@ -175,7 +175,7 @@ export default function ProfilePage() { if (!repliesCursor || repliesLoadingMore || !user) return; setRepliesLoadingMore(true); try { - const res = await fetch(`/api/posts?type=replies&userId=${user.id}&cursor=${repliesCursor}`); + const res = await fetch(`/api/users/${handle}/replies?cursor=${repliesCursor}`); const data = await res.json(); setRepliesPosts(prev => [...prev, ...(data.posts || [])]); setRepliesCursor(data.nextCursor || null); @@ -266,7 +266,10 @@ export default function ProfilePage() { }, [user, currentUser]); useEffect(() => { - if (!currentUser || !user || currentUser.handle === user.handle) { + const ownerHandle = user?.botOwner?.handle?.replace(/^@/, '').split('@')[0]; + const isOwnedBot = Boolean(user?.isBot && currentUser && ownerHandle === currentUser.handle); + + if (!currentUser || !user || currentUser.handle === user.handle || isOwnedBot) { setIsFollowing(false); setIsBlocked(false); return; @@ -314,7 +317,7 @@ export default function ProfilePage() { if (activeTab === 'replies' && user) { setRepliesLoading(true); setRepliesCursor(null); - fetch(`/api/posts?type=replies&userId=${user.id}`) + fetch(`/api/users/${handle}/replies`) .then(res => res.json()) .then(data => { setRepliesPosts(data.posts || []); @@ -323,7 +326,13 @@ export default function ProfilePage() { .catch(() => setRepliesPosts([])) .finally(() => setRepliesLoading(false)); } - }, [activeTab, handle, user]); + }, [activeTab, handle, user]); + + useEffect(() => { + if (user?.isBot && activeTab === 'following') { + setActiveTab('posts'); + } + }, [user?.isBot, activeTab]); const handleFollow = async () => { if (!currentUser) return; @@ -447,6 +456,19 @@ export default function ProfilePage() { } const isOwnProfile = currentUser?.handle === user.handle; + const botOwner = user.botOwner as BotOwner | null | undefined; + const botOwnerLocalHandle = botOwner?.handle?.replace(/^@/, '').split('@')[0] || null; + const isOwnedBotProfile = Boolean( + user.isBot && + currentUser && + botOwner && + (botOwner.id === currentUser.id || botOwnerLocalHandle === currentUser.handle) + ); + const showFollowingUi = !user.isBot; + const visibleTabs = (user.isBot + ? ['posts', 'replies', 'followers'] as const + : ['posts', 'replies', 'likes', 'followers', 'following'] as const + ); return ( <div style={{ maxWidth: '600px', margin: '0 auto', minHeight: '100vh' }}> @@ -518,7 +540,8 @@ export default function ProfilePage() { {/* Banner */} <div style={{ - height: '150px', + width: '100%', + aspectRatio: '3 / 1', background: (isEditing ? profileForm.headerUrl : user.headerUrl) ? `url(${isEditing ? profileForm.headerUrl : user.headerUrl}) center/cover` : 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)', @@ -553,7 +576,7 @@ export default function ProfilePage() { </div> <div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}> - {!isOwnProfile && currentUser && ( + {!isOwnProfile && !isOwnedBotProfile && currentUser && ( <> {!isBlocked && ( <button @@ -713,18 +736,20 @@ export default function ProfilePage() { <strong>{user.followersCount}</strong>{' '} <span style={{ color: 'var(--foreground-tertiary)' }}>Followers</span> </button> - <button - onClick={() => setActiveTab('following')} - style={{ - background: 'none', - border: 'none', - color: 'var(--foreground)', - cursor: 'pointer', - }} - > - <strong>{user.followingCount}</strong>{' '} - <span style={{ color: 'var(--foreground-tertiary)' }}>Following</span> - </button> + {showFollowingUi && ( + <button + onClick={() => setActiveTab('following')} + style={{ + background: 'none', + border: 'none', + color: 'var(--foreground)', + cursor: 'pointer', + }} + > + <strong>{user.followingCount}</strong>{' '} + <span style={{ color: 'var(--foreground-tertiary)' }}>Following</span> + </button> + )} </div> </div> </div> @@ -807,10 +832,7 @@ export default function ProfilePage() { {/* Tabs */} <div style={{ display: 'flex', borderTop: '1px solid var(--border)' }}> - {(user?.isBot - ? ['posts', 'replies', 'followers', 'following'] as const - : ['posts', 'replies', 'likes', 'followers', 'following'] as const - ).map(tab => ( + {visibleTabs.map(tab => ( <button key={tab} onClick={() => setActiveTab(tab)} diff --git a/src/components/Compose.tsx b/src/components/Compose.tsx index 615ffc3..1e19c93 100644 --- a/src/components/Compose.tsx +++ b/src/components/Compose.tsx @@ -36,6 +36,13 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What const [isNsfwNode, setIsNsfwNode] = useState(false); const maxLength = 600; const remaining = maxLength - content.length; + const previewMedia = linkPreview?.media?.length + ? linkPreview.media + : linkPreview?.image + ? [{ url: linkPreview.image }] + : []; + const previewImage = previewMedia[0]?.url || linkPreview?.image || null; + const isEmbeddedVideo = Boolean(linkPreview?.url?.match(/(youtube\.com|youtu\.be|vimeo\.com)/)); // Check if user can post NSFW content and if node is NSFW useEffect(() => { @@ -210,11 +217,32 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What x </button> <VideoEmbed url={linkPreview.url} /> - {!linkPreview.url.match(/(youtube\.com|youtu\.be|vimeo\.com)/) && ( + {!isEmbeddedVideo && ( <div className="link-preview-card mini"> - {linkPreview.image && ( + {linkPreview.type === 'video' && linkPreview.videoUrl ? ( <div className="link-preview-image"> - <img src={linkPreview.image} alt="" /> + <video + src={linkPreview.videoUrl} + poster={previewImage || undefined} + muted + playsInline + preload="metadata" + /> + </div> + ) : linkPreview.type === 'gallery' && previewMedia.length > 0 ? ( + <div className="link-preview-gallery compact"> + {previewMedia.slice(0, 3).map((item: { url: string }, index: number) => ( + <div className="link-preview-gallery-item" key={`${item.url}-${index}`}> + <img src={item.url} alt="" /> + {index === Math.min(previewMedia.length, 3) - 1 && previewMedia.length > 3 && ( + <span className="link-preview-gallery-more">+{previewMedia.length - 3}</span> + )} + </div> + ))} + </div> + ) : previewImage && ( + <div className="link-preview-image"> + <img src={previewImage} alt="" /> </div> )} <div className="link-preview-info"> diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index d898a1b..d57ea23 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -5,7 +5,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/components/Icons'; import { Bot, MoreHorizontal, UserX, VolumeX, Globe } from 'lucide-react'; -import { Post } from '@/lib/types'; +import { Post, LinkPreviewMediaItem } from '@/lib/types'; import { useAuth } from '@/lib/contexts/AuthContext'; import { useToast } from '@/lib/contexts/ToastContext'; import { VideoEmbed } from '@/components/VideoEmbed'; @@ -13,6 +13,7 @@ import BlurredVideo from '@/components/BlurredVideo'; import { useFormattedHandle } from '@/lib/utils/handle'; import { useDomain } from '@/lib/contexts/ConfigContext'; import { signedAPI } from '@/lib/api/signed-fetch'; +import type { LinkPreviewData } from '@/lib/media/linkPreview'; // Component for link preview image that hides on error function LinkPreviewImage({ src, alt }: { src: string; alt: string }) { @@ -31,6 +32,65 @@ function LinkPreviewImage({ src, alt }: { src: string; alt: string }) { ); } +const EMBED_VIDEO_REGEX = /(youtube\.com|youtu\.be|vimeo\.com)/; + +function isPlaceholderPreview(post: Post): boolean { + if (!post.linkPreviewUrl) { + return false; + } + + try { + const hostname = new URL( + post.linkPreviewUrl.startsWith('http') ? post.linkPreviewUrl : `https://${post.linkPreviewUrl}` + ).hostname.replace(/^www\./, '').toLowerCase(); + const title = post.linkPreviewTitle?.trim().toLowerCase() || ''; + const hasRichData = Boolean( + post.linkPreviewDescription || + post.linkPreviewImage || + post.linkPreviewVideoUrl || + (post.linkPreviewMedia && post.linkPreviewMedia.length > 0) + ); + + if (hasRichData) { + return false; + } + + return ( + !title || + title === 'reddit' || + title === hostname || + title === `www.${hostname}` + ); + } catch { + return false; + } +} + +function LinkPreviewGallery({ + media, + alt, + compact = false, +}: { + media: LinkPreviewMediaItem[]; + alt: string; + compact?: boolean; +}) { + const visibleMedia = media.slice(0, compact ? 3 : 4); + + return ( + <div className={`link-preview-gallery ${compact ? 'compact' : ''}`}> + {visibleMedia.map((item, index) => ( + <div className="link-preview-gallery-item" key={`${item.url}-${index}`}> + <img src={item.url} alt={alt} loading="lazy" /> + {index === visibleMedia.length - 1 && media.length > visibleMedia.length && ( + <span className="link-preview-gallery-more">+{media.length - visibleMedia.length}</span> + )} + </div> + ))} + </div> + ); +} + interface PostCardProps { post: Post; onLike?: (id: string, currentLiked: boolean) => void; @@ -41,10 +101,11 @@ interface PostCardProps { isDetail?: boolean; showThread?: boolean; // Show parent post inline as a thread isThreadParent?: boolean; // This post is being shown as a parent in a thread + isEmbedded?: boolean; parentPostAuthorId?: string; // ID of the parent post's author (for allowing deletion of replies) } -export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail, showThread = true, isThreadParent, parentPostAuthorId }: PostCardProps) { +export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail, showThread = true, isThreadParent, isEmbedded = false, parentPostAuthorId }: PostCardProps) { const { user: currentUser, did, handle: currentUserHandle, isIdentityUnlocked } = useAuth(); const { showToast } = useToast(); const router = useRouter(); @@ -57,8 +118,25 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, const [reporting, setReporting] = useState(false); const [deleting, setDeleting] = useState(false); const [showMenu, setShowMenu] = useState(false); + const [hydratedPreview, setHydratedPreview] = useState<LinkPreviewData | null>(null); const domain = useDomain(); const authorHandle = useFormattedHandle(post.author.handle, post.nodeDomain); + const isOwnOrOwnedBotPost = Boolean( + currentUser && ( + currentUser.id === post.author.id || + (post.bot && currentUser.id === post.bot.ownerId) || + (post.author.id.startsWith('swarm:') && ( + post.author.handle === currentUser.handle || + post.author.handle === `${currentUser.handle}@${domain}` + )) + ) + ); + const canDeletePost = Boolean( + currentUser && ( + isOwnOrOwnedBotPost || + (parentPostAuthorId && currentUser.id === parentPostAuthorId) + ) + ); // Sync state if post changes (e.g. after a re-render from parent) useEffect(() => { setLiked(post.isLiked || false); @@ -67,6 +145,47 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, setRepostsCount(post.repostsCount || 0); }, [post.isLiked, post.likesCount, post.isReposted, post.repostsCount, post.id]); + useEffect(() => { + let cancelled = false; + const missingPreviewData = Boolean( + post.linkPreviewUrl && + !post.linkPreviewTitle && + !post.linkPreviewDescription && + !post.linkPreviewImage && + !post.linkPreviewVideoUrl && + (!post.linkPreviewMedia || post.linkPreviewMedia.length === 0) + ); + const placeholderPreviewData = isPlaceholderPreview(post); + + if ((!missingPreviewData && !placeholderPreviewData) || !post.linkPreviewUrl) { + setHydratedPreview(null); + return; + } + + (async () => { + try { + const res = await fetch(`/api/media/preview?url=${encodeURIComponent(post.linkPreviewUrl!)}`); + if (!res.ok) return; + const data = await res.json(); + if (!cancelled) { + setHydratedPreview(data); + } + } catch { + } + })(); + + return () => { + cancelled = true; + }; + }, [ + post.linkPreviewUrl, + post.linkPreviewTitle, + post.linkPreviewDescription, + post.linkPreviewImage, + post.linkPreviewVideoUrl, + post.linkPreviewMedia, + ]); + const formatTime = (dateStr: string | Date) => { const date = new Date(dateStr); @@ -431,11 +550,91 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, : post.swarmReplyToAuthor)?.nodeDomain, } as Post : null); const replyToHandle = effectiveReplyTo?.author?.handle ? useFormattedHandle(effectiveReplyTo.author.handle, effectiveReplyTo.nodeDomain) : ''; + const repostHandle = useFormattedHandle(post.author.handle, post.nodeDomain); + const hasOwnContent = decodeHtmlEntities(post.content).trim().length > 0; + const isRepostEvent = Boolean(post.repostOf); + const effectivePreview = { + url: hydratedPreview?.url || post.linkPreviewUrl || null, + title: hydratedPreview?.title || post.linkPreviewTitle || null, + description: hydratedPreview?.description || post.linkPreviewDescription || null, + image: hydratedPreview?.image || post.linkPreviewImage || null, + type: hydratedPreview?.type || post.linkPreviewType || null, + videoUrl: hydratedPreview?.videoUrl || post.linkPreviewVideoUrl || null, + media: hydratedPreview?.media || post.linkPreviewMedia || null, + }; + const rawPreviewMedia = (() => { + const mediaJson = (post as Post & { linkPreviewMediaJson?: string | null }).linkPreviewMediaJson; + if (!mediaJson) { + return []; + } + try { + const parsed = JSON.parse(mediaJson); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + })(); + const previewMedia = (effectivePreview.media && effectivePreview.media.length > 0) + ? effectivePreview.media + : rawPreviewMedia.length > 0 + ? rawPreviewMedia + : effectivePreview.image + ? [{ url: effectivePreview.image }] + : []; + const previewImage = previewMedia[0]?.url || effectivePreview.image || null; + const isEmbeddedVideo = Boolean(effectivePreview.url && effectivePreview.url.match(EMBED_VIDEO_REGEX)); + const isRichVideoPreview = effectivePreview.type === 'video' && Boolean(effectivePreview.videoUrl); + const isGalleryPreview = effectivePreview.type === 'gallery' && previewMedia.length > 1; + + const renderLinkPreviewCard = (compact = false) => { + if (!effectivePreview.url || isEmbeddedVideo) { + return null; + } + + return ( + <a + href={effectivePreview.url} + target="_blank" + rel="noopener noreferrer" + className={`link-preview-card ${compact ? 'mini' : ''}`} + onClick={(e) => e.stopPropagation()} + > + {isRichVideoPreview && effectivePreview.videoUrl ? ( + <div className="link-preview-video"> + <video + src={effectivePreview.videoUrl} + poster={previewImage || undefined} + controls + playsInline + preload="metadata" + /> + </div> + ) : isGalleryPreview ? ( + <LinkPreviewGallery + media={previewMedia} + alt={effectivePreview.title || 'Link preview'} + compact={compact} + /> + ) : previewImage ? ( + <LinkPreviewImage src={previewImage} alt={effectivePreview.title || ''} /> + ) : null} + <div className="link-preview-info"> + <div className="link-preview-title">{effectivePreview.title ? decodeHtmlEntities(effectivePreview.title) : ''}</div> + {effectivePreview.description && ( + <div className="link-preview-description">{decodeHtmlEntities(effectivePreview.description)}</div> + )} + <div className="link-preview-url"> + {new URL(effectivePreview.url.startsWith('http') ? effectivePreview.url : `https://${effectivePreview.url}`).hostname} + </div> + </div> + </a> + ); + }; // If this is a thread parent being rendered, just render the article if (isThreadParent) { return ( - <article className="post thread-parent"> + <article className={`post thread-parent ${isEmbedded ? 'embedded' : ''}`}> <div className="post-header"> <Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}> <div className="avatar"> @@ -458,6 +657,46 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, ); } + if (isRepostEvent && post.repostOf) { + return ( + <> + <article className={`post repost-event ${isDetail ? 'detail' : ''} ${isEmbedded ? 'embedded' : ''}`}> + <div className="repost-event-header"> + <span className="repost-event-icon" aria-hidden="true"> + <RepeatIcon /> + </span> + <span className="repost-event-text"> + <Link href={`/u/${profileHandle}`} onClick={(e) => e.stopPropagation()}> + {post.author.displayName || post.author.handle} + </Link> + <span className="repost-event-copy"> reposted</span> + <span className="post-time"> {repostHandle} ยท {formatTime(post.createdAt)}</span> + </span> + </div> + + {hasOwnContent && ( + <div className="post-content">{renderContent(post.content, post.linkPreviewUrl ?? undefined)}</div> + )} + + <div className="repost-embed"> + <PostCard + post={post.repostOf} + onLike={onLike} + onRepost={onRepost} + onComment={onComment} + onDelete={onDelete} + onHide={onHide} + isDetail={isDetail} + showThread={false} + isEmbedded={true} + parentPostAuthorId={parentPostAuthorId} + /> + </div> + </article> + </> + ); + } + return ( <> {/* Show parent post as part of thread - only on detail page */} @@ -475,7 +714,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, /> </div> )} - <article className={`post ${isDetail ? 'detail' : ''}`}> + <article className={`post ${isDetail ? 'detail' : ''} ${isEmbedded ? 'embedded' : ''}`}> {!isDetail && <Link href={postUrl} className="post-link-overlay" aria-label="View post" />} <div className="post-header"> @@ -515,7 +754,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, </div> <span className="post-time">{authorHandle} ยท {formatTime(post.createdAt)}</span> </div> - {currentUser && currentUser.id !== post.author.id && ( + {currentUser && !isOwnOrOwnedBotPost && ( <div style={{ position: 'relative', marginLeft: 'auto' }}> <button className="post-menu-btn" @@ -671,28 +910,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, <VideoEmbed url={post.linkPreviewUrl} /> )} - {post.linkPreviewUrl && !post.linkPreviewUrl.match(/(youtube\.com|youtu\.be|vimeo\.com)/) && ( - <a - href={post.linkPreviewUrl} - target="_blank" - rel="noopener noreferrer" - className="link-preview-card" - onClick={(e) => e.stopPropagation()} - > - {post.linkPreviewImage && ( - <LinkPreviewImage src={post.linkPreviewImage} alt={post.linkPreviewTitle || ''} /> - )} - <div className="link-preview-info"> - <div className="link-preview-title">{post.linkPreviewTitle ? decodeHtmlEntities(post.linkPreviewTitle) : ''}</div> - {post.linkPreviewDescription && ( - <div className="link-preview-description">{decodeHtmlEntities(post.linkPreviewDescription)}</div> - )} - <div className="link-preview-url"> - {new URL(post.linkPreviewUrl.startsWith('http') ? post.linkPreviewUrl : `https://${post.linkPreviewUrl}`).hostname} - </div> - </div> - </a> - )} + {renderLinkPreviewCard()} <div className="post-actions"> <button className="post-action" onClick={handleComment}> @@ -707,20 +925,13 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, <HeartIcon filled={liked} /> <span>{likesCount || ''}</span> </button> - <button className="post-action" onClick={handleReport} disabled={reporting}> - <FlagIcon /> - <span>{reporting ? '...' : ''}</span> - </button> - {(currentUser && ( - currentUser.id === post.author.id || - (post.bot && currentUser.id === post.bot.ownerId) || - (parentPostAuthorId && currentUser.id === parentPostAuthorId) || - // Allow deleting own remote posts where handle might be username@node_domain - (post.author.id.startsWith('swarm:') && ( - post.author.handle === currentUser.handle || - post.author.handle === `${currentUser.handle}@${domain}` - )) - )) && ( + {!isOwnOrOwnedBotPost && ( + <button className="post-action" onClick={handleReport} disabled={reporting}> + <FlagIcon /> + <span>{reporting ? '...' : ''}</span> + </button> + )} + {canDeletePost && ( <button className="post-action delete-action" onClick={handleDelete} disabled={deleting} title="Delete post"> <TrashIcon /> <span>{deleting ? '...' : ''}</span> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 8cadb6c..a4faae1 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,24 +1,38 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; import Link from 'next/link'; import Image from 'next/image'; import { usePathname, useRouter } from 'next/navigation'; import { useAuth } from '@/lib/contexts/AuthContext'; import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons'; import { useFormattedHandle } from '@/lib/utils/handle'; -import { LogOut, Settings2 } from 'lucide-react'; +import { Check, ChevronDown, LogOut, Plus, Settings2 } from 'lucide-react'; +import { AuthScreen } from '@/app/login/page'; // import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper +function shortHandle(handle: string) { + const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle; + return `@${cleanHandle.split('@')[0]}`; +} + export function Sidebar() { - const { user, isAdmin, logout } = useAuth(); + const { user, accounts, isAdmin, logout, switchAccount } = useAuth(); const pathname = usePathname(); const router = useRouter(); const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined); const [unreadCount, setUnreadCount] = useState(0); const [unreadChatCount, setUnreadChatCount] = useState(0); const [loggingOut, setLoggingOut] = useState(false); - const formattedHandle = user ? useFormattedHandle(user.handle) : ''; + const [switchingAccountId, setSwitchingAccountId] = useState<string | null>(null); + const [accountMenuOpen, setAccountMenuOpen] = useState(false); + const [showAuthModal, setShowAuthModal] = useState(false); + const accountMenuRef = useRef<HTMLDivElement | null>(null); + const accountTriggerRef = useRef<HTMLButtonElement | null>(null); + const accountPopupRef = useRef<HTMLDivElement | null>(null); + const [accountPopupStyle, setAccountPopupStyle] = useState<React.CSSProperties | null>(null); + const formattedHandle = user ? shortHandle(user.handle) : ''; useEffect(() => { fetch('/api/node') @@ -91,18 +105,101 @@ export function Sidebar() { const isHome = pathname === '/'; const handleLogout = async () => { - if (loggingOut) return; + if (loggingOut || !user) return; setLoggingOut(true); try { - await logout(); - window.location.href = '/explore'; + const isLastAccount = accounts.length <= 1; + await logout(user.id); + setAccountMenuOpen(false); + + if (isLastAccount) { + window.location.href = '/explore'; + } else { + router.refresh(); + } } catch (error) { console.error('Logout failed:', error); setLoggingOut(false); } }; + const handleSwitchAccount = async (userId: string) => { + if (switchingAccountId || userId === user?.id) { + setAccountMenuOpen(false); + return; + } + + setSwitchingAccountId(userId); + try { + await switchAccount(userId); + setAccountMenuOpen(false); + router.refresh(); + } catch (error) { + console.error('Account switch failed:', error); + } finally { + setSwitchingAccountId(null); + } + }; + + const updateAccountPopupPosition = useCallback(() => { + if (!accountTriggerRef.current) return; + + const rect = accountTriggerRef.current.getBoundingClientRect(); + const popupWidth = 320; + const viewportPadding = 16; + const left = Math.min( + rect.left, + window.innerWidth - popupWidth - viewportPadding + ); + const bottom = window.innerHeight - rect.top + 12; + + setAccountPopupStyle({ + position: 'fixed', + left: `${Math.max(viewportPadding, left)}px`, + bottom: `${bottom}px`, + width: `${popupWidth}px`, + maxWidth: `min(${popupWidth}px, calc(100vw - 32px))`, + background: 'rgba(0, 0, 0, 0.96)', + border: '1px solid var(--border)', + borderRadius: '18px', + boxShadow: '0 20px 60px rgba(0, 0, 0, 0.58)', + overflow: 'hidden', + zIndex: 10000, + backdropFilter: 'blur(18px)', + }); + }, []); + + useEffect(() => { + if (!accountMenuOpen) return; + + updateAccountPopupPosition(); + + const handlePointerDown = (event: MouseEvent) => { + const target = event.target as Node; + const clickedTrigger = accountMenuRef.current?.contains(target); + const clickedPopup = accountPopupRef.current?.contains(target); + + if (!clickedTrigger && !clickedPopup) { + setAccountMenuOpen(false); + } + }; + + const handleWindowChange = () => { + updateAccountPopupPosition(); + }; + + window.addEventListener('mousedown', handlePointerDown); + window.addEventListener('resize', handleWindowChange); + window.addEventListener('scroll', handleWindowChange, true); + + return () => { + window.removeEventListener('mousedown', handlePointerDown); + window.removeEventListener('resize', handleWindowChange); + window.removeEventListener('scroll', handleWindowChange, true); + }; + }, [accountMenuOpen, updateAccountPopupPosition]); + return ( <aside className="sidebar"> <Link href={user ? "/" : "/explore"} className="logo" style={{ minHeight: '42px' }}> @@ -196,8 +293,130 @@ export function Sidebar() { )} </nav> {user && ( - <div style={{ marginTop: 'auto', paddingTop: '16px' }} className="sidebar-user-info"> - <div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0, marginBottom: '12px' }}> + <div + ref={accountMenuRef} + style={{ marginTop: 'auto', paddingTop: '16px', position: 'relative' }} + className="sidebar-user-info" + > + {accountMenuOpen && accountPopupStyle && typeof document !== 'undefined' && createPortal( + <div style={{ + ...accountPopupStyle, + }}> + <div ref={accountPopupRef}> + <div style={{ padding: '8px 0' }}> + {accounts.map((account) => { + const isActive = account.id === user.id; + const isSwitching = switchingAccountId === account.id; + + return ( + <button + key={account.id} + onClick={() => void handleSwitchAccount(account.id)} + disabled={isSwitching || loggingOut} + style={{ + width: '100%', + display: 'flex', + alignItems: 'center', + gap: '14px', + padding: '14px 18px', + background: 'transparent', + color: 'var(--foreground)', + border: 'none', + cursor: 'pointer', + textAlign: 'left', + }} + > + <div className="avatar avatar-sm" style={{ flexShrink: 0 }}> + {account.avatarUrl ? ( + <img src={account.avatarUrl} alt={account.displayName || account.handle} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> + ) : ( + (account.displayName?.charAt(0) || account.handle.charAt(0)).toUpperCase() + )} + </div> + <div style={{ minWidth: 0, flex: 1, display: 'grid', gap: '2px' }}> + <div style={{ fontWeight: 700, fontSize: '15px', lineHeight: 1.2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> + {account.displayName || account.handle} + </div> + <div style={{ color: 'var(--foreground-secondary)', fontSize: '13px', lineHeight: 1.2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> + {shortHandle(account.handle)} + </div> + </div> + <div style={{ width: '24px', display: 'flex', justifyContent: 'center', color: 'var(--accent)', flexShrink: 0 }}> + {isActive ? <Check size={18} /> : isSwitching ? <span style={{ fontSize: '12px' }}>...</span> : null} + </div> + </button> + ); + })} + </div> + + <div style={{ borderTop: '1px solid var(--border)', padding: '6px 0' }}> + <button + onClick={() => { + setAccountMenuOpen(false); + setShowAuthModal(true); + }} + style={{ + width: '100%', + display: 'flex', + alignItems: 'center', + gap: '14px', + padding: '14px 18px', + background: 'transparent', + color: 'var(--foreground)', + border: 'none', + cursor: 'pointer', + textAlign: 'left', + fontWeight: 600, + whiteSpace: 'nowrap', + }} + > + <Plus size={18} style={{ flexShrink: 0 }} /> + <span>Add an existing account</span> + </button> + + <button + onClick={handleLogout} + disabled={loggingOut} + style={{ + width: '100%', + display: 'flex', + alignItems: 'center', + gap: '14px', + padding: '14px 18px', + background: 'transparent', + color: 'var(--foreground)', + border: 'none', + cursor: 'pointer', + textAlign: 'left', + fontWeight: 600, + whiteSpace: 'nowrap', + }} + > + <LogOut size={18} style={{ flexShrink: 0 }} /> + <span>{loggingOut ? 'Signing out...' : `Sign out ${formattedHandle}`}</span> + </button> + </div> + </div> + </div>, + document.body + )} + + <button + ref={accountTriggerRef} + onClick={() => setAccountMenuOpen(open => !open)} + className="btn btn-ghost" + style={{ + width: '100%', + display: 'flex', + alignItems: 'center', + gap: '12px', + minWidth: 0, + padding: '10px 12px', + borderRadius: '16px', + border: '1px solid var(--border)', + background: accountMenuOpen ? 'var(--background-secondary)' : 'transparent', + }} + > <div className="avatar avatar-sm" style={{ flexShrink: 0 }}> {user.avatarUrl ? ( <img src={user.avatarUrl} alt={user.displayName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> @@ -205,31 +424,30 @@ export function Sidebar() { (user.displayName?.charAt(0) || user.handle.charAt(0)).toUpperCase() )} </div> - <div style={{ minWidth: 0, flex: 1 }}> + <div style={{ minWidth: 0, flex: 1, textAlign: 'left' }}> <div style={{ fontWeight: 600, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.displayName}</div> <div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{formattedHandle}</div> </div> - </div> - - <button - onClick={handleLogout} - disabled={loggingOut} - className="btn btn-ghost" - style={{ - width: '100%', - justifyContent: 'flex-start', - gap: '12px', - padding: '10px 12px', - fontSize: '14px', - }} - > - <LogOut size={20} /> - <span>{loggingOut ? 'Signing out...' : 'Sign out'}</span> + <ChevronDown size={18} style={{ + transform: accountMenuOpen ? 'rotate(180deg)' : 'rotate(0deg)', + transition: 'transform 0.2s ease', + flexShrink: 0, + }} /> </button> </div> )} {/* Identity Unlock Prompt Modal is now handled in LayoutWrapper */} + {showAuthModal && ( + <AuthScreen + modal + onClose={() => setShowAuthModal(false)} + onSuccess={() => { + setShowAuthModal(false); + router.refresh(); + }} + /> + )} </aside> ); } diff --git a/src/db/schema.ts b/src/db/schema.ts index 004f5dc..01dd357 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -143,6 +143,9 @@ export const posts = pgTable('posts', { linkPreviewTitle: text('link_preview_title'), linkPreviewDescription: text('link_preview_description'), linkPreviewImage: text('link_preview_image'), + linkPreviewType: text('link_preview_type'), + linkPreviewVideoUrl: text('link_preview_video_url'), + linkPreviewMediaJson: text('link_preview_media_json'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }, (table) => [ @@ -301,6 +304,9 @@ export const remotePosts = pgTable('remote_posts', { linkPreviewTitle: text('link_preview_title'), linkPreviewDescription: text('link_preview_description'), linkPreviewImage: text('link_preview_image'), + linkPreviewType: text('link_preview_type'), + linkPreviewVideoUrl: text('link_preview_video_url'), + linkPreviewMediaJson: text('link_preview_media_json'), // Media attachments stored as JSON mediaJson: text('media_json'), // JSON array of {url, altText} // Metadata @@ -374,6 +380,9 @@ export const userSwarmLikes = pgTable('user_swarm_likes', { linkPreviewTitle: text('link_preview_title'), linkPreviewDescription: text('link_preview_description'), linkPreviewImage: text('link_preview_image'), + linkPreviewType: text('link_preview_type'), + linkPreviewVideoUrl: text('link_preview_video_url'), + linkPreviewMediaJson: text('link_preview_media_json'), mediaJson: text('media_json'), likedAt: timestamp('liked_at').defaultNow().notNull(), }, (table) => [ @@ -382,6 +391,38 @@ export const userSwarmLikes = pgTable('user_swarm_likes', { uniqueIndex('user_swarm_likes_unique').on(table.userId, table.nodeDomain, table.originalPostId), ]); +// ============================================ +// USER SWARM REPOSTS (local users reposting remote swarm posts) +// ============================================ + +export const userSwarmReposts = pgTable('user_swarm_reposts', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + nodeDomain: text('node_domain').notNull(), + originalPostId: text('original_post_id').notNull(), + authorHandle: text('author_handle').notNull(), + authorDisplayName: text('author_display_name'), + authorAvatarUrl: text('author_avatar_url'), + content: text('content').notNull(), + postCreatedAt: timestamp('post_created_at').notNull(), + likesCount: integer('likes_count').default(0).notNull(), + repostsCount: integer('reposts_count').default(0).notNull(), + repliesCount: integer('replies_count').default(0).notNull(), + linkPreviewUrl: text('link_preview_url'), + linkPreviewTitle: text('link_preview_title'), + linkPreviewDescription: text('link_preview_description'), + linkPreviewImage: text('link_preview_image'), + linkPreviewType: text('link_preview_type'), + linkPreviewVideoUrl: text('link_preview_video_url'), + linkPreviewMediaJson: text('link_preview_media_json'), + mediaJson: text('media_json'), + repostedAt: timestamp('reposted_at').defaultNow().notNull(), +}, (table) => [ + index('user_swarm_reposts_user_idx').on(table.userId, table.repostedAt), + index('user_swarm_reposts_post_idx').on(table.nodeDomain, table.originalPostId), + uniqueIndex('user_swarm_reposts_unique').on(table.userId, table.nodeDomain, table.originalPostId), +]); + // ============================================ // REMOTE REPOSTS (reposts from federated users on local posts) // ============================================ @@ -411,6 +452,12 @@ export const notifications = pgTable('notifications', { actorDisplayName: text('actor_display_name'), actorAvatarUrl: text('actor_avatar_url'), actorNodeDomain: text('actor_node_domain'), // null for local actors + // Target info - used for owner-shadow notifications like interactions with owned bots + targetHandle: text('target_handle'), + targetDisplayName: text('target_display_name'), + targetAvatarUrl: text('target_avatar_url'), + targetNodeDomain: text('target_node_domain'), + targetIsBot: boolean('target_is_bot'), // Post reference postId: uuid('post_id').references(() => posts.id, { onDelete: 'cascade' }), postContent: text('post_content'), // Cached content for display @@ -586,8 +633,9 @@ export const bots = pgTable('bots', { personalityConfig: text('personality_config').notNull(), // JSON // LLM configuration - llmProvider: text('llm_provider').notNull(), // openrouter, openai, anthropic + llmProvider: text('llm_provider').notNull(), // openrouter, openai, anthropic, custom llmModel: text('llm_model').notNull(), + llmEndpoint: text('llm_endpoint'), llmApiKeyEncrypted: text('llm_api_key_encrypted').notNull(), // Scheduling @@ -840,6 +888,11 @@ export const swarmNodes = pgTable('swarm_nodes', { // Trust/reputation (for future spam prevention) trustScore: integer('trust_score').default(50).notNull(), // 0-100 + // Admin moderation + isBlocked: boolean('is_blocked').default(false).notNull(), + blockReason: text('block_reason'), + blockedAt: timestamp('blocked_at'), + // Capabilities capabilities: text('capabilities'), // JSON array: ["handles", "gossip", "relay"] @@ -851,6 +904,7 @@ export const swarmNodes = pgTable('swarm_nodes', { index('swarm_nodes_last_seen_idx').on(table.lastSeenAt), index('swarm_nodes_trust_idx').on(table.trustScore), index('swarm_nodes_nsfw_idx').on(table.isNsfw), + index('swarm_nodes_blocked_idx').on(table.isBlocked), ]); /** diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index 6529604..4fb9cfb 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -3,7 +3,7 @@ */ import { db, users, sessions } from '@/db'; -import { eq } from 'drizzle-orm'; +import { eq, inArray } from 'drizzle-orm'; import bcrypt from 'bcryptjs'; import { v4 as uuid } from 'uuid'; import { generateKeyPair } from '@/lib/crypto/keys'; @@ -13,8 +13,107 @@ import { cookies } from 'next/headers'; import { upsertHandleEntries } from '@/lib/federation/handles'; import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3'; -const SESSION_COOKIE_NAME = 'synapsis_session'; -const SESSION_EXPIRY_DAYS = 30; +const ACTIVE_SESSION_COOKIE_NAME = 'synapsis_session'; +const SESSION_COOKIE_NAME = 'synapsis_sessions'; +const SESSION_EXPIRY_DAYS = 3650; + +type SessionRecord = typeof sessions.$inferSelect & { + user: typeof users.$inferSelect; +}; + +export interface AuthAccount { + id: string; + handle: string; + displayName: string | null; + avatarUrl: string | null; + did: string; + publicKey: string; + privateKeyEncrypted: string | null; + email: string | null; + isActive: boolean; +} + +function parseSessionCookie(value: string | undefined): string[] { + if (!value) { + return []; + } + + return value + .split(',') + .map(token => token.trim()) + .filter(Boolean); +} + +async function readSessionState() { + const cookieStore = await cookies(); + return { + cookieStore, + activeToken: cookieStore.get(ACTIVE_SESSION_COOKIE_NAME)?.value ?? null, + tokens: parseSessionCookie(cookieStore.get(SESSION_COOKIE_NAME)?.value), + }; +} + +async function writeSessionState(tokens: string[], activeToken?: string | null) { + const cookieStore = await cookies(); + const dedupedTokens = [...new Set(tokens.filter(Boolean))]; + + if (dedupedTokens.length === 0) { + cookieStore.delete(ACTIVE_SESSION_COOKIE_NAME); + cookieStore.delete(SESSION_COOKIE_NAME); + return; + } + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + SESSION_EXPIRY_DAYS); + + const nextActiveToken = activeToken && dedupedTokens.includes(activeToken) + ? activeToken + : dedupedTokens[0]; + + const cookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + expires: expiresAt, + path: '/', + }; + + cookieStore.set(ACTIVE_SESSION_COOKIE_NAME, nextActiveToken, cookieOptions); + cookieStore.set(SESSION_COOKIE_NAME, dedupedTokens.join(','), cookieOptions); +} + +async function loadSessionsByTokens(tokens: string[]): Promise<SessionRecord[]> { + const uniqueTokens = [...new Set(tokens.filter(Boolean))]; + if (uniqueTokens.length === 0) { + return []; + } + + const sessionRecords = await db.query.sessions.findMany({ + where: inArray(sessions.token, uniqueTokens), + with: { + user: true, + }, + }); + + const sessionMap = new Map(sessionRecords.map(session => [session.token, session])); + return uniqueTokens + .map(token => sessionMap.get(token)) + .filter((session): session is SessionRecord => Boolean(session)); +} + +function toAuthAccount(session: SessionRecord, activeToken: string | null): AuthAccount { + return { + id: session.user.id, + handle: session.user.handle, + displayName: session.user.displayName, + avatarUrl: session.user.avatarUrl, + did: session.user.did, + publicKey: session.user.publicKey, + privateKeyEncrypted: session.user.privateKeyEncrypted, + email: session.user.email, + isActive: session.token === activeToken, + }; +} /** * Hash a password @@ -55,6 +154,16 @@ export function generateLegacyDID(): string { * Create a new session for a user */ export async function createSession(userId: string): Promise<string> { + const { tokens } = await readSessionState(); + const existingSessions = await loadSessionsByTokens(tokens); + const existingUserTokens = existingSessions + .filter(session => session.userId === userId) + .map(session => session.token); + + if (existingUserTokens.length > 0) { + await db.delete(sessions).where(inArray(sessions.token, existingUserTokens)); + } + const token = uuid(); const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + SESSION_EXPIRY_DAYS); @@ -65,15 +174,8 @@ export async function createSession(userId: string): Promise<string> { expiresAt, }); - // Set the session cookie - const cookieStore = await cookies(); - cookieStore.set(SESSION_COOKIE_NAME, token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - expires: expiresAt, - path: '/', - }); + const filteredTokens = tokens.filter(existingToken => !existingUserTokens.includes(existingToken)); + await writeSessionState([token, ...filteredTokens], token); return token; } @@ -82,25 +184,50 @@ export async function createSession(userId: string): Promise<string> { * Get the current session from cookies */ export async function getSession(): Promise<{ user: typeof users.$inferSelect } | null> { - const cookieStore = await cookies(); - const token = cookieStore.get(SESSION_COOKIE_NAME)?.value; + const { activeToken, tokens } = await readSessionState(); + const sessionRecords = await loadSessionsByTokens(tokens); - if (!token) { + if (sessionRecords.length === 0) { + await writeSessionState([], null); return null; } - const session = await db.query.sessions.findFirst({ - where: eq(sessions.token, token), - with: { - user: true, - }, - }); + const activeSession = sessionRecords.find(session => session.token === activeToken) ?? sessionRecords[0]; + await writeSessionState(sessionRecords.map(session => session.token), activeSession.token); - if (!session || session.expiresAt < new Date()) { - return null; + return { user: activeSession.user }; +} + +export async function getSessionAccounts(): Promise<AuthAccount[]> { + const { activeToken, tokens } = await readSessionState(); + const sessionRecords = await loadSessionsByTokens(tokens); + + if (sessionRecords.length === 0) { + await writeSessionState([], null); + return []; } - return { user: session.user }; + const resolvedActiveToken = sessionRecords.some(session => session.token === activeToken) + ? activeToken + : sessionRecords[0].token; + + await writeSessionState(sessionRecords.map(session => session.token), resolvedActiveToken); + + return sessionRecords.map(session => toAuthAccount(session, resolvedActiveToken)); +} + +export async function switchSession(userId: string): Promise<{ user: typeof users.$inferSelect }> { + const { tokens } = await readSessionState(); + const sessionRecords = await loadSessionsByTokens(tokens); + const matchingSession = sessionRecords.find(session => session.user.id === userId); + + if (!matchingSession) { + throw new Error('Session not found'); + } + + await writeSessionState(sessionRecords.map(session => session.token), matchingSession.token); + + return { user: matchingSession.user }; } /** @@ -119,14 +246,31 @@ export async function requireAuth(): Promise<typeof users.$inferSelect> { /** * Destroy the current session */ -export async function destroySession(): Promise<void> { - const cookieStore = await cookies(); - const token = cookieStore.get(SESSION_COOKIE_NAME)?.value; +export async function destroySession(userId?: string): Promise<void> { + const { activeToken, tokens } = await readSessionState(); + const sessionRecords = await loadSessionsByTokens(tokens); - if (token) { - await db.delete(sessions).where(eq(sessions.token, token)); - cookieStore.delete(SESSION_COOKIE_NAME); + if (sessionRecords.length === 0) { + await writeSessionState([], null); + return; } + + const targetSession = userId + ? sessionRecords.find(session => session.user.id === userId) + : sessionRecords.find(session => session.token === activeToken) ?? sessionRecords[0]; + + if (!targetSession) { + return; + } + + await db.delete(sessions).where(eq(sessions.token, targetSession.token)); + + const remainingSessions = sessionRecords.filter(session => session.token !== targetSession.token); + const nextActiveToken = targetSession.token === activeToken + ? remainingSessions[0]?.token ?? null + : activeToken; + + await writeSessionState(remainingSessions.map(session => session.token), nextActiveToken); } /** diff --git a/src/lib/bots/autonomous.ts b/src/lib/bots/autonomous.ts index 33fb430..1c7da20 100644 --- a/src/lib/bots/autonomous.ts +++ b/src/lib/bots/autonomous.ts @@ -12,7 +12,7 @@ import { db, botContentItems, bots } from '@/db'; import { eq, and } from 'drizzle-orm'; import { ContentGenerator, type Bot as ContentGeneratorBot, type ContentItem } from './contentGenerator'; import { canPost } from './rateLimiter'; -import { decryptApiKey, deserializeEncryptedData } from './encryption'; +import { decryptApiKey, deserializeEncryptedData, type LLMProvider } from './encryption'; import { parseScheduleConfig, isDue } from './scheduler'; import { triggerPost } from './posting'; @@ -213,8 +213,9 @@ function toContentGeneratorBot(bot: typeof bots.$inferSelect, handle: string): C name: bot.name, handle: handle, personalityConfig: JSON.parse(bot.personalityConfig), - llmProvider: bot.llmProvider as 'openrouter' | 'openai' | 'anthropic', + llmProvider: bot.llmProvider as LLMProvider, llmModel: bot.llmModel, + llmEndpoint: bot.llmEndpoint, llmApiKeyEncrypted: bot.llmApiKeyEncrypted, }; } diff --git a/src/lib/bots/botManager.ts b/src/lib/bots/botManager.ts index 8e1a647..365ade6 100644 --- a/src/lib/bots/botManager.ts +++ b/src/lib/bots/botManager.ts @@ -15,6 +15,7 @@ import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3'; import { decryptPrivateKey, deserializeEncryptedKey } from '@/lib/crypto/private-key'; import { eq, and, count } from 'drizzle-orm'; import { generateKeyPair } from '@/lib/crypto/keys'; +import { isIP } from 'net'; import { encryptApiKey, decryptApiKey, @@ -52,6 +53,7 @@ export interface BotCreateInput { personality: PersonalityConfig; llmProvider: LLMProvider; llmModel: string; + llmEndpoint?: string; llmApiKey: string; schedule?: ScheduleConfig; autonomousMode?: boolean; @@ -65,6 +67,7 @@ export interface BotUpdateInput { personality?: PersonalityConfig; llmProvider?: LLMProvider; llmModel?: string; + llmEndpoint?: string | null; llmApiKey?: string; schedule?: ScheduleConfig | null; autonomousMode?: boolean; @@ -83,6 +86,7 @@ export interface Bot { personalityConfig: PersonalityConfig; llmProvider: LLMProvider; llmModel: string; + llmEndpoint?: string | null; scheduleConfig: ScheduleConfig | null; autonomousMode: boolean; isActive: boolean; @@ -248,6 +252,65 @@ export function validateScheduleConfig(config: ScheduleConfig): void { } } +function isPrivateHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + + if ( + normalized === 'localhost' || + normalized.endsWith('.localhost') || + normalized.endsWith('.local') + ) { + return true; + } + + const ipVersion = isIP(normalized); + if (ipVersion === 4) { + const octets = normalized.split('.').map(Number); + const [first, second] = octets; + + return ( + first === 10 || + first === 127 || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) + ); + } + + if (ipVersion === 6) { + return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:'); + } + + return false; +} + +export function normalizeAndValidateLlmEndpoint(endpoint: string): string { + if (!endpoint || typeof endpoint !== 'string') { + throw new BotValidationError('Custom endpoint is required'); + } + + let parsed: URL; + try { + parsed = new URL(endpoint.trim()); + } catch { + throw new BotValidationError('Custom endpoint must be a valid URL'); + } + + if (parsed.protocol !== 'https:') { + throw new BotValidationError('Custom endpoint must use HTTPS'); + } + + if (parsed.username || parsed.password) { + throw new BotValidationError('Custom endpoint cannot include embedded credentials'); + } + + if (isPrivateHostname(parsed.hostname)) { + throw new BotValidationError('Custom endpoint must be publicly reachable and cannot target localhost or a private network'); + } + + return parsed.toString(); +} + // ============================================ // HELPER FUNCTIONS // ============================================ @@ -269,6 +332,7 @@ function dbBotToBot(dbBot: typeof bots.$inferSelect, botUser: typeof users.$infe personalityConfig: JSON.parse(dbBot.personalityConfig) as PersonalityConfig, llmProvider: dbBot.llmProvider as LLMProvider, llmModel: dbBot.llmModel, + llmEndpoint: dbBot.llmEndpoint, scheduleConfig: dbBot.scheduleConfig ? JSON.parse(dbBot.scheduleConfig) as ScheduleConfig : null, autonomousMode: dbBot.autonomousMode, isActive: dbBot.isActive, @@ -308,6 +372,10 @@ export async function createBot(ownerId: string, config: BotCreateInput): Promis if (config.schedule) { validateScheduleConfig(config.schedule); } + + const normalizedEndpoint = config.llmProvider === 'custom' + ? normalizeAndValidateLlmEndpoint(config.llmEndpoint || '') + : null; // Validate API key format const apiKeyValidation = validateApiKeyFormat(config.llmApiKey, config.llmProvider); @@ -391,6 +459,7 @@ export async function createBot(ownerId: string, config: BotCreateInput): Promis personalityConfig: JSON.stringify(config.personality), llmProvider: config.llmProvider, llmModel: config.llmModel, + llmEndpoint: normalizedEndpoint, llmApiKeyEncrypted: serializeEncryptedData(encryptedApiKey), scheduleConfig: config.schedule ? JSON.stringify(config.schedule) : null, autonomousMode: config.autonomousMode ?? false, @@ -443,15 +512,7 @@ export async function updateBot(botId: string, config: BotUpdateInput): Promise< if (config.schedule !== undefined && config.schedule !== null) { validateScheduleConfig(config.schedule); } - - // Validate API key if provided - if (config.llmApiKey !== undefined && config.llmProvider !== undefined) { - const apiKeyValidation = validateApiKeyFormat(config.llmApiKey, config.llmProvider); - if (!apiKeyValidation.valid) { - throw new BotValidationError(apiKeyValidation.error || 'Invalid API key'); - } - } - + // Check if bot exists and get its user account const existingBot = await db.query.bots.findFirst({ where: eq(bots.id, botId), @@ -469,6 +530,25 @@ export async function updateBot(botId: string, config: BotUpdateInput): Promise< if (!botUser) { throw new BotNotFoundError(botId); } + + const targetProvider = (config.llmProvider ?? existingBot.llmProvider) as LLMProvider; + + if (targetProvider === 'custom') { + const endpointToValidate = config.llmEndpoint !== undefined ? config.llmEndpoint : existingBot.llmEndpoint; + if (!endpointToValidate) { + throw new BotValidationError('Custom endpoint is required'); + } + config.llmEndpoint = normalizeAndValidateLlmEndpoint(endpointToValidate); + } else if (config.llmEndpoint !== undefined || config.llmProvider !== undefined) { + config.llmEndpoint = null; + } + + if (config.llmApiKey !== undefined) { + const apiKeyValidation = validateApiKeyFormat(config.llmApiKey, targetProvider); + if (!apiKeyValidation.valid) { + throw new BotValidationError(apiKeyValidation.error || 'Invalid API key'); + } + } // Build update object for bot config const botUpdateData: Partial<typeof bots.$inferInsert> = { @@ -508,6 +588,12 @@ export async function updateBot(botId: string, config: BotUpdateInput): Promise< if (config.llmModel !== undefined) { botUpdateData.llmModel = config.llmModel; } + + if (config.llmEndpoint !== undefined) { + botUpdateData.llmEndpoint = config.llmEndpoint; + } else if (config.llmProvider !== undefined && config.llmProvider !== 'custom') { + botUpdateData.llmEndpoint = null; + } if (config.llmApiKey !== undefined) { const encryptedApiKey = encryptApiKey(config.llmApiKey); diff --git a/src/lib/bots/contentFetcher.test.ts b/src/lib/bots/contentFetcher.test.ts index cd349ed..25d9d81 100644 --- a/src/lib/bots/contentFetcher.test.ts +++ b/src/lib/bots/contentFetcher.test.ts @@ -12,6 +12,8 @@ import { calculateBackoffDelay, shouldRetrySource, isSourceDueForFetch, + shouldExcludeRedditPost, + fetchRedditPosts, BASE_BACKOFF_DELAY_MS, MAX_BACKOFF_DELAY_MS, MAX_CONSECUTIVE_ERRORS, @@ -195,6 +197,88 @@ describe('isSourceDueForFetch', () => { }); +describe('Reddit filtering', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('excludes stickied and pinned reddit posts', () => { + expect(shouldExcludeRedditPost({ + id: 't3_stickied', + title: 'Community thread', + permalink: '/r/test/comments/stickied/community_thread/', + url: 'https://reddit.com/r/test/comments/stickied/community_thread/', + created_utc: 1710000000, + stickied: true, + })).toBe(true); + + expect(shouldExcludeRedditPost({ + id: 't3_pinned', + title: 'Pinned thread', + permalink: '/r/test/comments/pinned/pinned_thread/', + url: 'https://reddit.com/r/test/comments/pinned/pinned_thread/', + created_utc: 1710000000, + pinned: true, + })).toBe(true); + + expect(shouldExcludeRedditPost({ + id: 't3_normal', + title: 'Normal post', + permalink: '/r/test/comments/normal/normal_post/', + url: 'https://reddit.com/r/test/comments/normal/normal_post/', + created_utc: 1710000000, + })).toBe(false); + }); + + it('fetchRedditPosts removes community highlights before returning items', async () => { + const payload = { + data: { + children: [ + { + data: { + id: 'abc123', + name: 't3_abc123', + title: 'Community Highlights', + selftext: 'Pinned moderator thread', + permalink: '/r/test/comments/abc123/community_highlights/', + url: 'https://www.reddit.com/r/test/comments/abc123/community_highlights/', + created_utc: 1710000000, + stickied: true, + }, + }, + { + data: { + id: 'def456', + name: 't3_def456', + title: 'Actual content post', + selftext: 'Useful content', + permalink: '/r/test/comments/def456/actual_content_post/', + url: 'https://example.com/article', + created_utc: 1710000100, + stickied: false, + pinned: false, + }, + }, + ], + }, + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + text: async () => JSON.stringify(payload), + } as Response); + + const items = await fetchRedditPosts('test', { maxItems: 10 }); + + expect(items).toHaveLength(1); + expect(items[0]?.title).toBe('Actual content post'); + expect(items[0]?.id).toBe('t3_def456'); + }); +}); + // ============================================ // BACKOFF DELAY PROPERTY TESTS // ============================================ diff --git a/src/lib/bots/contentFetcher.ts b/src/lib/bots/contentFetcher.ts index bb0b0bc..f8595e6 100644 --- a/src/lib/bots/contentFetcher.ts +++ b/src/lib/bots/contentFetcher.ts @@ -41,6 +41,7 @@ export interface ContentItemInput { title: string; content: string | null; url: string; + url_overridden_by_dest?: string; publishedAt: Date; } @@ -325,16 +326,39 @@ export async function fetchRedditPosts( subreddit: string, options: FetchOptions = {} ): Promise<FeedItem[]> { - // Use RSS feed instead of JSON API - more reliable and doesn't require auth - const rssUrl = `https://www.reddit.com/r/${subreddit}/hot.rss`; - + const requestedMaxItems = options.maxItems ?? DEFAULT_MAX_ITEMS; + const fetchLimit = Math.min(Math.max(requestedMaxItems + 10, requestedMaxItems), 100); + const url = new URL(`${REDDIT_API_BASE}/r/${subreddit}/hot.json`); + url.searchParams.set('raw_json', '1'); + url.searchParams.set('limit', String(fetchLimit)); + + const responseText = await fetchUrl(url.toString(), { + timeout: options.timeout, + headers: { + 'Accept': 'application/json', + }, + }); + + let response: RedditListingResponse; try { - return await fetchRSSFeed(rssUrl, options); - } catch (error) { - // If RSS fails, try the old.reddit.com RSS which sometimes works better - const oldRedditUrl = `https://old.reddit.com/r/${subreddit}/hot.rss`; - return await fetchRSSFeed(oldRedditUrl, options); + response = JSON.parse(responseText) as RedditListingResponse; + } catch { + throw new ParseError('Failed to parse Reddit JSON response'); } + + const posts = response.data?.children + ?.map((child) => child.data) + .filter((post): post is RedditListingChildData => Boolean(post)) || []; + + const filteredPosts = posts.filter((post) => !shouldExcludeRedditPost(post)); + + return filteredPosts.slice(0, requestedMaxItems).map((post): FeedItem => ({ + id: post.name || post.id, + title: post.title, + content: post.selftext || '', + url: `${REDDIT_API_BASE}${post.permalink}`, + publishedAt: new Date(post.created_utc * 1000), + })); } // ============================================ @@ -384,6 +408,31 @@ interface BraveNewsResponse { results?: BraveNewsResult[]; } +interface RedditListingChildData { + id: string; + name?: string; + title: string; + selftext?: string; + permalink: string; + url: string; + url_overridden_by_dest?: string; + created_utc: number; + stickied?: boolean; + pinned?: boolean; +} + +interface RedditListingResponse { + data?: { + children?: Array<{ + data?: RedditListingChildData; + }>; + }; +} + +export function shouldExcludeRedditPost(post: RedditListingChildData): boolean { + return Boolean(post.stickied || post.pinned); +} + /** * Fetch articles from a news API. * diff --git a/src/lib/bots/contentGenerator.ts b/src/lib/bots/contentGenerator.ts index 6d53176..47af7af 100644 --- a/src/lib/bots/contentGenerator.ts +++ b/src/lib/bots/contentGenerator.ts @@ -26,6 +26,7 @@ export interface Bot { personalityConfig: PersonalityConfig; llmProvider: LLMProvider; llmModel: string; + llmEndpoint?: string | null; llmApiKeyEncrypted: string; } @@ -35,6 +36,7 @@ export interface Bot { export interface ContentItem { id: string; sourceId: string; + externalId?: string; title: string; content: string | null; url: string; @@ -467,6 +469,7 @@ export class ContentGenerator { provider: bot.llmProvider, apiKey: bot.llmApiKeyEncrypted, model: bot.llmModel, + endpoint: bot.llmEndpoint, }); } diff --git a/src/lib/bots/encryption.ts b/src/lib/bots/encryption.ts index e10245c..1542f9b 100644 --- a/src/lib/bots/encryption.ts +++ b/src/lib/bots/encryption.ts @@ -13,7 +13,7 @@ import * as crypto from 'crypto'; // TYPES // ============================================ -export type LLMProvider = 'openrouter' | 'openai' | 'anthropic'; +export type LLMProvider = 'openrouter' | 'openai' | 'anthropic' | 'custom'; export interface EncryptedData { encrypted: string; // Base64 encoded ciphertext + auth tag @@ -40,6 +40,7 @@ const API_KEY_PATTERNS: Record<LLMProvider, RegExp> = { openrouter: /^sk-or-[a-zA-Z0-9_-]+$/, anthropic: /^sk-ant-[a-zA-Z0-9_-]+$/, openai: /^sk-[a-zA-Z0-9_-]+$/, + custom: /^[\s\S]+$/, }; /** @@ -330,7 +331,7 @@ export function validateAndDetectApiKey(apiKey: string): ApiKeyValidationResult * @returns True if the provider is supported */ export function isSupportedProvider(provider: string): provider is LLMProvider { - return provider === 'openrouter' || provider === 'openai' || provider === 'anthropic'; + return provider === 'openrouter' || provider === 'openai' || provider === 'anthropic' || provider === 'custom'; } /** @@ -339,5 +340,5 @@ export function isSupportedProvider(provider: string): provider is LLMProvider { * @returns Array of supported provider names */ export function getSupportedProviders(): LLMProvider[] { - return ['openrouter', 'openai', 'anthropic']; + return ['openrouter', 'openai', 'anthropic', 'custom']; } diff --git a/src/lib/bots/llmClient.ts b/src/lib/bots/llmClient.ts index 7b2f510..4484e52 100644 --- a/src/lib/bots/llmClient.ts +++ b/src/lib/bots/llmClient.ts @@ -24,6 +24,7 @@ export interface LLMConfig { provider: LLMProvider; apiKey: string; // Encrypted before storage model: string; + endpoint?: string | null; } /** @@ -123,6 +124,7 @@ export const PROVIDER_ENDPOINTS: Record<LLMProvider, string> = { openrouter: 'https://openrouter.ai/api/v1/chat/completions', openai: 'https://api.openai.com/v1/chat/completions', anthropic: 'https://api.anthropic.com/v1/messages', + custom: '', }; /** @@ -132,6 +134,7 @@ export const DEFAULT_MODELS: Record<LLMProvider, string> = { openrouter: 'openai/gpt-3.5-turbo', openai: 'gpt-3.5-turbo', anthropic: 'claude-3-haiku-20240307', + custom: 'gpt-4o-mini', }; // ============================================ @@ -299,7 +302,8 @@ export function parseOpenRouterResponse( */ export function parseOpenAIResponse( data: Record<string, unknown>, - model: string + model: string, + provider: LLMProvider = 'openai' ): LLMCompletionResponse { const choices = data.choices as Array<{ message: { content: string } }>; const usage = data.usage as { prompt_tokens: number; completion_tokens: number; total_tokens: number }; @@ -312,7 +316,7 @@ export function parseOpenAIResponse( total: usage?.total_tokens ?? 0, }, model: (data.model as string) ?? model, - provider: 'openai', + provider, }; } @@ -359,6 +363,7 @@ export function buildHeaders(provider: LLMProvider, apiKey: string): Record<stri headers['X-Title'] = 'Synapsis Bot'; break; case 'openai': + case 'custom': headers['Authorization'] = `Bearer ${apiKey}`; break; case 'anthropic': @@ -386,6 +391,7 @@ export class LLMClient { private provider: LLMProvider; private apiKey: string; private model: string; + private endpoint: string | null; private retryConfig: RetryConfig; private timeoutMs: number; @@ -403,6 +409,7 @@ export class LLMClient { ) { this.provider = config.provider; this.model = config.model || DEFAULT_MODELS[config.provider]; + this.endpoint = config.endpoint || null; this.retryConfig = retryConfig; this.timeoutMs = timeoutMs; @@ -480,7 +487,16 @@ export class LLMClient { * @throws LLMClientError if the request fails */ private async makeRequest(request: LLMCompletionRequest): Promise<LLMCompletionResponse> { - const endpoint = PROVIDER_ENDPOINTS[this.provider]; + const endpoint = this.provider === 'custom' ? this.endpoint : PROVIDER_ENDPOINTS[this.provider]; + if (!endpoint) { + throw new LLMClientError( + 'Custom provider requires an endpoint', + 'INVALID_REQUEST', + this.provider, + undefined, + false + ); + } const headers = buildHeaders(this.provider, this.apiKey); const body = this.buildRequestBody(request); @@ -566,6 +582,7 @@ export class LLMClient { case 'openrouter': return buildOpenRouterRequest(request, this.model); case 'openai': + case 'custom': return buildOpenAIRequest(request, this.model); case 'anthropic': return buildAnthropicRequest(request, this.model); @@ -580,7 +597,9 @@ export class LLMClient { case 'openrouter': return parseOpenRouterResponse(data, this.model); case 'openai': - return parseOpenAIResponse(data, this.model); + return parseOpenAIResponse(data, this.model, 'openai'); + case 'custom': + return parseOpenAIResponse(data, this.model, 'custom'); case 'anthropic': return parseAnthropicResponse(data, this.model); } @@ -618,12 +637,14 @@ export function createLLMClient( export function createLLMClientFromBot( provider: LLMProvider, encryptedApiKey: string, - model: string + model: string, + endpoint?: string | null ): LLMClient { return new LLMClient({ provider, apiKey: encryptedApiKey, model, + endpoint, }); } @@ -643,7 +664,7 @@ export function validateLLMConfig(config: unknown): { valid: boolean; errors: st const configObj = config as Record<string, unknown>; // Validate provider - const validProviders: LLMProvider[] = ['openrouter', 'openai', 'anthropic']; + const validProviders: LLMProvider[] = ['openrouter', 'openai', 'anthropic', 'custom']; if (!configObj.provider || !validProviders.includes(configObj.provider as LLMProvider)) { errors.push(`Provider must be one of: ${validProviders.join(', ')}`); } @@ -657,6 +678,10 @@ export function validateLLMConfig(config: unknown): { valid: boolean; errors: st if (configObj.model !== undefined && typeof configObj.model !== 'string') { errors.push('Model must be a string'); } + + if (configObj.provider === 'custom' && (!configObj.endpoint || typeof configObj.endpoint !== 'string')) { + errors.push('Endpoint is required for custom provider'); + } return { valid: errors.length === 0, errors }; } diff --git a/src/lib/bots/mentionHandler.ts b/src/lib/bots/mentionHandler.ts index 16317f9..28b466e 100644 --- a/src/lib/bots/mentionHandler.ts +++ b/src/lib/bots/mentionHandler.ts @@ -446,6 +446,7 @@ export async function processMention(mentionId: string): Promise<MentionResponse personalityConfig: JSON.parse(bot.personalityConfig), llmProvider: bot.llmProvider as any, llmModel: bot.llmModel, + llmEndpoint: bot.llmEndpoint, llmApiKeyEncrypted: bot.llmApiKeyEncrypted, }; diff --git a/src/lib/bots/posting.ts b/src/lib/bots/posting.ts index 02e8dc5..b54598b 100644 --- a/src/lib/bots/posting.ts +++ b/src/lib/bots/posting.ts @@ -7,12 +7,15 @@ * Requirements: 5.4, 11.5, 11.6 */ -import { db, posts, users, bots, botContentItems, botActivityLogs } from '@/db'; +import { db, posts, users, bots, botContentItems, botActivityLogs, botContentSources } from '@/db'; import { eq, and, inArray } from 'drizzle-orm'; import { ContentGenerator, type Bot as ContentGeneratorBot, type ContentItem } from './contentGenerator'; import { canPost, recordPost } from './rateLimiter'; import { getBotById } from './botManager'; -import { decryptApiKey, deserializeEncryptedData } from './encryption'; +import { decryptApiKey, deserializeEncryptedData, type LLMProvider } from './encryption'; +import { fetchRedditRichPreview } from '@/lib/media/redditPreview'; +import type { LinkPreviewData } from '@/lib/media/linkPreview'; +import { fetchGenericLinkPreview } from '@/lib/media/genericPreview'; // ============================================ // TYPES @@ -139,8 +142,9 @@ function toContentGeneratorBot(bot: typeof bots.$inferSelect & { user: { handle: name: bot.name, handle: bot.user.handle, personalityConfig: JSON.parse(bot.personalityConfig), - llmProvider: bot.llmProvider as 'openrouter' | 'openai' | 'anthropic', + llmProvider: bot.llmProvider as LLMProvider, llmModel: bot.llmModel, + llmEndpoint: bot.llmEndpoint, llmApiKeyEncrypted: bot.llmApiKeyEncrypted, }; } @@ -175,6 +179,7 @@ async function getContentItemById(contentItemId: string): Promise<ContentItem | return { id: item.id, sourceId: item.sourceId, + externalId: item.externalId, title: item.title, content: item.content, url: item.url, @@ -250,6 +255,7 @@ async function getNextUnprocessedContentItem(botId: string): Promise<ContentItem return { id: item.id, sourceId: item.sourceId, + externalId: item.externalId, title: item.title, content: item.content, url: item.url, @@ -630,62 +636,8 @@ function isRedditUrl(url: string): boolean { * Fetch link preview for Reddit URLs using their oEmbed API. * Reddit blocks regular scraping but provides oEmbed for embedding. */ -async function fetchRedditPreview(url: string): Promise<{ - url: string; - title: string | null; - description: string | null; - image: string | null; -} | null> { - try { - // Reddit's oEmbed endpoint - const oembedUrl = `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}`; - - const response = await fetch(oembedUrl, { - headers: { - 'Accept': 'application/json', - }, - signal: AbortSignal.timeout(5000), - }); - - if (!response.ok) { - console.log(`Reddit oEmbed returned ${response.status} for ${url}`); - return null; - } - - const data = await response.json(); - - // Extract title - try title field first, then parse from HTML - let title = data.title || null; - if (!title && data.html) { - // Try to extract title from the embed HTML - const titleMatch = data.html.match(/href="[^"]+">([^<]+)<\/a>/); - if (titleMatch && titleMatch[1] && titleMatch[1] !== 'Comment') { - title = titleMatch[1]; - } - } - - // Build description from subreddit info if available - let description = null; - if (data.author_name) { - description = `Posted by ${data.author_name}`; - } else if (data.html) { - // Try to extract subreddit from HTML - const subredditMatch = data.html.match(/r\/([a-zA-Z0-9_]+)/); - if (subredditMatch) { - description = `r/${subredditMatch[1]}`; - } - } - - return { - url, - title, - description, - image: data.thumbnail_url || null, - }; - } catch (error) { - console.error('Failed to fetch Reddit oEmbed preview:', error); - return null; - } +async function fetchRedditPreview(url: string): Promise<LinkPreviewData | null> { + return fetchRedditRichPreview(url); } /** @@ -695,12 +647,7 @@ async function fetchRedditPreview(url: string): Promise<{ * @param url - The URL to fetch preview for * @returns Link preview data or null */ -async function fetchLinkPreview(url: string): Promise<{ - url: string; - title: string | null; - description: string | null; - image: string | null; -} | null> { +async function fetchLinkPreview(url: string): Promise<LinkPreviewData | null> { // Use Reddit-specific handler if (isRedditUrl(url)) { return fetchRedditPreview(url); @@ -708,48 +655,61 @@ async function fetchLinkPreview(url: string): Promise<{ // Generic OG tag scraping for other sites try { - const response = await fetch(url, { - headers: { - 'User-Agent': 'Mozilla/5.0 (compatible; SynapsisBot/1.0; +https://synapsis.social)', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.5', - }, - signal: AbortSignal.timeout(5000), - }); - - if (!response.ok) { - return null; - } - - const html = await response.text(); - - // Simple regex extraction for OG tags - const getMeta = (property: string): string | null => { - const regex = new RegExp(`<meta[^>]+(?:property|name)=["'](?:og:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i'); - const match = html.match(regex); - if (match) return match[1]; - - const regexRev = new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:)?${property}["']`, 'i'); - const matchRev = html.match(regexRev); - return matchRev ? matchRev[1] : null; - }; - - const title = getMeta('title') || html.match(/<title>([^<]+)<\/title>/i)?.[1]; - const description = getMeta('description'); - const image = getMeta('image'); - - return { - url, - title: title?.trim() || null, - description: description?.trim() || null, - image: image?.trim() || null, - }; + return await fetchGenericLinkPreview(url); } catch (error) { console.error('Failed to fetch link preview:', error); return null; } } +function extractRedditPostId(contentItem: ContentItem): string | null { + if (contentItem.externalId) { + const normalizedExternalId = contentItem.externalId.replace(/^t3_/, ''); + if (/^[a-z0-9]+$/i.test(normalizedExternalId)) { + return normalizedExternalId; + } + } + + if (contentItem.url) { + try { + const pathnameParts = new URL(contentItem.url).pathname.split('/').filter(Boolean); + const commentsIndex = pathnameParts.indexOf('comments'); + if (commentsIndex >= 0 && pathnameParts[commentsIndex + 1]) { + return pathnameParts[commentsIndex + 1]; + } + } catch { + // Fall back to returning null below. + } + } + + return null; +} + +async function resolveContentItemSourceUrl(contentItem?: ContentItem | null): Promise<string | undefined> { + if (!contentItem?.url) { + return contentItem?.url; + } + + const source = await db.query.botContentSources.findFirst({ + where: eq(botContentSources.id, contentItem.sourceId), + columns: { + type: true, + subreddit: true, + }, + }); + + if (source?.type !== 'reddit' || !source.subreddit) { + return contentItem.url; + } + + const postId = extractRedditPostId(contentItem); + if (!postId) { + return contentItem.url; + } + + return `https://www.reddit.com/r/${source.subreddit}/comments/${postId}/`; +} + /** * Create a post in the database. * Posts are created under the bot's own user account. @@ -762,7 +722,7 @@ async function fetchLinkPreview(url: string): Promise<{ async function createPostInDatabase( botId: string, content: string, - sourceUrl?: string + contentItem?: ContentItem | null ): Promise<typeof posts.$inferSelect> { // Get bot config const bot = await db.query.bots.findFirst({ @@ -789,6 +749,7 @@ async function createPostInDatabase( } // Fetch link preview if source URL provided + const sourceUrl = await resolveContentItemSourceUrl(contentItem); let linkPreview: Awaited<ReturnType<typeof fetchLinkPreview>> = null; if (sourceUrl) { linkPreview = await fetchLinkPreview(sourceUrl); @@ -809,6 +770,9 @@ async function createPostInDatabase( linkPreviewTitle: linkPreview?.title || null, linkPreviewDescription: linkPreview?.description || null, linkPreviewImage: linkPreview?.image || null, + linkPreviewType: linkPreview?.type || null, + linkPreviewVideoUrl: linkPreview?.videoUrl || null, + linkPreviewMediaJson: linkPreview?.media ? JSON.stringify(linkPreview.media) : null, }).returning(); // Update bot user's post count @@ -1079,7 +1043,7 @@ export async function triggerPost( } // Create post in database with source URL for link preview (if content item exists) - const post = await createPostInDatabase(botId, postContent, contentItem?.url); + const post = await createPostInDatabase(botId, postContent, contentItem); // Record post for rate limiting if (!skipRateLimitCheck) { diff --git a/src/lib/contexts/AuthContext.tsx b/src/lib/contexts/AuthContext.tsx index eea4156..f3d6786 100644 --- a/src/lib/contexts/AuthContext.tsx +++ b/src/lib/contexts/AuthContext.tsx @@ -14,8 +14,14 @@ export interface User { privateKeyEncrypted?: string; } +export interface AuthAccount extends User { + isActive: boolean; +} + interface AuthContextType { user: User | null; + accounts: AuthAccount[]; + activeAccountId: string | null; isAdmin: boolean; loading: boolean; isIdentityUnlocked: boolean; @@ -24,8 +30,10 @@ interface AuthContextType { handle: string | null; checkAdmin: () => Promise<void>; unlockIdentity: (password: string, explicitUser?: User) => Promise<void>; - login: (user: User) => void; - logout: () => Promise<void>; + login: (user?: User) => Promise<void>; + logout: (userId?: string) => Promise<void>; + switchAccount: (userId: string) => Promise<void>; + refreshAuth: () => Promise<void>; lockIdentity: () => Promise<void>; // New: manual lock signUserAction: (action: string, data: any) => Promise<any>; requiresUnlock: boolean; // True if user has encrypted key but not unlocked @@ -35,6 +43,8 @@ interface AuthContextType { const AuthContext = createContext<AuthContextType>({ user: null, + accounts: [], + activeAccountId: null, isAdmin: false, loading: true, isIdentityUnlocked: false, @@ -43,8 +53,10 @@ const AuthContext = createContext<AuthContextType>({ handle: null, checkAdmin: async () => { }, unlockIdentity: async () => { }, - login: () => { }, + login: async () => { }, logout: async () => { }, + switchAccount: async () => { }, + refreshAuth: async () => { }, lockIdentity: async () => { }, signUserAction: async () => Promise.reject('Not initialized'), requiresUnlock: false, @@ -54,6 +66,7 @@ const AuthContext = createContext<AuthContextType>({ export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState<User | null>(null); + const [accounts, setAccounts] = useState<AuthAccount[]>([]); const [isAdmin, setIsAdmin] = useState(false); const [loading, setLoading] = useState(true); const [showUnlockPrompt, setShowUnlockPrompt] = useState(false); @@ -80,6 +93,41 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }, []); + const applyAuthState = useCallback(async (data: { user: User | null; accounts?: AuthAccount[] | null }) => { + const nextAccounts = data.accounts ?? []; + setAccounts(nextAccounts); + setUser(data.user); + + if (data.user?.did && data.user?.publicKey) { + await initializeIdentity({ + did: data.user.did, + handle: data.user.handle, + publicKey: data.user.publicKey, + privateKeyEncrypted: data.user.privateKeyEncrypted, + }); + await checkAdmin(); + } else { + await clearIdentity(); + setIsAdmin(false); + } + }, [checkAdmin, clearIdentity, initializeIdentity]); + + const refreshAuth = useCallback(async () => { + setLoading(true); + try { + const res = await fetch('/api/auth/me', { cache: 'no-store' }); + const data = await res.json(); + await applyAuthState({ + user: res.ok ? data.user ?? null : null, + accounts: res.ok ? data.accounts ?? [] : [], + }); + } catch { + await applyAuthState({ user: null, accounts: [] }); + } finally { + setLoading(false); + } + }, [applyAuthState]); + /** * Unlock the user's identity with their password * Persists the key for auto-unlock on refresh @@ -112,81 +160,65 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { /** * Manually set the user state (called after successful login) */ - const login = useCallback((userData: User) => { - setUser(userData); - checkAdmin(); - - // Initialize identity - will try to auto-restore if possible - if (userData.did && userData.publicKey) { - initializeIdentity({ - did: userData.did, - handle: userData.handle, - publicKey: userData.publicKey, - privateKeyEncrypted: userData.privateKeyEncrypted, - }); - } - }, [checkAdmin, initializeIdentity]); + const login = useCallback(async (_userData?: User) => { + await refreshAuth(); + }, [refreshAuth]); /** * Logout the user and clear their identity */ - const logout = useCallback(async () => { + const logout = useCallback(async (userId?: string) => { try { - await fetch('/api/auth/logout', { method: 'POST' }); - await clearIdentity(); + await fetch('/api/auth/logout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(userId ? { userId } : {}), + }); setShowUnlockPrompt(false); - setUser(null); - setIsAdmin(false); + await refreshAuth(); } catch (error) { console.error('[Auth] Logout failed:', error); throw error; } - }, [clearIdentity]); + }, [refreshAuth]); + + const switchAccount = useCallback(async (userId: string) => { + try { + setLoading(true); + const res = await fetch('/api/auth/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId }), + }); + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || 'Failed to switch account'); + } + + await refreshAuth(); + } catch (error) { + console.error('[Auth] Switch account failed:', error); + throw error; + } finally { + setLoading(false); + } + }, [refreshAuth]); // Load auth state on mount useEffect(() => { - const loadAuth = async () => { - setLoading(true); - try { - const res = await fetch('/api/auth/me'); - if (res.ok) { - const data = await res.json(); - setUser(data.user); - - // Initialize identity - will auto-restore if persisted - if (data.user?.did && data.user?.publicKey) { - await initializeIdentity({ - did: data.user.did, - handle: data.user.handle, - publicKey: data.user.publicKey, - privateKeyEncrypted: data.user.privateKeyEncrypted, - }); - } - - if (data.user) { - await checkAdmin(); - } - } else { - setUser(null); - await clearIdentity(); - } - } catch { - setUser(null); - await clearIdentity(); - } finally { - setLoading(false); - } - }; - - loadAuth(); - }, [checkAdmin, initializeIdentity, clearIdentity]); + refreshAuth(); + }, [refreshAuth]); // Determine if unlock is required (has encrypted key but not unlocked) const requiresUnlock = !!user?.privateKeyEncrypted && !isUnlocked && !isRestoring; + const activeAccountId = user?.id ?? null; return ( <AuthContext.Provider value={{ user, + accounts, + activeAccountId, isAdmin, loading, isIdentityUnlocked: isUnlocked, @@ -197,6 +229,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { unlockIdentity, login, logout, + switchAccount, + refreshAuth, lockIdentity, signUserAction, requiresUnlock, diff --git a/src/lib/crypto/key-persistence.ts b/src/lib/crypto/key-persistence.ts index adcc7c2..2d0b278 100644 --- a/src/lib/crypto/key-persistence.ts +++ b/src/lib/crypto/key-persistence.ts @@ -17,8 +17,8 @@ import { deserializeEncryptedKey, type EncryptedPrivateKey } from './private-key const DB_NAME = 'synapsis-identity'; const DB_VERSION = 1; const STORE_NAME = 'keys'; -const SESSION_KEY_ITEM = 'synapsis_session_key'; -const WRAPPED_KEY_ITEM = 'synapsis_wrapped_key'; +const SESSION_KEY_PREFIX = 'synapsis_session_key:'; +const WRAPPED_KEY_PREFIX = 'synapsis_wrapped_key:'; interface WrappedKey { wrapped: string; // Base64 of wrapped key @@ -32,6 +32,14 @@ interface SessionData { createdAt: number; } +function getSessionKeyItem(identifier: string): string { + return `${SESSION_KEY_PREFIX}${identifier}`; +} + +function getWrappedKeyItem(identifier: string): string { + return `${WRAPPED_KEY_PREFIX}${identifier}`; +} + // ============================================ // IndexedDB Operations // ============================================ @@ -218,7 +226,8 @@ async function unwrapRawPrivateKey( */ export async function persistUnlockedKey( privateKeyBase64: string, - password: string + password: string, + identifier: string ): Promise<void> { try { // Derive session key from password @@ -228,7 +237,7 @@ export async function persistUnlockedKey( const wrapped = await wrapRawPrivateKey(privateKeyBase64, sessionKey); // Store wrapped key in IndexedDB - await storeInDB(WRAPPED_KEY_ITEM, wrapped); + await storeInDB(getWrappedKeyItem(identifier), wrapped); // Store session key in localStorage (so it survives refreshes) const sessionKeyData = await exportSessionKey(sessionKey); @@ -236,7 +245,7 @@ export async function persistUnlockedKey( key: sessionKeyData, createdAt: Date.now(), }; - localStorage.setItem(SESSION_KEY_ITEM, JSON.stringify(sessionData)); + localStorage.setItem(getSessionKeyItem(identifier), JSON.stringify(sessionData)); console.log('[KeyPersistence] Key persisted successfully'); } catch (error) { @@ -250,10 +259,10 @@ export async function persistUnlockedKey( * Returns the raw key bytes if available, null otherwise * The caller must then import these bytes as a non-extractable CryptoKey */ -export async function tryRestoreKey(): Promise<ArrayBuffer | null> { +export async function tryRestoreKey(identifier: string): Promise<ArrayBuffer | null> { try { // Get session key from localStorage - const sessionDataRaw = localStorage.getItem(SESSION_KEY_ITEM); + const sessionDataRaw = localStorage.getItem(getSessionKeyItem(identifier)); if (!sessionDataRaw) { console.log('[KeyPersistence] No session key found'); return null; @@ -262,15 +271,15 @@ export async function tryRestoreKey(): Promise<ArrayBuffer | null> { const sessionData: SessionData = JSON.parse(sessionDataRaw); // Check expiry (24 hours) - const MAX_AGE = 24 * 60 * 60 * 1000; + const MAX_AGE = 3650 * 24 * 60 * 60 * 1000; if (Date.now() - sessionData.createdAt > MAX_AGE) { console.log('[KeyPersistence] Session expired'); - await clearPersistentKey(); + await clearPersistentKey(identifier); return null; } // Get wrapped key from IndexedDB - const wrapped = await getFromDB<WrappedKey>(WRAPPED_KEY_ITEM); + const wrapped = await getFromDB<WrappedKey>(getWrappedKeyItem(identifier)); if (!wrapped) { console.log('[KeyPersistence] No wrapped key found'); return null; @@ -293,10 +302,24 @@ export async function tryRestoreKey(): Promise<ArrayBuffer | null> { /** * Clear the persisted key (logout) */ -export async function clearPersistentKey(): Promise<void> { +export async function clearPersistentKey(identifier?: string): Promise<void> { try { - localStorage.removeItem(SESSION_KEY_ITEM); - await removeFromDB(WRAPPED_KEY_ITEM); + if (identifier) { + localStorage.removeItem(getSessionKeyItem(identifier)); + await removeFromDB(getWrappedKeyItem(identifier)); + return; + } + + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i += 1) { + const key = localStorage.key(i); + if (key?.startsWith(SESSION_KEY_PREFIX)) { + keysToRemove.push(key); + } + } + + keysToRemove.forEach(key => localStorage.removeItem(key)); + console.log('[KeyPersistence] Key cleared'); } catch (error) { console.error('[KeyPersistence] Error clearing key:', error); @@ -306,13 +329,13 @@ export async function clearPersistentKey(): Promise<void> { /** * Check if a persisted key is available */ -export async function hasPersistentKey(): Promise<boolean> { - const sessionData = localStorage.getItem(SESSION_KEY_ITEM); +export async function hasPersistentKey(identifier: string): Promise<boolean> { + const sessionData = localStorage.getItem(getSessionKeyItem(identifier)); if (!sessionData) return false; try { const parsed: SessionData = JSON.parse(sessionData); - const MAX_AGE = 24 * 60 * 60 * 1000; + const MAX_AGE = 3650 * 24 * 60 * 60 * 1000; return Date.now() - parsed.createdAt <= MAX_AGE; } catch { return false; diff --git a/src/lib/hooks/useUserIdentity.ts b/src/lib/hooks/useUserIdentity.ts index d00f290..67f4faa 100644 --- a/src/lib/hooks/useUserIdentity.ts +++ b/src/lib/hooks/useUserIdentity.ts @@ -51,10 +51,14 @@ export function useUserIdentity() { setIsRestoring(false); return; } - + + if (!globalIdentity?.did) { + return; + } + // Try to restore from persistent storage - const restoredKeyBytes = await tryRestoreKey(); - if (restoredKeyBytes && globalIdentity) { + const restoredKeyBytes = await tryRestoreKey(globalIdentity.did); + if (restoredKeyBytes) { // Import the restored key as non-extractable const cryptoKey = await importPrivateKey(restoredKeyBytes); keyStore.setPrivateKey(cryptoKey); @@ -86,6 +90,8 @@ export function useUserIdentity() { publicKey: string; privateKeyEncrypted?: string; }) => { + keyStore.clear(); + const coreIdentity = { did: userData.did, handle: userData.handle, @@ -94,7 +100,7 @@ export function useUserIdentity() { keyStore.setIdentity(coreIdentity); // Try to auto-restore if we have persisted key - const restoredKeyBytes = await tryRestoreKey(); + const restoredKeyBytes = await tryRestoreKey(userData.did); if (restoredKeyBytes) { const cryptoKey = await importPrivateKey(restoredKeyBytes); keyStore.setPrivateKey(cryptoKey); @@ -150,7 +156,11 @@ export function useUserIdentity() { // PERSIST: Save raw key bytes for auto-restore on refresh // We pass the raw bytes because the CryptoKey is non-extractable - await persistUnlockedKey(privateKeyBase64, password); + if (!userDid) { + throw new Error('User DID is required to persist the unlocked identity'); + } + + await persistUnlockedKey(privateKeyBase64, password, userDid); console.log('[Identity] Private key stored in memory and persisted'); @@ -171,8 +181,9 @@ export function useUserIdentity() { * Lock the identity (manual lock, keeps identity info) */ const lockIdentity = useCallback(async () => { + const identifier = keyStore.getIdentity()?.did; keyStore.clear(); - await clearPersistentKey(); + await clearPersistentKey(identifier); setIsUnlocked(false); setIdentity(prev => prev ? { ...prev, isUnlocked: false } : null); }, []); @@ -181,8 +192,9 @@ export function useUserIdentity() { * Clear the identity (logout) */ const clearIdentity = useCallback(async () => { + const identifier = keyStore.getIdentity()?.did; keyStore.clear(); - await clearPersistentKey(); + await clearPersistentKey(identifier); setIdentity(null); setIsUnlocked(false); }, []); diff --git a/src/lib/media/genericPreview.ts b/src/lib/media/genericPreview.ts new file mode 100644 index 0000000..22ccb1e --- /dev/null +++ b/src/lib/media/genericPreview.ts @@ -0,0 +1,62 @@ +import type { LinkPreviewData } from './linkPreview'; + +const GENERIC_PREVIEW_USER_AGENT = 'Mozilla/5.0 (compatible; SynapsisBot/1.0; +https://synapsis.social)'; + +function decodeHtmlEntities(value: string): string { + return value + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +function extractMeta(html: string, property: string): string | null { + const patterns = [ + new RegExp(`<meta[^>]+(?:property|name|itemprop)=["'](?:og:|twitter:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i'), + new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name|itemprop)=["'](?:og:|twitter:)?${property}["']`, 'i'), + ]; + + for (const pattern of patterns) { + const match = html.match(pattern); + if (match?.[1]) { + return decodeHtmlEntities(match[1]); + } + } + + return null; +} + +export async function fetchGenericLinkPreview(url: string): Promise<LinkPreviewData | null> { + try { + const response = await fetch(url, { + headers: { + 'User-Agent': GENERIC_PREVIEW_USER_AGENT, + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + }, + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + return null; + } + + const html = await response.text(); + const title = extractMeta(html, 'title') || html.match(/<title>([^<]+)<\/title>/i)?.[1] || null; + const description = extractMeta(html, 'description'); + const image = extractMeta(html, 'image'); + + return { + url, + title: title?.trim() || null, + description: description?.trim() || null, + image: image?.trim() || null, + type: image?.trim() ? 'image' : 'card', + videoUrl: null, + media: image?.trim() ? [{ url: image.trim() }] : null, + }; + } catch { + return null; + } +} diff --git a/src/lib/media/linkPreview.ts b/src/lib/media/linkPreview.ts new file mode 100644 index 0000000..6f19a92 --- /dev/null +++ b/src/lib/media/linkPreview.ts @@ -0,0 +1,44 @@ +export type LinkPreviewType = 'card' | 'image' | 'gallery' | 'video'; + +export interface LinkPreviewMediaItem { + url: string; + width?: number | null; + height?: number | null; + mimeType?: string | null; +} + +export interface LinkPreviewData { + url: string; + title: string | null; + description: string | null; + image: string | null; + type?: LinkPreviewType | null; + videoUrl?: string | null; + media?: LinkPreviewMediaItem[] | null; +} + +export function parseLinkPreviewMediaJson( + value?: string | null +): LinkPreviewMediaItem[] | undefined { + if (!value) return undefined; + + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) return undefined; + + return parsed.filter((item): item is LinkPreviewMediaItem => ( + item && + typeof item === 'object' && + typeof item.url === 'string' + )); + } catch { + return undefined; + } +} + +export function serializeLinkPreviewMedia( + media?: LinkPreviewMediaItem[] | null +): string | null { + if (!media || media.length === 0) return null; + return JSON.stringify(media); +} diff --git a/src/lib/media/redditPreview.ts b/src/lib/media/redditPreview.ts new file mode 100644 index 0000000..17e84e2 --- /dev/null +++ b/src/lib/media/redditPreview.ts @@ -0,0 +1,62 @@ +import type { LinkPreviewData } from './linkPreview'; + +interface RedditOEmbedResponse { + title?: string; + author_name?: string; + provider_name?: string; + thumbnail_url?: string; + html?: string; +} + +function extractTitleFromHtml(html?: string): string | null { + if (!html) return null; + const titleMatch = html.match(/href="[^"]+">([^<]+)<\/a>/); + if (titleMatch?.[1] && titleMatch[1] !== 'Comment') { + return titleMatch[1]; + } + return null; +} + +function extractSubredditFromHtml(html?: string): string | null { + if (!html) return null; + const subredditMatch = html.match(/r\/([a-zA-Z0-9_]+)/); + return subredditMatch?.[1] || null; +} + +export async function fetchRedditRichPreview(url: string): Promise<LinkPreviewData | null> { + try { + const oembedUrl = `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}`; + const response = await fetch(oembedUrl, { + headers: { + Accept: 'application/json', + 'User-Agent': 'Synapsis Link Preview/1.0', + }, + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + return null; + } + + const data = await response.json() as RedditOEmbedResponse; + const title = data.title || extractTitleFromHtml(data.html) || 'Reddit'; + const subreddit = extractSubredditFromHtml(data.html); + const description = data.author_name + ? `Posted by ${data.author_name}${subreddit ? ` in r/${subreddit}` : ''}` + : subreddit + ? `r/${subreddit}` + : (data.provider_name || 'Reddit'); + + return { + url, + title, + description, + image: null, + type: 'card', + videoUrl: null, + media: null, + }; + } catch { + return null; + } +} diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts new file mode 100644 index 0000000..b004b4b --- /dev/null +++ b/src/lib/notifications.ts @@ -0,0 +1,19 @@ +import { users } from '@/db'; + +type NotificationTargetUser = Pick< + typeof users.$inferSelect, + 'handle' | 'displayName' | 'avatarUrl' | 'isBot' +>; + +export function buildNotificationTarget( + user: NotificationTargetUser, + nodeDomain: string | null = null +) { + return { + targetHandle: user.handle, + targetDisplayName: user.displayName || user.handle, + targetAvatarUrl: user.avatarUrl || null, + targetNodeDomain: nodeDomain, + targetIsBot: user.isBot, + }; +} diff --git a/src/lib/storage/session.ts b/src/lib/storage/session.ts index a869fdd..8bc7ef5 100644 --- a/src/lib/storage/session.ts +++ b/src/lib/storage/session.ts @@ -3,8 +3,8 @@ import { cookies } from 'next/headers'; import type { users } from '@/db'; import { decryptS3Credentials, type StorageProvider } from '@/lib/storage/s3'; -const STORAGE_SESSION_COOKIE = 'synapsis_storage_session'; -const STORAGE_SESSION_TTL_MS = 12 * 60 * 60 * 1000; +const STORAGE_SESSION_COOKIE = 'synapsis_storage_sessions'; +const STORAGE_SESSION_TTL_MS = 3650 * 24 * 60 * 60 * 1000; interface StorageSessionPayload { userId: string; @@ -18,6 +18,8 @@ interface StorageSessionPayload { expiresAt: number; } +type StorageSessionMap = Record<string, StorageSessionPayload>; + function getEncryptionKey(): Buffer { const secret = process.env.AUTH_SECRET; @@ -63,6 +65,55 @@ function decryptPayload(token: string): StorageSessionPayload { return JSON.parse(decrypted) as StorageSessionPayload; } +function encryptPayloadMap(payload: StorageSessionMap): string { + return encryptPayload(payload as unknown as StorageSessionPayload); +} + +function decryptPayloadMap(token: string): StorageSessionMap { + return decryptPayload(token) as unknown as StorageSessionMap; +} + +async function readStorageSessionMap(): Promise<StorageSessionMap> { + const cookieStore = await cookies(); + const token = cookieStore.get(STORAGE_SESSION_COOKIE)?.value; + + if (!token) { + return {}; + } + + try { + const payload = decryptPayloadMap(token); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + await clearStorageSession(); + return {}; + } + + return payload; + } catch { + await clearStorageSession(); + return {}; + } +} + +async function writeStorageSessionMap(payload: StorageSessionMap): Promise<void> { + const cookieStore = await cookies(); + + if (Object.keys(payload).length === 0) { + cookieStore.delete(STORAGE_SESSION_COOKIE); + return; + } + + const maxExpiresAt = Math.max(...Object.values(payload).map(session => session.expiresAt)); + + cookieStore.set(STORAGE_SESSION_COOKIE, encryptPayloadMap(payload), { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + expires: new Date(maxExpiresAt), + }); +} + export async function createStorageSession( user: typeof users.$inferSelect, password: string @@ -73,7 +124,7 @@ export async function createStorageSession( !user.storageSecretKeyEncrypted || !user.storageBucket ) { - await clearStorageSession(); + await clearStorageSession(user.id); return null; } @@ -95,42 +146,42 @@ export async function createStorageSession( expiresAt: Date.now() + STORAGE_SESSION_TTL_MS, }; - const cookieStore = await cookies(); - cookieStore.set(STORAGE_SESSION_COOKIE, encryptPayload(payload), { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - expires: new Date(payload.expiresAt), - }); + const existingSessions = await readStorageSessionMap(); + existingSessions[user.id] = payload; + await writeStorageSessionMap(existingSessions); return payload; } export async function getStorageSession(userId: string): Promise<StorageSessionPayload | null> { - const cookieStore = await cookies(); - const token = cookieStore.get(STORAGE_SESSION_COOKIE)?.value; + const sessions = await readStorageSessionMap(); + const payload = sessions[userId]; - if (!token) { + if (!payload) { return null; } - try { - const payload = decryptPayload(token); - - if (payload.userId !== userId || payload.expiresAt <= Date.now()) { - await clearStorageSession(); - return null; - } - - return payload; - } catch { - await clearStorageSession(); + if (payload.expiresAt <= Date.now()) { + delete sessions[userId]; + await writeStorageSessionMap(sessions); return null; } + + return payload; } -export async function clearStorageSession(): Promise<void> { - const cookieStore = await cookies(); - cookieStore.delete(STORAGE_SESSION_COOKIE); +export async function clearStorageSession(userId?: string): Promise<void> { + if (!userId) { + const cookieStore = await cookies(); + cookieStore.delete(STORAGE_SESSION_COOKIE); + return; + } + + const sessions = await readStorageSessionMap(); + if (!(userId in sessions)) { + return; + } + + delete sessions[userId]; + await writeStorageSessionMap(sessions); } diff --git a/src/lib/swarm/interactions.ts b/src/lib/swarm/interactions.ts index f345b71..48f7a7c 100644 --- a/src/lib/swarm/interactions.ts +++ b/src/lib/swarm/interactions.ts @@ -14,6 +14,8 @@ import { getActiveSwarmNodes } from './registry'; import type { SwarmNodeInfo } from './types'; +import { filterBlockedDomains, isNodeBlocked, normalizeNodeDomain } from './node-blocklist'; +import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview'; // ============================================ // TYPES @@ -141,16 +143,24 @@ export interface SwarmMentionPayload { * Check if a domain is a known Synapsis swarm node */ export async function isSwarmNode(domain: string): Promise<boolean> { + const normalizedDomain = normalizeNodeDomain(domain); + if (await isNodeBlocked(normalizedDomain)) { + return false; + } const nodes = await getActiveSwarmNodes(500); - return nodes.some(n => n.domain === domain); + return nodes.some(n => n.domain === normalizedDomain); } /** * Get swarm node info if the domain is a swarm node */ export async function getSwarmNodeInfo(domain: string): Promise<SwarmNodeInfo | null> { + const normalizedDomain = normalizeNodeDomain(domain); + if (await isNodeBlocked(normalizedDomain)) { + return null; + } const nodes = await getActiveSwarmNodes(500); - return nodes.find(n => n.domain === domain) || null; + return nodes.find(n => n.domain === normalizedDomain) || null; } /** @@ -257,11 +267,19 @@ async function deliverSwarmInteraction( payload: unknown ): Promise<SwarmInteractionResponse> { try { + const normalizedTargetDomain = normalizeNodeDomain(targetDomain); + if (await isNodeBlocked(normalizedTargetDomain)) { + return { + success: false, + error: `Blocked node: ${normalizedTargetDomain}`, + }; + } + const baseUrl = targetDomain.startsWith('http') ? targetDomain - : targetDomain.startsWith('localhost') || targetDomain.startsWith('127.0.0.1') - ? `http://${targetDomain}` - : `https://${targetDomain}`; + : normalizedTargetDomain.startsWith('localhost') || normalizedTargetDomain.startsWith('127.0.0.1') + ? `http://${normalizedTargetDomain}` + : `https://${normalizedTargetDomain}`; const url = `${baseUrl}${endpoint}`; @@ -334,17 +352,31 @@ export interface SwarmUserProfile { export interface SwarmUserPost { id: string; + originalPostId?: string; content: string; createdAt: string; isNsfw: boolean; likesCount: number; repostsCount: number; repliesCount: number; + nodeDomain?: string; + author?: { + handle: string; + displayName?: string; + avatarUrl?: string; + isBot?: boolean; + nodeDomain?: string; + }; media?: { url: string; mimeType?: string; altText?: string }[]; linkPreviewUrl?: string; linkPreviewTitle?: string; linkPreviewDescription?: string; linkPreviewImage?: string; + linkPreviewType?: 'card' | 'image' | 'gallery' | 'video'; + linkPreviewVideoUrl?: string; + linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }>; + repostOfId?: string; + repostOf?: SwarmUserPost | null; } export interface SwarmProfileResponse { @@ -363,11 +395,16 @@ export async function fetchSwarmUserProfile( postsLimit: number = 25 ): Promise<SwarmProfileResponse | null> { try { + const normalizedDomain = normalizeNodeDomain(domain); + if (await isNodeBlocked(normalizedDomain)) { + return null; + } + const baseUrl = domain.startsWith('http') ? domain - : domain.startsWith('localhost') || domain.startsWith('127.0.0.1') - ? `http://${domain}` - : `https://${domain}`; + : normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1') + ? `http://${normalizedDomain}` + : `https://${normalizedDomain}`; const url = `${baseUrl}/api/swarm/users/${handle}?limit=${postsLimit}`; @@ -449,6 +486,9 @@ export async function cacheSwarmUserPosts( linkPreviewTitle: post.linkPreviewTitle || null, linkPreviewDescription: post.linkPreviewDescription || null, linkPreviewImage: post.linkPreviewImage || null, + linkPreviewType: post.linkPreviewType || null, + linkPreviewVideoUrl: post.linkPreviewVideoUrl || null, + linkPreviewMediaJson: serializeLinkPreviewMedia(post.linkPreviewMedia), mediaJson: post.media ? JSON.stringify(post.media) : null, }); @@ -470,11 +510,16 @@ export async function fetchSwarmPost( domain: string ): Promise<SwarmUserPost | null> { try { + const normalizedDomain = normalizeNodeDomain(domain); + if (await isNodeBlocked(normalizedDomain)) { + return null; + } + const baseUrl = domain.startsWith('http') ? domain - : domain.startsWith('localhost') || domain.startsWith('127.0.0.1') - ? `http://${domain}` - : `https://${domain}`; + : normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1') + ? `http://${normalizedDomain}` + : `https://${normalizedDomain}`; const url = `${baseUrl}/api/swarm/posts/${postId}`; @@ -591,6 +636,9 @@ export interface SwarmPostDeliveryPayload { linkPreviewTitle?: string; linkPreviewDescription?: string; linkPreviewImage?: string; + linkPreviewType?: 'card' | 'image' | 'gallery' | 'video'; + linkPreviewVideoUrl?: string; + linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }>; }; author: { handle: string; @@ -637,7 +685,7 @@ export async function getSwarmFollowerDomains(userId: string): Promise<string[]> return match ? match[1] : null; }).filter((d): d is string => d !== null); - return [...new Set(domains)]; + return await filterBlockedDomains([...new Set(domains)]); } catch (error) { console.error('[Swarm] Error getting swarm follower domains:', error); return []; @@ -660,6 +708,9 @@ export async function deliverPostToSwarmFollowers( linkPreviewTitle?: string | null; linkPreviewDescription?: string | null; linkPreviewImage?: string | null; + linkPreviewType?: string | null; + linkPreviewVideoUrl?: string | null; + linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }> | null; }, author: { handle: string; @@ -693,6 +744,9 @@ export async function deliverPostToSwarmFollowers( linkPreviewTitle: post.linkPreviewTitle || undefined, linkPreviewDescription: post.linkPreviewDescription || undefined, linkPreviewImage: post.linkPreviewImage || undefined, + linkPreviewType: (post.linkPreviewType as SwarmPostDeliveryPayload['post']['linkPreviewType']) || undefined, + linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined, + linkPreviewMedia: post.linkPreviewMedia || undefined, }, author: { handle: author.handle, diff --git a/src/lib/swarm/node-blocklist.ts b/src/lib/swarm/node-blocklist.ts new file mode 100644 index 0000000..20183ff --- /dev/null +++ b/src/lib/swarm/node-blocklist.ts @@ -0,0 +1,124 @@ +import { db, swarmNodes } from '@/db'; +import { and, eq, inArray } from 'drizzle-orm'; + +export function normalizeNodeDomain(domain: string): string { + return domain + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .replace(/\/.*$/, '') + .replace(/^@/, ''); +} + +export async function isNodeBlocked(domain: string | null | undefined): Promise<boolean> { + if (!db || !domain) return false; + + const normalized = normalizeNodeDomain(domain); + if (!normalized) return false; + + const node = await db.query.swarmNodes.findFirst({ + where: eq(swarmNodes.domain, normalized), + columns: { + isBlocked: true, + }, + }); + + return Boolean(node?.isBlocked); +} + +export async function getBlockedNodeDomains(): Promise<Set<string>> { + if (!db) return new Set(); + + const rows = await db.query.swarmNodes.findMany({ + where: eq(swarmNodes.isBlocked, true), + columns: { + domain: true, + }, + }); + + return new Set(rows.map((row) => row.domain)); +} + +export async function filterBlockedDomains(domains: string[]): Promise<string[]> { + if (!db || domains.length === 0) return domains; + + const normalized = Array.from(new Set(domains.map(normalizeNodeDomain).filter(Boolean))); + if (normalized.length === 0) return []; + + const blocked = await db.query.swarmNodes.findMany({ + where: and( + inArray(swarmNodes.domain, normalized), + eq(swarmNodes.isBlocked, true), + ), + columns: { + domain: true, + }, + }); + + const blockedSet = new Set(blocked.map((row) => row.domain)); + return normalized.filter((domain) => !blockedSet.has(domain)); +} + +export async function upsertBlockedNode(domain: string, reason?: string | null) { + if (!db) return null; + + const normalized = normalizeNodeDomain(domain); + if (!normalized) return null; + + const existing = await db.query.swarmNodes.findFirst({ + where: eq(swarmNodes.domain, normalized), + }); + + if (existing) { + const [updated] = await db.update(swarmNodes) + .set({ + isBlocked: true, + blockReason: reason || null, + blockedAt: new Date(), + isActive: false, + updatedAt: new Date(), + }) + .where(eq(swarmNodes.id, existing.id)) + .returning(); + + return updated; + } + + const [created] = await db.insert(swarmNodes) + .values({ + domain: normalized, + isBlocked: true, + blockReason: reason || null, + blockedAt: new Date(), + isActive: false, + trustScore: 0, + }) + .returning(); + + return created; +} + +export async function unblockNode(domain: string) { + if (!db) return null; + + const normalized = normalizeNodeDomain(domain); + if (!normalized) return null; + + const existing = await db.query.swarmNodes.findFirst({ + where: eq(swarmNodes.domain, normalized), + }); + + if (!existing) return null; + + const [updated] = await db.update(swarmNodes) + .set({ + isBlocked: false, + blockReason: null, + blockedAt: null, + updatedAt: new Date(), + }) + .where(eq(swarmNodes.id, existing.id)) + .returning(); + + return updated; +} diff --git a/src/lib/swarm/registry.ts b/src/lib/swarm/registry.ts index 5416321..1b93fd7 100644 --- a/src/lib/swarm/registry.ts +++ b/src/lib/swarm/registry.ts @@ -8,6 +8,7 @@ import { db, swarmNodes, swarmSeeds, swarmSyncLog } from '@/db'; import { eq, desc, and, gt, lt, sql } from 'drizzle-orm'; import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types'; import { SWARM_CONFIG, DEFAULT_SEED_NODES } from './types'; +import { normalizeNodeDomain } from './node-blocklist'; /** * Get or create a swarm node entry @@ -21,14 +22,16 @@ export async function upsertSwarmNode( } const existing = await db.query.swarmNodes.findFirst({ - where: eq(swarmNodes.domain, node.domain), + where: eq(swarmNodes.domain, normalizeNodeDomain(node.domain)), }); + const normalizedDomain = normalizeNodeDomain(node.domain); + const capabilities = node.capabilities ? JSON.stringify(node.capabilities) : null; if (!existing) { await db.insert(swarmNodes).values({ - domain: node.domain, + domain: normalizedDomain, name: node.name, description: node.description, logoUrl: node.logoUrl, @@ -58,10 +61,10 @@ export async function upsertSwarmNode( capabilities: capabilities ?? existing.capabilities, lastSeenAt: new Date(), consecutiveFailures: 0, - isActive: true, + isActive: existing.isBlocked ? false : true, updatedAt: new Date(), }) - .where(eq(swarmNodes.domain, node.domain)); + .where(eq(swarmNodes.domain, normalizedDomain)); return { isNew: false }; } @@ -83,8 +86,10 @@ export async function upsertSwarmNodes( // Filter out our own domain const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN; const filteredNodes = nodes.filter(n => n.domain !== ourDomain); + const normalizedOurDomain = ourDomain ? normalizeNodeDomain(ourDomain) : null; + const safeNodes = filteredNodes.filter(n => normalizeNodeDomain(n.domain) !== normalizedOurDomain); - for (const node of filteredNodes) { + for (const node of safeNodes) { const result = await upsertSwarmNode(node, discoveredVia); if (result.isNew) { added++; @@ -105,7 +110,10 @@ export async function getActiveSwarmNodes(limit = 100): Promise<SwarmNodeInfo[]> } const nodes = await db.query.swarmNodes.findMany({ - where: eq(swarmNodes.isActive, true), + where: and( + eq(swarmNodes.isActive, true), + eq(swarmNodes.isBlocked, false), + ), orderBy: [desc(swarmNodes.lastSeenAt)], limit, }); @@ -125,6 +133,7 @@ export async function getNodesForGossip(count: number): Promise<SwarmNodeInfo[]> const nodes = await db.query.swarmNodes.findMany({ where: and( eq(swarmNodes.isActive, true), + eq(swarmNodes.isBlocked, false), gt(swarmNodes.trustScore, 20) ), orderBy: sql`RANDOM()`, @@ -143,7 +152,10 @@ export async function getNodesSince(since: Date, limit = 100): Promise<SwarmNode } const nodes = await db.query.swarmNodes.findMany({ - where: gt(swarmNodes.updatedAt, since), + where: and( + gt(swarmNodes.updatedAt, since), + eq(swarmNodes.isBlocked, false), + ), orderBy: [desc(swarmNodes.updatedAt)], limit, }); @@ -177,7 +189,7 @@ export async function markNodeFailure(domain: string): Promise<void> { .set({ consecutiveFailures: newFailures, trustScore: newTrust, - isActive, + isActive: node.isBlocked ? false : isActive, updatedAt: new Date(), }) .where(eq(swarmNodes.domain, domain)); @@ -211,7 +223,7 @@ export async function markNodeSuccess(domain: string): Promise<void> { .set({ consecutiveFailures: 0, trustScore: newTrust, - isActive: true, + isActive: node.isBlocked ? false : true, lastSeenAt: new Date(), lastSyncAt: new Date(), updatedAt: new Date(), diff --git a/src/lib/swarm/remote-profile-posts.ts b/src/lib/swarm/remote-profile-posts.ts new file mode 100644 index 0000000..cb8ad96 --- /dev/null +++ b/src/lib/swarm/remote-profile-posts.ts @@ -0,0 +1,62 @@ +export const parseRemoteHandle = (handle: string) => { + const clean = handle.toLowerCase().replace(/^@/, ''); + const parts = clean.split('@').filter(Boolean); + if (parts.length === 2) { + return { handle: parts[0], domain: parts[1] }; + } + return null; +}; + +export const getRemoteBaseUrl = (domain: string) => + domain.startsWith('http') + ? domain + : domain.startsWith('localhost') || domain.startsWith('127.0.0.1') + ? `http://${domain}` + : `https://${domain}`; + +type RemoteProfilePost = { + id: string; + originalPostId?: string; + author?: { + id?: string; + handle: string; + displayName?: string | null; + avatarUrl?: string | null; + isBot?: boolean; + }; + nodeDomain?: string | null; + isSwarm?: boolean; + repostOf?: RemoteProfilePost | null; + replyTo?: RemoteProfilePost | null; + media?: Array<{ id?: string; url: string; altText?: string | null; mimeType?: string | null }>; + [key: string]: unknown; +}; + +export function mapRemoteProfilePost(post: RemoteProfilePost, remoteDomain: string): RemoteProfilePost { + const isAlreadySwarm = post.id.startsWith('swarm:'); + const rawOriginalId = post.originalPostId || (isAlreadySwarm ? post.id.split(':').pop() || post.id : post.id); + const effectiveDomain = post.nodeDomain || remoteDomain; + + return { + ...post, + id: isAlreadySwarm ? post.id : `swarm:${effectiveDomain}:${rawOriginalId}`, + originalPostId: rawOriginalId, + isSwarm: true, + nodeDomain: effectiveDomain, + author: post.author ? { + ...post.author, + id: post.author.id?.startsWith('swarm:') + ? post.author.id + : `swarm:${effectiveDomain}:${post.author.handle.includes('@') ? post.author.handle : post.author.handle}`, + handle: post.author.handle.includes('@') + ? post.author.handle + : `${post.author.handle}@${effectiveDomain}`, + } : post.author, + media: post.media?.map((item, index) => ({ + ...item, + id: item.id || `swarm:${effectiveDomain}:${rawOriginalId}:media:${index}`, + })), + repostOf: post.repostOf ? mapRemoteProfilePost(post.repostOf, remoteDomain) : post.repostOf, + replyTo: post.replyTo ? mapRemoteProfilePost(post.replyTo, remoteDomain) : post.replyTo, + }; +} diff --git a/src/lib/swarm/reposts.ts b/src/lib/swarm/reposts.ts new file mode 100644 index 0000000..837d56f --- /dev/null +++ b/src/lib/swarm/reposts.ts @@ -0,0 +1,40 @@ +export interface SwarmRepostTarget { + id: string; + nodeDomain: string; + originalPostId: string; +} + +export async function getViewerSwarmRepostedPostIds( + targets: SwarmRepostTarget[], + viewerId: string +): Promise<Set<string>> { + const repostedIds = new Set<string>(); + + if (!targets.length || !viewerId) { + return repostedIds; + } + + const { db, userSwarmReposts } = await import('@/db'); + const { and, eq, inArray } = await import('drizzle-orm'); + + const domains = Array.from(new Set(targets.map((target) => target.nodeDomain))); + const originalPostIds = Array.from(new Set(targets.map((target) => target.originalPostId))); + + const rows = await db.query.userSwarmReposts.findMany({ + where: and( + eq(userSwarmReposts.userId, viewerId), + inArray(userSwarmReposts.nodeDomain, domains), + inArray(userSwarmReposts.originalPostId, originalPostIds), + ), + }); + + const rowKeys = new Set(rows.map((row) => `${row.nodeDomain}:${row.originalPostId}`)); + + for (const target of targets) { + if (rowKeys.has(`${target.nodeDomain}:${target.originalPostId}`)) { + repostedIds.add(target.id); + } + } + + return repostedIds; +} diff --git a/src/lib/swarm/signature.ts b/src/lib/swarm/signature.ts index 5cb8cb3..0a218c4 100644 --- a/src/lib/swarm/signature.ts +++ b/src/lib/swarm/signature.ts @@ -10,6 +10,7 @@ import crypto from 'crypto'; import { db, users } from '@/db'; import { eq } from 'drizzle-orm'; import { canonicalize } from '@/lib/crypto/user-signing'; +import { isNodeBlocked, normalizeNodeDomain } from './node-blocklist'; /** * Sign a payload with the node's private key @@ -56,14 +57,21 @@ export function verifySignature(payload: any, signature: string, publicKey: stri */ export async function getNodePublicKey(domain: string): Promise<string | null> { try { + const normalizedDomain = normalizeNodeDomain(domain); + if (await isNodeBlocked(normalizedDomain)) { + console.warn(`[Signature] Refusing public key fetch for blocked node ${normalizedDomain}`); + return null; + } + // Check if we have a cached node info - const protocol = domain.includes('localhost') ? 'http' : 'https'; - const response = await fetch(`${protocol}://${domain}/api/node`, { + const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https'; + const response = await fetch(`${protocol}://${normalizedDomain}/api/node`, { headers: { 'Accept': 'application/json' }, + signal: AbortSignal.timeout(5000), }); if (!response.ok) { - console.error(`[Signature] Failed to fetch node info from ${domain}: ${response.status}`); + console.error(`[Signature] Failed to fetch node info from ${normalizedDomain}: ${response.status}`); return null; } @@ -88,8 +96,14 @@ export async function verifySwarmRequest( signature: string, senderDomain: string ): Promise<boolean> { + const normalizedDomain = normalizeNodeDomain(senderDomain); + if (await isNodeBlocked(normalizedDomain)) { + console.warn(`[Signature] Rejected blocked node ${normalizedDomain}`); + return false; + } + // Get the sender node's public key - const publicKey = await getNodePublicKey(senderDomain); + const publicKey = await getNodePublicKey(normalizedDomain); if (!publicKey) { console.error(`[Signature] Could not get public key for ${senderDomain}`); @@ -118,8 +132,14 @@ export async function verifyUserInteraction( userDomain: string ): Promise<boolean> { try { + const normalizedDomain = normalizeNodeDomain(userDomain); + if (await isNodeBlocked(normalizedDomain)) { + console.warn(`[Signature] Rejected user interaction from blocked node ${normalizedDomain}`); + return false; + } + // Try to get cached user - const fullHandle = `${userHandle}@${userDomain}`; + const fullHandle = `${userHandle}@${normalizedDomain}`; let user = await db?.query.users.findFirst({ where: eq(users.handle, fullHandle), }); @@ -130,11 +150,14 @@ export async function verifyUserInteraction( publicKey = user.publicKey; } else { // Fetch from remote node - const protocol = userDomain.includes('localhost') ? 'http' : 'https'; - const response = await fetch(`${protocol}://${userDomain}/api/users/${userHandle}`); + const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https'; + const response = await fetch(`${protocol}://${normalizedDomain}/api/users/${userHandle}`, { + headers: { 'Accept': 'application/json' }, + signal: AbortSignal.timeout(5000), + }); if (!response.ok) { - console.error(`[Signature] Failed to fetch user ${userHandle}@${userDomain}: ${response.status}`); + console.error(`[Signature] Failed to fetch user ${userHandle}@${normalizedDomain}: ${response.status}`); return false; } @@ -144,7 +167,7 @@ export async function verifyUserInteraction( // Cache the user if we don't have them if (!user && publicKey && db) { await db.insert(users).values({ - did: userData.user?.did || `did:swarm:${userDomain}:${userHandle}`, + did: userData.user?.did || `did:swarm:${normalizedDomain}:${userHandle}`, handle: fullHandle, displayName: userData.user?.displayName || userHandle, avatarUrl: userData.user?.avatarUrl, diff --git a/src/lib/swarm/timeline.ts b/src/lib/swarm/timeline.ts index 293943c..273071a 100644 --- a/src/lib/swarm/timeline.ts +++ b/src/lib/swarm/timeline.ts @@ -6,6 +6,8 @@ import { getActiveSwarmNodes } from './registry'; import type { SwarmPost } from '@/app/api/swarm/timeline/route'; +import { filterBlockedDomains, isNodeBlocked, normalizeNodeDomain } from './node-blocklist'; +import type { LinkPreviewData } from '@/lib/media/linkPreview'; interface TimelineResult { posts: SwarmPost[]; @@ -41,12 +43,7 @@ function extractFirstUrl(content: string): string | null { /** * Fetch link preview for a URL */ -async function fetchLinkPreview(url: string): Promise<{ - url: string; - title: string | null; - description: string | null; - image: string | null; -} | null> { +async function fetchLinkPreview(url: string): Promise<LinkPreviewData | null> { try { const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; const protocol = nodeDomain === 'localhost' ? 'http' : 'https'; @@ -70,6 +67,9 @@ async function fetchLinkPreview(url: string): Promise<{ title: data.title || null, description: data.description || null, image: data.image || null, + type: data.type || null, + videoUrl: data.videoUrl || null, + media: data.media || null, }; } catch { return null; @@ -101,6 +101,9 @@ async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise<SwarmPost[]> linkPreviewTitle: preview.title || undefined, linkPreviewDescription: preview.description || undefined, linkPreviewImage: preview.image || undefined, + linkPreviewType: preview.type || undefined, + linkPreviewVideoUrl: preview.videoUrl || undefined, + linkPreviewMedia: preview.media || undefined, }; }); @@ -116,14 +119,19 @@ async function fetchNodeTimeline( cursor?: string ): Promise<{ posts: SwarmPost[]; nodeIsNsfw?: boolean; error?: string }> { try { + const normalizedDomain = normalizeNodeDomain(domain); + if (await isNodeBlocked(normalizedDomain)) { + return { posts: [], error: 'Blocked node' }; + } + // Determine protocol - use http for localhost, https for everything else let baseUrl: string; if (domain.startsWith('http')) { baseUrl = domain; - } else if (domain.startsWith('localhost') || domain.startsWith('127.0.0.1')) { - baseUrl = `http://${domain}`; + } else if (normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1')) { + baseUrl = `http://${normalizedDomain}`; } else { - baseUrl = `https://${domain}`; + baseUrl = `https://${normalizedDomain}`; } const url = `${baseUrl}/api/swarm/timeline?limit=${limit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`; @@ -166,13 +174,14 @@ export async function fetchSwarmTimeline( const nodes = await getActiveSwarmNodes(maxNodes); // Always include our own posts - const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; + const ourDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'); // Always query all nodes - we filter posts, not nodes - const nodesToQuery = [ + const candidateDomains = [ ourDomain, ...nodes.map(n => n.domain).filter(d => d !== ourDomain) - ].slice(0, maxNodes); + ]; + const nodesToQuery = (await filterBlockedDomains(candidateDomains)).slice(0, maxNodes); console.log(`[Swarm Timeline] Querying ${nodesToQuery.length} nodes: ${nodesToQuery.join(', ')}`); console.log(`[Swarm Timeline] includeNsfw: ${includeNsfw}, cursor: ${cursor || 'none'}`); diff --git a/src/lib/types.ts b/src/lib/types.ts index f859f67..0549ef3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -40,6 +40,13 @@ export interface Attachment { altText?: string | null; } +export interface LinkPreviewMediaItem { + url: string; + width?: number | null; + height?: number | null; + mimeType?: string | null; +} + export interface Post { id: string; content: string; @@ -53,8 +60,13 @@ export interface Post { linkPreviewTitle?: string | null; linkPreviewDescription?: string | null; linkPreviewImage?: string | null; + linkPreviewType?: 'card' | 'image' | 'gallery' | 'video' | null; + linkPreviewVideoUrl?: string | null; + linkPreviewMedia?: LinkPreviewMediaItem[] | null; replyTo?: Post | null; replyToId?: string | null; + repostOf?: Post | null; + repostOfId?: string | null; // Swarm reply info (when replying to a post on another node) swarmReplyToId?: string | null; swarmReplyToContent?: string | null;