feat: Implement core API endpoints, database schema, ActivityPub federation, and initial user interface for the application.
This commit is contained in:
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -1,36 +1,108 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# Project Synapsis
|
||||||
|
|
||||||
## Getting Started
|
An open-source, federated social network designed as global communication infrastructure.
|
||||||
|
|
||||||
First, run the development server:
|
## Features
|
||||||
|
|
||||||
```bash
|
- **Federation**: ActivityPub-compatible, interoperable with Mastodon and other fediverse platforms
|
||||||
npm run dev
|
- **Global Identity**: Portable identity backed by DIDs - your handle works everywhere
|
||||||
# or
|
- **Easy Deployment**: Deploy to Vercel with one click, no Docker or complex setup required
|
||||||
yarn dev
|
- **Modern UX**: Clean, Vercel-inspired dark theme with responsive design
|
||||||
# or
|
- **Transparent Feeds**: Chronological and curated feeds with clear algorithms
|
||||||
pnpm dev
|
- **Moderation**: Admin dashboard for reports, post removals, and user actions
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
## Quick Start
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
### Prerequisites
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
- Node.js 18+
|
||||||
|
- A Neon PostgreSQL database (free tier available at https://neon.tech)
|
||||||
|
- An Upstash Redis database (free tier available at https://upstash.com)
|
||||||
|
|
||||||
## Learn More
|
### Setup
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/your-username/synapsis.git
|
||||||
|
cd synapsis
|
||||||
|
```
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
2. Install dependencies:
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
3. Copy the environment example and configure:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
## Deploy on Vercel
|
4. Update `.env` with your database credentials:
|
||||||
|
```
|
||||||
|
DATABASE_URL=postgresql://...
|
||||||
|
UPSTASH_REDIS_REST_URL=https://...
|
||||||
|
UPSTASH_REDIS_REST_TOKEN=...
|
||||||
|
AUTH_SECRET=generate-a-random-string
|
||||||
|
NEXT_PUBLIC_NODE_DOMAIN=your-domain.com
|
||||||
|
NEXT_PUBLIC_NODE_NAME=My Synapsis Node
|
||||||
|
ADMIN_EMAILS=comma,separated,emails
|
||||||
|
```
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
5. Run database migrations:
|
||||||
|
```bash
|
||||||
|
npm run db:push
|
||||||
|
```
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
6. Start the development server:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploy to Vercel
|
||||||
|
|
||||||
|
[](https://vercel.com/new/clone?repository-url=https://github.com/your-username/synapsis)
|
||||||
|
|
||||||
|
1. Click the button above
|
||||||
|
2. Connect your Neon PostgreSQL and Upstash Redis
|
||||||
|
3. Configure environment variables
|
||||||
|
4. Deploy!
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Frontend**: Next.js 14+ with App Router
|
||||||
|
- **Backend**: Next.js API Routes (serverless)
|
||||||
|
- **Database**: PostgreSQL via Neon
|
||||||
|
- **Cache**: Redis via Upstash
|
||||||
|
- **Federation**: ActivityPub protocol
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `POST /api/auth/register` - Create account
|
||||||
|
- `POST /api/auth/login` - Login
|
||||||
|
- `POST /api/auth/logout` - Logout
|
||||||
|
- `GET /api/auth/me` - Get current user
|
||||||
|
|
||||||
|
### Posts
|
||||||
|
- `POST /api/posts` - Create a post
|
||||||
|
- `GET /api/posts` - Get feed
|
||||||
|
- `POST /api/posts/[id]/like` - Like a post
|
||||||
|
- `DELETE /api/posts/[id]/like` - Unlike a post
|
||||||
|
- `POST /api/posts/[id]/repost` - Repost
|
||||||
|
|
||||||
|
### Federation
|
||||||
|
- `GET /.well-known/webfinger` - WebFinger discovery
|
||||||
|
- `GET /.well-known/nodeinfo` - NodeInfo discovery
|
||||||
|
- `GET /nodeinfo/2.1` - Node metadata
|
||||||
|
- `GET /.well-known/synapsis-handles` - Handle registry export
|
||||||
|
- `POST /api/federation/handles` - Ingest handle registry from peers
|
||||||
|
- `POST /api/federation/gossip` - Pull registry from peer nodes (admin)
|
||||||
|
- `GET /api/handles/resolve` - Resolve handle to DID
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
- `GET /install` - Setup wizard
|
||||||
|
- `GET /api/install/status` - Installation status
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache 2.0 License - see LICENSE file for details.
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/db/schema.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+1396
-4
File diff suppressed because it is too large
Load Diff
+20
-3
@@ -2,22 +2,39 @@
|
|||||||
"name": "synapsis",
|
"name": "synapsis",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@neondatabase/serverless": "^0.10.4",
|
||||||
|
"@upstash/redis": "^1.34.3",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
|
"drizzle-orm": "^0.44.1",
|
||||||
|
"jose": "^6.0.11",
|
||||||
"next": "16.1.4",
|
"next": "16.1.4",
|
||||||
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"drizzle-kit": "^0.31.8",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.4",
|
"eslint-config-next": "16.1.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-brain-circuit-icon lucide-brain-circuit"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M9 13a4.5 4.5 0 0 0 3-4"/><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/><path d="M3.477 10.896a4 4 0 0 1 .585-.396"/><path d="M6 18a4 4 0 0 1-1.967-.516"/><path d="M12 13h4"/><path d="M12 18h6a2 2 0 0 1 2 2v1"/><path d="M12 8h8"/><path d="M16 8V5a2 2 0 0 1 2-2"/><circle cx="16" cy="13" r=".5"/><circle cx="18" cy="3" r=".5"/><circle cx="20" cy="21" r=".5"/><circle cx="20" cy="8" r=".5"/></svg>
|
||||||
|
After Width: | Height: | Size: 746 B |
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "Synapsis",
|
||||||
|
"short_name": "Synapsis",
|
||||||
|
"description": "Federated social network infrastructure",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0a0a0a",
|
||||||
|
"theme_color": "#00d4aa",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
"social",
|
||||||
|
"communication"
|
||||||
|
],
|
||||||
|
"screenshots": [],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Compose",
|
||||||
|
"url": "/?compose=true",
|
||||||
|
"description": "Write a new post"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
const nodeName = process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node';
|
||||||
|
const nodeDescription = process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A Synapsis federated social network node';
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
|
||||||
|
href: `https://${nodeDomain}/nodeinfo/2.1`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, handleRegistry } from '@/db';
|
||||||
|
import { desc, eq, gt } from 'drizzle-orm';
|
||||||
|
import { normalizeHandle } from '@/lib/federation/handles';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ handles: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const handleParam = searchParams.get('handle');
|
||||||
|
const sinceParam = searchParams.get('since');
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500);
|
||||||
|
|
||||||
|
if (handleParam) {
|
||||||
|
const cleanHandle = normalizeHandle(handleParam);
|
||||||
|
const entry = await db.query.handleRegistry.findFirst({
|
||||||
|
where: eq(handleRegistry.handle, cleanHandle),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return NextResponse.json({ handles: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
handles: [{
|
||||||
|
handle: entry.handle,
|
||||||
|
did: entry.did,
|
||||||
|
nodeDomain: entry.nodeDomain,
|
||||||
|
updatedAt: entry.updatedAt,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sinceDate = sinceParam ? new Date(sinceParam) : null;
|
||||||
|
const entries = await db.query.handleRegistry.findMany({
|
||||||
|
where: sinceDate ? gt(handleRegistry.updatedAt, sinceDate) : undefined,
|
||||||
|
orderBy: [desc(handleRegistry.updatedAt)],
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
handles: entries.map((entry) => ({
|
||||||
|
handle: entry.handle,
|
||||||
|
did: entry.did,
|
||||||
|
nodeDomain: entry.nodeDomain,
|
||||||
|
updatedAt: entry.updatedAt,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Handle export error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to export handles' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, users } from '@/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { generateWebFingerResponse, parseWebFingerResource } from '@/lib/activitypub/webfinger';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const resource = searchParams.get('resource');
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing resource parameter' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseWebFingerResource(resource);
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid resource format' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
|
||||||
|
// Check if this is our domain
|
||||||
|
if (parsed.domain !== nodeDomain && parsed.domain !== nodeDomain.replace(/:\d+$/, '')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Resource not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the user
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, parsed.handle.toLowerCase()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'User not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = generateWebFingerResponse(user.handle, nodeDomain);
|
||||||
|
|
||||||
|
return NextResponse.json(response, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/jrd+json',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Default() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
handle: string;
|
||||||
|
displayName: string;
|
||||||
|
bio?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
headerUrl?: string;
|
||||||
|
followersCount: number;
|
||||||
|
followingCount: number;
|
||||||
|
postsCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserSummary {
|
||||||
|
id: string;
|
||||||
|
handle: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
bio?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaItem {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
altText?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
likesCount: number;
|
||||||
|
repostsCount: number;
|
||||||
|
repliesCount: number;
|
||||||
|
author: User;
|
||||||
|
media?: MediaItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const ArrowLeftIcon = () => (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="19" y1="12" x2="5" y2="12" />
|
||||||
|
<polyline points="12 19 5 12 12 5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CalendarIcon = () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const HeartIcon = ({ filled }: { filled?: boolean }) => (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RepeatIcon = () => (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="17 1 21 5 17 9" />
|
||||||
|
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||||
|
<polyline points="7 23 3 19 7 15" />
|
||||||
|
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MessageIcon = () => (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FlagIcon = () => (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M5 5v16" />
|
||||||
|
<path d="M5 5h11l-1 4 1 4H5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
function UserRow({ user }: { user: UserSummary }) {
|
||||||
|
return (
|
||||||
|
<Link href={`/@${user.handle}`} className="user-row">
|
||||||
|
<div className="avatar">
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<img src={user.avatarUrl} alt={user.displayName || user.handle} />
|
||||||
|
) : (
|
||||||
|
(user.displayName || user.handle).charAt(0).toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="user-row-content">
|
||||||
|
<div style={{ fontWeight: 600 }}>{user.displayName || user.handle}</div>
|
||||||
|
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px' }}>@{user.handle}</div>
|
||||||
|
{user.bio && (
|
||||||
|
<div className="user-row-bio">{user.bio}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostCard({ post }: { post: Post }) {
|
||||||
|
const [liked, setLiked] = useState(false);
|
||||||
|
const [reposted, setReposted] = useState(false);
|
||||||
|
const [reporting, setReporting] = useState(false);
|
||||||
|
|
||||||
|
const formatTime = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (minutes < 1) return 'now';
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
if (hours < 24) return `${hours}h`;
|
||||||
|
if (days < 7) return `${days}d`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="post">
|
||||||
|
<div className="post-header">
|
||||||
|
<div className="avatar">
|
||||||
|
{post.author.avatarUrl ? (
|
||||||
|
<img src={post.author.avatarUrl} alt={post.author.displayName} />
|
||||||
|
) : (
|
||||||
|
post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="post-author">
|
||||||
|
<Link href={`/@${post.author.handle}`} className="post-handle">
|
||||||
|
{post.author.displayName || post.author.handle}
|
||||||
|
</Link>
|
||||||
|
<span className="post-time">@{post.author.handle} · {formatTime(post.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="post-content">{post.content}</div>
|
||||||
|
{post.media && post.media.length > 0 && (
|
||||||
|
<div className="post-media-grid">
|
||||||
|
{post.media.map((item) => (
|
||||||
|
<div className="post-media-item" key={item.id}>
|
||||||
|
<img src={item.url} alt={item.altText || 'Post media'} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="post-actions">
|
||||||
|
<button className="post-action">
|
||||||
|
<MessageIcon />
|
||||||
|
<span>{post.repliesCount || ''}</span>
|
||||||
|
</button>
|
||||||
|
<button className={`post-action ${reposted ? 'reposted' : ''}`} onClick={() => setReposted(!reposted)}>
|
||||||
|
<RepeatIcon />
|
||||||
|
<span>{post.repostsCount + (reposted ? 1 : 0) || ''}</span>
|
||||||
|
</button>
|
||||||
|
<button className={`post-action ${liked ? 'liked' : ''}`} onClick={() => setLiked(!liked)}>
|
||||||
|
<HeartIcon filled={liked} />
|
||||||
|
<span>{post.likesCount + (liked ? 1 : 0) || ''}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="post-action"
|
||||||
|
onClick={async () => {
|
||||||
|
if (reporting) return;
|
||||||
|
const reason = window.prompt('Why are you reporting this post?');
|
||||||
|
if (!reason) return;
|
||||||
|
setReporting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/reports', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ targetType: 'post', targetId: post.id, reason }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
alert('Please log in to report.');
|
||||||
|
} else {
|
||||||
|
alert('Report failed. Please try again.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert('Report submitted. Thank you.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert('Report failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setReporting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={reporting}
|
||||||
|
>
|
||||||
|
<FlagIcon />
|
||||||
|
<span>{reporting ? '...' : ''}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const params = useParams();
|
||||||
|
const handle = (params.handle as string)?.replace(/^@/, '') || '';
|
||||||
|
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
|
const [currentUser, setCurrentUser] = useState<{ id: string; handle: string } | null>(null);
|
||||||
|
const [isFollowing, setIsFollowing] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState<'posts' | 'followers' | 'following'>('posts');
|
||||||
|
const [followers, setFollowers] = useState<UserSummary[]>([]);
|
||||||
|
const [following, setFollowing] = useState<UserSummary[]>([]);
|
||||||
|
const [followersLoading, setFollowersLoading] = useState(false);
|
||||||
|
const [followingLoading, setFollowingLoading] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [profileForm, setProfileForm] = useState({
|
||||||
|
displayName: '',
|
||||||
|
bio: '',
|
||||||
|
avatarUrl: '',
|
||||||
|
headerUrl: '',
|
||||||
|
});
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setSaveError(null);
|
||||||
|
setFollowers([]);
|
||||||
|
setFollowing([]);
|
||||||
|
// Get current user
|
||||||
|
fetch('/api/auth/me')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setCurrentUser(data.user))
|
||||||
|
.catch(() => { });
|
||||||
|
|
||||||
|
// Get profile
|
||||||
|
fetch(`/api/users/${handle}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setUser(data.user);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
|
||||||
|
// Get posts
|
||||||
|
fetch(`/api/users/${handle}/posts`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setPosts(data.posts || []))
|
||||||
|
.catch(() => { });
|
||||||
|
}, [handle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && currentUser?.handle === user.handle) {
|
||||||
|
setProfileForm({
|
||||||
|
displayName: user.displayName || '',
|
||||||
|
bio: user.bio || '',
|
||||||
|
avatarUrl: user.avatarUrl || '',
|
||||||
|
headerUrl: user.headerUrl || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user, currentUser]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser || !user || currentUser.handle === user.handle) {
|
||||||
|
setIsFollowing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/api/users/${handle}/follow`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setIsFollowing(!!data.following))
|
||||||
|
.catch(() => setIsFollowing(false));
|
||||||
|
}, [currentUser, user, handle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'followers') {
|
||||||
|
setFollowersLoading(true);
|
||||||
|
fetch(`/api/users/${handle}/followers`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setFollowers(data.followers || []))
|
||||||
|
.catch(() => setFollowers([]))
|
||||||
|
.finally(() => setFollowersLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === 'following') {
|
||||||
|
setFollowingLoading(true);
|
||||||
|
fetch(`/api/users/${handle}/following`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setFollowing(data.following || []))
|
||||||
|
.catch(() => setFollowing([]))
|
||||||
|
.finally(() => setFollowingLoading(false));
|
||||||
|
}
|
||||||
|
}, [activeTab, handle]);
|
||||||
|
|
||||||
|
const handleFollow = async () => {
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const method = isFollowing ? 'DELETE' : 'POST';
|
||||||
|
const res = await fetch(`/api/users/${handle}/follow`, { method });
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setIsFollowing(!isFollowing);
|
||||||
|
if (user) {
|
||||||
|
setUser({
|
||||||
|
...user,
|
||||||
|
followersCount: isFollowing ? user.followersCount - 1 : user.followersCount + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveProfile = async () => {
|
||||||
|
if (!isOwnProfile) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(profileForm),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to update profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(data.user);
|
||||||
|
setIsEditing(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Profile update failed', error);
|
||||||
|
setSaveError('Unable to update profile. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--foreground-tertiary)',
|
||||||
|
}}>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
}}>
|
||||||
|
<h1 style={{ fontSize: '24px', fontWeight: 600 }}>User not found</h1>
|
||||||
|
<Link href="/" className="btn btn-primary">Go home</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwnProfile = currentUser?.handle === user.handle;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '600px', margin: '0 auto', minHeight: '100vh' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<header style={{
|
||||||
|
padding: '16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '24px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
background: 'var(--background)',
|
||||||
|
zIndex: 10,
|
||||||
|
}}>
|
||||||
|
<Link href="/" style={{ color: 'var(--foreground)' }}>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>{user.displayName || user.handle}</h1>
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>{user.postsCount} posts</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Profile Header */}
|
||||||
|
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
{/* Banner */}
|
||||||
|
<div style={{
|
||||||
|
height: '150px',
|
||||||
|
background: user.headerUrl
|
||||||
|
? `url(${user.headerUrl}) center/cover`
|
||||||
|
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Avatar & Actions */}
|
||||||
|
<div style={{ padding: '0 16px' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
marginTop: '-48px',
|
||||||
|
}}>
|
||||||
|
<div className="avatar avatar-lg" style={{
|
||||||
|
width: '96px',
|
||||||
|
height: '96px',
|
||||||
|
fontSize: '36px',
|
||||||
|
border: '4px solid var(--background)',
|
||||||
|
}}>
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<img src={user.avatarUrl} alt={user.displayName || user.handle} />
|
||||||
|
) : (
|
||||||
|
(user.displayName || user.handle).charAt(0).toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isOwnProfile && currentUser && (
|
||||||
|
<button
|
||||||
|
className={`btn ${isFollowing ? '' : 'btn-primary'}`}
|
||||||
|
onClick={handleFollow}
|
||||||
|
style={{ marginBottom: '12px' }}
|
||||||
|
>
|
||||||
|
{isFollowing ? 'Following' : 'Follow'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOwnProfile && (
|
||||||
|
<button className="btn" style={{ marginBottom: '12px' }} onClick={() => setIsEditing(!isEditing)}>
|
||||||
|
{isEditing ? 'Close' : 'Edit Profile'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Info */}
|
||||||
|
<div style={{ padding: '12px 0' }}>
|
||||||
|
<h2 style={{ fontSize: '20px', fontWeight: 700 }}>{user.displayName || user.handle}</h2>
|
||||||
|
<p style={{ color: 'var(--foreground-tertiary)' }}>@{user.handle}</p>
|
||||||
|
|
||||||
|
{user.bio && (
|
||||||
|
<p style={{ marginTop: '12px', lineHeight: 1.5 }}>{user.bio}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginTop: '12px',
|
||||||
|
color: 'var(--foreground-tertiary)',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
<CalendarIcon />
|
||||||
|
<span>Joined {formatDate(user.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '16px', marginTop: '12px' }}>
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('followers')}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--foreground)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{user.followersCount}</strong>{' '}
|
||||||
|
<span style={{ color: 'var(--foreground-tertiary)' }}>Followers</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOwnProfile && isEditing && (
|
||||||
|
<div style={{ padding: '0 16px 16px' }}>
|
||||||
|
<div className="card" style={{ padding: '16px' }}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: '12px' }}>Edit profile</div>
|
||||||
|
<div style={{ display: 'grid', gap: '12px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Display name</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={profileForm.displayName}
|
||||||
|
onChange={(e) => setProfileForm({ ...profileForm, displayName: e.target.value })}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Bio</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
value={profileForm.bio}
|
||||||
|
onChange={(e) => setProfileForm({ ...profileForm, bio: e.target.value })}
|
||||||
|
maxLength={160}
|
||||||
|
style={{ minHeight: '80px', resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Avatar URL</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={profileForm.avatarUrl}
|
||||||
|
onChange={(e) => setProfileForm({ ...profileForm, avatarUrl: e.target.value })}
|
||||||
|
placeholder="https://"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Header URL</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={profileForm.headerUrl}
|
||||||
|
onChange={(e) => setProfileForm({ ...profileForm, headerUrl: e.target.value })}
|
||||||
|
placeholder="https://"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{saveError && (
|
||||||
|
<div style={{ color: 'var(--error)', fontSize: '13px' }}>{saveError}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||||
|
<button className="btn btn-ghost" onClick={() => setIsEditing(false)} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={handleSaveProfile} disabled={isSaving}>
|
||||||
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div style={{ display: 'flex', borderTop: '1px solid var(--border)' }}>
|
||||||
|
{(['posts', 'followers', 'following'] as const).map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '16px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: activeTab === tab ? '2px solid var(--accent)' : '2px solid transparent',
|
||||||
|
color: activeTab === tab ? 'var(--foreground)' : 'var(--foreground-tertiary)',
|
||||||
|
fontWeight: activeTab === tab ? 600 : 400,
|
||||||
|
cursor: 'pointer',
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{activeTab === 'posts' && (
|
||||||
|
posts.length === 0 ? (
|
||||||
|
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
<p>No posts yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
posts.map(post => <PostCard key={post.id} post={post} />)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'followers' && (
|
||||||
|
followersLoading ? (
|
||||||
|
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
Loading followers...
|
||||||
|
</div>
|
||||||
|
) : followers.length === 0 ? (
|
||||||
|
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
<p>No followers yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{followers.map(follower => (
|
||||||
|
<UserRow key={follower.id} user={follower} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'following' && (
|
||||||
|
followingLoading ? (
|
||||||
|
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
Loading following...
|
||||||
|
</div>
|
||||||
|
) : following.length === 0 ? (
|
||||||
|
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
<p>Not following anyone yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{following.map(userItem => (
|
||||||
|
<UserRow key={userItem.id} user={userItem} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
type AdminUser = {
|
||||||
|
id: string;
|
||||||
|
handle: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
isSuspended: boolean;
|
||||||
|
isSilenced: boolean;
|
||||||
|
suspensionReason?: string | null;
|
||||||
|
silenceReason?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminPost = {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
isRemoved: boolean;
|
||||||
|
removedReason?: string | null;
|
||||||
|
author: {
|
||||||
|
id: string;
|
||||||
|
handle: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type Report = {
|
||||||
|
id: string;
|
||||||
|
targetType: 'post' | 'user';
|
||||||
|
targetId: string;
|
||||||
|
reason: string;
|
||||||
|
status: 'open' | 'resolved';
|
||||||
|
createdAt: string;
|
||||||
|
reporter?: {
|
||||||
|
id: string;
|
||||||
|
handle: string;
|
||||||
|
} | null;
|
||||||
|
target?: AdminPost | AdminUser | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value: string) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
|
||||||
|
const [tab, setTab] = useState<'reports' | 'posts' | 'users'>('reports');
|
||||||
|
const [reports, setReports] = useState<Report[]>([]);
|
||||||
|
const [posts, setPosts] = useState<AdminPost[]>([]);
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [reportStatus, setReportStatus] = useState<'open' | 'resolved' | 'all'>('open');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/admin/me')
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => setIsAdmin(!!data.isAdmin))
|
||||||
|
.catch(() => setIsAdmin(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadReports = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/reports?status=${reportStatus}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setReports(data.reports || []);
|
||||||
|
} catch {
|
||||||
|
setReports([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPosts = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/posts?status=all');
|
||||||
|
const data = await res.json();
|
||||||
|
setPosts(data.posts || []);
|
||||||
|
} catch {
|
||||||
|
setPosts([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/users');
|
||||||
|
const data = await res.json();
|
||||||
|
setUsers(data.users || []);
|
||||||
|
} catch {
|
||||||
|
setUsers([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
if (tab === 'reports') loadReports();
|
||||||
|
if (tab === 'posts') loadPosts();
|
||||||
|
if (tab === 'users') loadUsers();
|
||||||
|
}, [tab, isAdmin, reportStatus]);
|
||||||
|
|
||||||
|
const handleReportResolve = async (id: string, status: 'open' | 'resolved') => {
|
||||||
|
const note = status === 'resolved' ? window.prompt('Resolution note (optional):') || '' : '';
|
||||||
|
await fetch(`/api/admin/reports/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status, note }),
|
||||||
|
});
|
||||||
|
loadReports();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePostAction = async (id: string, action: 'remove' | 'restore') => {
|
||||||
|
const reason = action === 'remove' ? window.prompt('Reason (optional):') || '' : '';
|
||||||
|
await fetch(`/api/admin/posts/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action, reason }),
|
||||||
|
});
|
||||||
|
if (tab === 'reports') {
|
||||||
|
loadReports();
|
||||||
|
} else {
|
||||||
|
loadPosts();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserAction = async (id: string, action: 'suspend' | 'unsuspend' | 'silence' | 'unsilence') => {
|
||||||
|
const needsReason = action === 'suspend' || action === 'silence';
|
||||||
|
const reason = needsReason ? window.prompt('Reason (optional):') || '' : '';
|
||||||
|
await fetch(`/api/admin/users/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action, reason }),
|
||||||
|
});
|
||||||
|
loadUsers();
|
||||||
|
};
|
||||||
|
|
||||||
|
const reportCounts = useMemo(() => {
|
||||||
|
return {
|
||||||
|
open: reports.filter((r) => r.status === 'open').length,
|
||||||
|
resolved: reports.filter((r) => r.status === 'resolved').length,
|
||||||
|
};
|
||||||
|
}, [reports]);
|
||||||
|
|
||||||
|
if (isAdmin === null) {
|
||||||
|
return (
|
||||||
|
<div className="admin-shell">
|
||||||
|
<div className="admin-card">Checking permissions...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="admin-shell">
|
||||||
|
<div className="admin-card">
|
||||||
|
<h1>Moderation</h1>
|
||||||
|
<p>You do not have access to this page.</p>
|
||||||
|
<Link href="/" className="btn btn-primary" style={{ marginTop: '12px' }}>
|
||||||
|
Back to home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-shell">
|
||||||
|
<div className="admin-header">
|
||||||
|
<div>
|
||||||
|
<h1>Moderation Dashboard</h1>
|
||||||
|
<p>Manage reports, posts, and user actions.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/" className="btn btn-ghost">
|
||||||
|
Return to feed
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-tabs">
|
||||||
|
<button className={`admin-tab ${tab === 'reports' ? 'active' : ''}`} onClick={() => setTab('reports')}>
|
||||||
|
Reports
|
||||||
|
</button>
|
||||||
|
<button className={`admin-tab ${tab === 'posts' ? 'active' : ''}`} onClick={() => setTab('posts')}>
|
||||||
|
Posts
|
||||||
|
</button>
|
||||||
|
<button className={`admin-tab ${tab === 'users' ? 'active' : ''}`} onClick={() => setTab('users')}>
|
||||||
|
Users
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'reports' && (
|
||||||
|
<div className="admin-card">
|
||||||
|
<div className="admin-toolbar">
|
||||||
|
<div className="admin-filters">
|
||||||
|
{(['open', 'resolved', 'all'] as const).map((status) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
className={`pill ${reportStatus === status ? 'active' : ''}`}
|
||||||
|
onClick={() => setReportStatus(status)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="admin-stats">
|
||||||
|
<span>Open: {reportCounts.open}</span>
|
||||||
|
<span>Resolved: {reportCounts.resolved}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="admin-empty">Loading reports...</div>
|
||||||
|
) : reports.length === 0 ? (
|
||||||
|
<div className="admin-empty">No reports found.</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-list">
|
||||||
|
{reports.map((report) => (
|
||||||
|
<div key={report.id} className="admin-row">
|
||||||
|
<div className="admin-row-main">
|
||||||
|
<div className="admin-row-title">
|
||||||
|
<span className={`status-pill ${report.status}`}>
|
||||||
|
{report.status}
|
||||||
|
</span>
|
||||||
|
<span className="admin-row-meta">
|
||||||
|
{report.targetType.toUpperCase()} report
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-row-body">
|
||||||
|
{report.reason}
|
||||||
|
</div>
|
||||||
|
<div className="admin-row-sub">
|
||||||
|
Reported by {report.reporter?.handle || 'anonymous'} • {formatDate(report.createdAt)}
|
||||||
|
</div>
|
||||||
|
{report.targetType === 'post' && report.target && 'content' in report.target && (
|
||||||
|
<div className="admin-row-target">
|
||||||
|
<strong>@{report.target.author.handle}:</strong> {report.target.content || '[repost]'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{report.targetType === 'user' && report.target && 'handle' in report.target && (
|
||||||
|
<div className="admin-row-target">
|
||||||
|
User: @{report.target.handle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-row-actions">
|
||||||
|
{report.targetType === 'post' && report.target && 'content' in report.target && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => handlePostAction(report.target.id, report.target.isRemoved ? 'restore' : 'remove')}
|
||||||
|
>
|
||||||
|
{report.target.isRemoved ? 'Restore post' : 'Remove post'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{report.status === 'open' ? (
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => handleReportResolve(report.id, 'resolved')}>
|
||||||
|
Resolve
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => handleReportResolve(report.id, 'open')}>
|
||||||
|
Reopen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'posts' && (
|
||||||
|
<div className="admin-card">
|
||||||
|
{loading ? (
|
||||||
|
<div className="admin-empty">Loading posts...</div>
|
||||||
|
) : posts.length === 0 ? (
|
||||||
|
<div className="admin-empty">No posts found.</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-list">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<div key={post.id} className="admin-row">
|
||||||
|
<div className="admin-row-main">
|
||||||
|
<div className="admin-row-title">
|
||||||
|
<span className={`status-pill ${post.isRemoved ? 'removed' : 'active'}`}>
|
||||||
|
{post.isRemoved ? 'removed' : 'active'}
|
||||||
|
</span>
|
||||||
|
<span className="admin-row-meta">
|
||||||
|
@{post.author.handle} • {formatDate(post.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-row-body">{post.content || '[repost]'}</div>
|
||||||
|
{post.removedReason && (
|
||||||
|
<div className="admin-row-sub">Reason: {post.removedReason}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-row-actions">
|
||||||
|
{post.isRemoved ? (
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => handlePostAction(post.id, 'restore')}>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => handlePostAction(post.id, 'remove')}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'users' && (
|
||||||
|
<div className="admin-card">
|
||||||
|
{loading ? (
|
||||||
|
<div className="admin-empty">Loading users...</div>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<div className="admin-empty">No users found.</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-list">
|
||||||
|
{users.map((user) => (
|
||||||
|
<div key={user.id} className="admin-row">
|
||||||
|
<div className="admin-row-main">
|
||||||
|
<div className="admin-row-title">
|
||||||
|
<span className={`status-pill ${user.isSuspended ? 'suspended' : 'active'}`}>
|
||||||
|
{user.isSuspended ? 'suspended' : 'active'}
|
||||||
|
</span>
|
||||||
|
<span className={`status-pill ${user.isSilenced ? 'silenced' : 'visible'}`}>
|
||||||
|
{user.isSilenced ? 'silenced' : 'visible'}
|
||||||
|
</span>
|
||||||
|
<span className="admin-row-meta">
|
||||||
|
@{user.handle} • {formatDate(user.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-row-body">
|
||||||
|
{user.displayName || user.handle}
|
||||||
|
</div>
|
||||||
|
{user.suspensionReason && (
|
||||||
|
<div className="admin-row-sub">Suspension: {user.suspensionReason}</div>
|
||||||
|
)}
|
||||||
|
{user.silenceReason && (
|
||||||
|
<div className="admin-row-sub">Silence: {user.silenceReason}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-row-actions">
|
||||||
|
<Link href={`/@${user.handle}`} className="btn btn-ghost btn-sm">
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
{user.isSuspended ? (
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => handleUserAction(user.id, 'unsuspend')}>
|
||||||
|
Unsuspend
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => handleUserAction(user.id, 'suspend')}>
|
||||||
|
Suspend
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{user.isSilenced ? (
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => handleUserAction(user.id, 'unsilence')}>
|
||||||
|
Unsilence
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => handleUserAction(user.id, 'silence')}>
|
||||||
|
Silence
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
import { isAdminUser } from '@/lib/auth/admin';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ isAdmin: false, user: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ isAdmin: false, user: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
isAdmin: isAdminUser(session.user),
|
||||||
|
user: {
|
||||||
|
id: session.user.id,
|
||||||
|
handle: session.user.handle,
|
||||||
|
displayName: session.user.displayName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin status error:', error);
|
||||||
|
return NextResponse.json({ isAdmin: false, user: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, posts } from '@/db';
|
||||||
|
import { requireAdmin } from '@/lib/auth/admin';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
const moderationSchema = z.object({
|
||||||
|
action: z.enum(['remove', 'restore']),
|
||||||
|
reason: z.string().max(240).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await context.params;
|
||||||
|
const body = await request.json();
|
||||||
|
const data = moderationSchema.parse(body);
|
||||||
|
|
||||||
|
const post = await db.query.posts.findFirst({
|
||||||
|
where: eq(posts.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.action === 'remove') {
|
||||||
|
const [updated] = await db.update(posts)
|
||||||
|
.set({
|
||||||
|
isRemoved: true,
|
||||||
|
removedAt: new Date(),
|
||||||
|
removedBy: admin.id,
|
||||||
|
removedReason: data.reason || null,
|
||||||
|
})
|
||||||
|
.where(eq(posts.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({ post: updated });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [restored] = await db.update(posts)
|
||||||
|
.set({
|
||||||
|
isRemoved: false,
|
||||||
|
removedAt: null,
|
||||||
|
removedBy: null,
|
||||||
|
removedReason: null,
|
||||||
|
})
|
||||||
|
.where(eq(posts.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({ post: restored });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message === 'Admin required') {
|
||||||
|
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||||
|
}
|
||||||
|
console.error('Post moderation error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to update post' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, posts } from '@/db';
|
||||||
|
import { requireAdmin } from '@/lib/auth/admin';
|
||||||
|
import { desc, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const status = searchParams.get('status') || 'active'; // active | removed | all
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||||
|
|
||||||
|
const where =
|
||||||
|
status === 'active'
|
||||||
|
? eq(posts.isRemoved, false)
|
||||||
|
: status === 'removed'
|
||||||
|
? eq(posts.isRemoved, true)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const results = await db.query.posts.findMany({
|
||||||
|
where,
|
||||||
|
with: {
|
||||||
|
author: true,
|
||||||
|
},
|
||||||
|
orderBy: [desc(posts.createdAt)],
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sanitized = results.map((post) => ({
|
||||||
|
id: post.id,
|
||||||
|
content: post.content,
|
||||||
|
createdAt: post.createdAt,
|
||||||
|
isRemoved: post.isRemoved,
|
||||||
|
removedReason: post.removedReason,
|
||||||
|
author: {
|
||||||
|
id: post.author.id,
|
||||||
|
handle: post.author.handle,
|
||||||
|
displayName: post.author.displayName,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ posts: sanitized });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Admin required') {
|
||||||
|
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||||
|
}
|
||||||
|
console.error('Admin posts error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch posts' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, reports } from '@/db';
|
||||||
|
import { requireAdmin } from '@/lib/auth/admin';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
status: z.enum(['open', 'resolved']),
|
||||||
|
note: z.string().max(240).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await context.params;
|
||||||
|
const body = await request.json();
|
||||||
|
const data = updateSchema.parse(body);
|
||||||
|
|
||||||
|
const report = await db.query.reports.findFirst({
|
||||||
|
where: eq(reports.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return NextResponse.json({ error: 'Report not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db.update(reports)
|
||||||
|
.set({
|
||||||
|
status: data.status,
|
||||||
|
resolvedAt: data.status === 'resolved' ? new Date() : null,
|
||||||
|
resolvedBy: data.status === 'resolved' ? admin.id : null,
|
||||||
|
resolutionNote: data.note || null,
|
||||||
|
})
|
||||||
|
.where(eq(reports.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({ report: updated });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message === 'Admin required') {
|
||||||
|
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||||
|
}
|
||||||
|
console.error('Report update error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to update report' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, reports, posts, users } from '@/db';
|
||||||
|
import { requireAdmin } from '@/lib/auth/admin';
|
||||||
|
import { desc, inArray, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const status = searchParams.get('status') || 'open'; // open | resolved | all
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||||
|
|
||||||
|
const reportRows = await db.query.reports.findMany({
|
||||||
|
where: status === 'all' ? undefined : eq(reports.status, status),
|
||||||
|
orderBy: [desc(reports.createdAt)],
|
||||||
|
limit,
|
||||||
|
with: {
|
||||||
|
reporter: true,
|
||||||
|
resolver: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const postIds = reportRows
|
||||||
|
.filter((report) => report.targetType === 'post')
|
||||||
|
.map((report) => report.targetId);
|
||||||
|
const userIds = reportRows
|
||||||
|
.filter((report) => report.targetType === 'user')
|
||||||
|
.map((report) => report.targetId);
|
||||||
|
|
||||||
|
const postTargetsRaw = postIds.length
|
||||||
|
? await db.query.posts.findMany({
|
||||||
|
where: inArray(posts.id, postIds),
|
||||||
|
with: { author: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const userTargetsRaw = userIds.length
|
||||||
|
? await db.query.users.findMany({
|
||||||
|
where: inArray(users.id, userIds),
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const postTargets = postTargetsRaw.map((post) => ({
|
||||||
|
id: post.id,
|
||||||
|
content: post.content,
|
||||||
|
createdAt: post.createdAt,
|
||||||
|
isRemoved: post.isRemoved,
|
||||||
|
author: {
|
||||||
|
id: post.author.id,
|
||||||
|
handle: post.author.handle,
|
||||||
|
displayName: post.author.displayName,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const userTargets = userTargetsRaw.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
handle: user.handle,
|
||||||
|
displayName: user.displayName,
|
||||||
|
isSuspended: user.isSuspended,
|
||||||
|
isSilenced: user.isSilenced,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const postMap = new Map(postTargets.map((post) => [post.id, post]));
|
||||||
|
const userMap = new Map(userTargets.map((user) => [user.id, user]));
|
||||||
|
|
||||||
|
const reportsWithTargets = reportRows.map((report) => ({
|
||||||
|
id: report.id,
|
||||||
|
targetType: report.targetType,
|
||||||
|
targetId: report.targetId,
|
||||||
|
reason: report.reason,
|
||||||
|
status: report.status,
|
||||||
|
createdAt: report.createdAt,
|
||||||
|
reporter: report.reporter
|
||||||
|
? { id: report.reporter.id, handle: report.reporter.handle }
|
||||||
|
: null,
|
||||||
|
resolver: report.resolver
|
||||||
|
? { id: report.resolver.id, handle: report.resolver.handle }
|
||||||
|
: null,
|
||||||
|
target:
|
||||||
|
report.targetType === 'post'
|
||||||
|
? postMap.get(report.targetId) || null
|
||||||
|
: userMap.get(report.targetId) || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ reports: reportsWithTargets });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Admin required') {
|
||||||
|
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||||
|
}
|
||||||
|
console.error('Admin reports error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch reports' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, users } from '@/db';
|
||||||
|
import { requireAdmin } from '@/lib/auth/admin';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
const moderationSchema = z.object({
|
||||||
|
action: z.enum(['suspend', 'unsuspend', 'silence', 'unsilence']),
|
||||||
|
reason: z.string().max(240).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await context.params;
|
||||||
|
const body = await request.json();
|
||||||
|
const data = moderationSchema.parse(body);
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (user.id === admin.id && (data.action === 'suspend' || data.action === 'silence')) {
|
||||||
|
return NextResponse.json({ error: 'Cannot apply this action to yourself' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.action === 'suspend') {
|
||||||
|
const [updated] = await db.update(users)
|
||||||
|
.set({
|
||||||
|
isSuspended: true,
|
||||||
|
suspendedAt: new Date(),
|
||||||
|
suspensionReason: data.reason || null,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, id))
|
||||||
|
.returning();
|
||||||
|
return NextResponse.json({ user: {
|
||||||
|
id: updated.id,
|
||||||
|
handle: updated.handle,
|
||||||
|
displayName: updated.displayName,
|
||||||
|
email: updated.email,
|
||||||
|
isSuspended: updated.isSuspended,
|
||||||
|
suspensionReason: updated.suspensionReason,
|
||||||
|
isSilenced: updated.isSilenced,
|
||||||
|
silenceReason: updated.silenceReason,
|
||||||
|
createdAt: updated.createdAt,
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.action === 'unsuspend') {
|
||||||
|
const [updated] = await db.update(users)
|
||||||
|
.set({
|
||||||
|
isSuspended: false,
|
||||||
|
suspendedAt: null,
|
||||||
|
suspensionReason: null,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, id))
|
||||||
|
.returning();
|
||||||
|
return NextResponse.json({ user: {
|
||||||
|
id: updated.id,
|
||||||
|
handle: updated.handle,
|
||||||
|
displayName: updated.displayName,
|
||||||
|
email: updated.email,
|
||||||
|
isSuspended: updated.isSuspended,
|
||||||
|
suspensionReason: updated.suspensionReason,
|
||||||
|
isSilenced: updated.isSilenced,
|
||||||
|
silenceReason: updated.silenceReason,
|
||||||
|
createdAt: updated.createdAt,
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.action === 'silence') {
|
||||||
|
const [updated] = await db.update(users)
|
||||||
|
.set({
|
||||||
|
isSilenced: true,
|
||||||
|
silencedAt: new Date(),
|
||||||
|
silenceReason: data.reason || null,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, id))
|
||||||
|
.returning();
|
||||||
|
return NextResponse.json({ user: {
|
||||||
|
id: updated.id,
|
||||||
|
handle: updated.handle,
|
||||||
|
displayName: updated.displayName,
|
||||||
|
email: updated.email,
|
||||||
|
isSuspended: updated.isSuspended,
|
||||||
|
suspensionReason: updated.suspensionReason,
|
||||||
|
isSilenced: updated.isSilenced,
|
||||||
|
silenceReason: updated.silenceReason,
|
||||||
|
createdAt: updated.createdAt,
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db.update(users)
|
||||||
|
.set({
|
||||||
|
isSilenced: false,
|
||||||
|
silencedAt: null,
|
||||||
|
silenceReason: null,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({ user: {
|
||||||
|
id: updated.id,
|
||||||
|
handle: updated.handle,
|
||||||
|
displayName: updated.displayName,
|
||||||
|
email: updated.email,
|
||||||
|
isSuspended: updated.isSuspended,
|
||||||
|
suspensionReason: updated.suspensionReason,
|
||||||
|
isSilenced: updated.isSilenced,
|
||||||
|
silenceReason: updated.silenceReason,
|
||||||
|
createdAt: updated.createdAt,
|
||||||
|
} });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message === 'Admin required') {
|
||||||
|
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||||
|
}
|
||||||
|
console.error('Admin user moderation error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to update user' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, users } from '@/db';
|
||||||
|
import { requireAdmin } from '@/lib/auth/admin';
|
||||||
|
import { desc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||||
|
|
||||||
|
const results = await db.select({
|
||||||
|
id: users.id,
|
||||||
|
handle: users.handle,
|
||||||
|
displayName: users.displayName,
|
||||||
|
email: users.email,
|
||||||
|
isSuspended: users.isSuspended,
|
||||||
|
suspensionReason: users.suspensionReason,
|
||||||
|
isSilenced: users.isSilenced,
|
||||||
|
silenceReason: users.silenceReason,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.orderBy(desc(users.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
return NextResponse.json({ users: results });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Admin required') {
|
||||||
|
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||||
|
}
|
||||||
|
console.error('Admin users error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch users' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { authenticateUser, createSession } from '@/lib/auth';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = loginSchema.parse(body);
|
||||||
|
|
||||||
|
const user = await authenticateUser(data.email, data.password);
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
await createSession(user.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
handle: user.handle,
|
||||||
|
displayName: user.displayName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid input', details: error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Login failed' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { destroySession } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
await destroySession();
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Logout failed' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getSession, requireAuth } from '@/lib/auth';
|
||||||
|
import { db, users } from '@/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const updateProfileSchema = z.object({
|
||||||
|
displayName: z.string().min(1).max(50).optional(),
|
||||||
|
bio: z.string().max(160).optional().nullable(),
|
||||||
|
avatarUrl: z.string().url().optional().nullable(),
|
||||||
|
headerUrl: z.string().url().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Return null user if no database is connected (for UI testing)
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ user: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ user: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: session.user.id,
|
||||||
|
handle: session.user.handle,
|
||||||
|
displayName: session.user.displayName,
|
||||||
|
avatarUrl: session.user.avatarUrl,
|
||||||
|
bio: session.user.bio,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Session check error:', error);
|
||||||
|
return NextResponse.json({ user: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
try {
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await requireAuth();
|
||||||
|
const body = await request.json();
|
||||||
|
const data = updateProfileSchema.parse(body);
|
||||||
|
|
||||||
|
const updateData: {
|
||||||
|
displayName?: string;
|
||||||
|
bio?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
headerUrl?: string | null;
|
||||||
|
updatedAt?: Date;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (data.displayName !== undefined) updateData.displayName = data.displayName;
|
||||||
|
if (data.bio !== undefined) updateData.bio = data.bio === '' ? null : data.bio;
|
||||||
|
if (data.avatarUrl !== undefined) updateData.avatarUrl = data.avatarUrl === '' ? null : data.avatarUrl;
|
||||||
|
if (data.headerUrl !== undefined) updateData.headerUrl = data.headerUrl === '' ? null : data.headerUrl;
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: currentUser.id,
|
||||||
|
handle: currentUser.handle,
|
||||||
|
displayName: currentUser.displayName,
|
||||||
|
avatarUrl: currentUser.avatarUrl,
|
||||||
|
bio: currentUser.bio,
|
||||||
|
headerUrl: currentUser.headerUrl,
|
||||||
|
followersCount: currentUser.followersCount,
|
||||||
|
followingCount: currentUser.followingCount,
|
||||||
|
postsCount: currentUser.postsCount,
|
||||||
|
createdAt: currentUser.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData.updatedAt = new Date();
|
||||||
|
|
||||||
|
const [updatedUser] = await db.update(users)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(users.id, currentUser.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: updatedUser.id,
|
||||||
|
handle: updatedUser.handle,
|
||||||
|
displayName: updatedUser.displayName,
|
||||||
|
avatarUrl: updatedUser.avatarUrl,
|
||||||
|
bio: updatedUser.bio,
|
||||||
|
headerUrl: updatedUser.headerUrl,
|
||||||
|
followersCount: updatedUser.followersCount,
|
||||||
|
followingCount: updatedUser.followingCount,
|
||||||
|
postsCount: updatedUser.postsCount,
|
||||||
|
createdAt: updatedUser.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error('Profile update error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { registerUser, createSession } from '@/lib/auth';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
handle: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
displayName: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = registerSchema.parse(body);
|
||||||
|
|
||||||
|
const user = await registerUser(
|
||||||
|
data.handle,
|
||||||
|
data.email,
|
||||||
|
data.password,
|
||||||
|
data.displayName
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create session for new user
|
||||||
|
await createSession(user.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
handle: user.handle,
|
||||||
|
displayName: user.displayName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Registration error:', error);
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid input', details: error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Registration failed' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { requireAdmin } from '@/lib/auth/admin';
|
||||||
|
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||||
|
|
||||||
|
const gossipSchema = z.object({
|
||||||
|
nodes: z.array(z.string().min(1)).min(1),
|
||||||
|
since: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const data = gossipSchema.parse(body);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const node of data.nodes) {
|
||||||
|
const baseUrl = node.startsWith('http') ? node : `https://${node}`;
|
||||||
|
const url = new URL('/.well-known/synapsis-handles', baseUrl);
|
||||||
|
if (data.since) {
|
||||||
|
url.searchParams.set('since', data.since);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url.toString(), { method: 'GET' });
|
||||||
|
if (!res.ok) {
|
||||||
|
results.push({ node, success: false, error: `HTTP ${res.status}` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await res.json();
|
||||||
|
const handles = Array.isArray(payload?.handles) ? payload.handles : [];
|
||||||
|
const merged = await upsertHandleEntries(handles);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
node,
|
||||||
|
success: true,
|
||||||
|
added: merged.added,
|
||||||
|
updated: merged.updated,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
results.push({ node, success: false, error: 'Fetch failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ results });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message === 'Admin required') {
|
||||||
|
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||||
|
}
|
||||||
|
console.error('Gossip error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to gossip handles' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||||
|
|
||||||
|
const payloadSchema = z.object({
|
||||||
|
handles: z.array(z.object({
|
||||||
|
handle: z.string().min(1),
|
||||||
|
did: z.string().min(1),
|
||||||
|
nodeDomain: z.string().min(1),
|
||||||
|
updatedAt: z.string().optional(),
|
||||||
|
})).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = payloadSchema.parse(body);
|
||||||
|
|
||||||
|
const result = await upsertHandleEntries(data.handles);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
added: result.added,
|
||||||
|
updated: result.updated,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
console.error('Handle ingest error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to ingest handles' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, handleRegistry } from '@/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { normalizeHandle, upsertHandleEntries } from '@/lib/federation/handles';
|
||||||
|
|
||||||
|
const parseHandleWithDomain = (handle: string) => {
|
||||||
|
const clean = normalizeHandle(handle);
|
||||||
|
const parts = clean.split('@').filter(Boolean);
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return { handle: parts[0], domain: parts[1] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const handleParam = searchParams.get('handle');
|
||||||
|
|
||||||
|
if (!handleParam) {
|
||||||
|
return NextResponse.json({ error: 'Handle is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseHandleWithDomain(handleParam);
|
||||||
|
const lookupHandle = parsed ? parsed.handle : normalizeHandle(handleParam);
|
||||||
|
const localEntry = await db.query.handleRegistry.findFirst({
|
||||||
|
where: eq(handleRegistry.handle, lookupHandle),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (localEntry) {
|
||||||
|
return NextResponse.json({
|
||||||
|
handle: localEntry.handle,
|
||||||
|
did: localEntry.did,
|
||||||
|
nodeDomain: localEntry.nodeDomain,
|
||||||
|
updatedAt: localEntry.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
return NextResponse.json({ error: 'Handle not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL('/.well-known/synapsis-handles', `https://${parsed.domain}`);
|
||||||
|
url.searchParams.set('handle', parsed.handle);
|
||||||
|
|
||||||
|
const res = await fetch(url.toString());
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json({ error: 'Handle not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const entry = Array.isArray(data?.handles) ? data.handles[0] : null;
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return NextResponse.json({ error: 'Handle not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertHandleEntries([entry]);
|
||||||
|
|
||||||
|
return NextResponse.json(entry);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Handle resolve error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to resolve handle' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, users } from '@/db';
|
||||||
|
import { count, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
const requiredEnv = [
|
||||||
|
'DATABASE_URL',
|
||||||
|
'AUTH_SECRET',
|
||||||
|
'NEXT_PUBLIC_NODE_DOMAIN',
|
||||||
|
'NEXT_PUBLIC_NODE_NAME',
|
||||||
|
'ADMIN_EMAILS',
|
||||||
|
];
|
||||||
|
|
||||||
|
const optionalEnv: string[] = [];
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const envStatus = {
|
||||||
|
required: requiredEnv.reduce<Record<string, boolean>>((acc, key) => {
|
||||||
|
acc[key] = Boolean(process.env[key]);
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
optional: optionalEnv.reduce<Record<string, boolean>>((acc, key) => {
|
||||||
|
acc[key] = Boolean(process.env[key]);
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({
|
||||||
|
env: envStatus,
|
||||||
|
db: { connected: false, schemaReady: false, usersCount: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let schemaReady = true;
|
||||||
|
let usersCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.execute(sql`select 1 from users limit 1`);
|
||||||
|
const [result] = await db.select({ count: count() }).from(users);
|
||||||
|
usersCount = Number(result?.count || 0);
|
||||||
|
} catch {
|
||||||
|
schemaReady = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
env: envStatus,
|
||||||
|
db: { connected: true, schemaReady, usersCount },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Install status error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to check status' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, media } from '@/db';
|
||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
|
import { writeFile, mkdir } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
const UPLOAD_DIR = join(process.cwd(), 'public', 'uploads');
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth();
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File | null;
|
||||||
|
const altText = (formData.get('alt') as string | null) || null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'File too large. Maximum size: 10MB'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
const ext = file.name.split('.').pop() || 'jpg';
|
||||||
|
const filename = `${randomUUID()}.${ext}`;
|
||||||
|
const filepath = join(UPLOAD_DIR, filename);
|
||||||
|
|
||||||
|
// Ensure upload directory exists
|
||||||
|
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
await writeFile(filepath, Buffer.from(bytes));
|
||||||
|
|
||||||
|
const url = `/uploads/${filename}`;
|
||||||
|
|
||||||
|
// If database is available, store media record
|
||||||
|
if (db) {
|
||||||
|
const [mediaRecord] = await db.insert(media).values({
|
||||||
|
userId: user.id,
|
||||||
|
postId: null,
|
||||||
|
url,
|
||||||
|
altText,
|
||||||
|
mimeType: file.type,
|
||||||
|
width: 0, // TODO: Get actual dimensions
|
||||||
|
height: 0,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
media: mediaRecord,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, notifications } from '@/db';
|
||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
|
import { and, desc, eq, inArray, isNull } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const markSchema = z.object({
|
||||||
|
ids: z.array(z.string().uuid()).optional(),
|
||||||
|
all: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ notifications: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '30'), 50);
|
||||||
|
const unreadOnly = searchParams.get('unread') === 'true';
|
||||||
|
|
||||||
|
const conditions = [eq(notifications.userId, user.id)];
|
||||||
|
if (unreadOnly) {
|
||||||
|
conditions.push(isNull(notifications.readAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.query.notifications.findMany({
|
||||||
|
where: and(...conditions),
|
||||||
|
with: {
|
||||||
|
actor: true,
|
||||||
|
post: true,
|
||||||
|
},
|
||||||
|
orderBy: [desc(notifications.createdAt)],
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
type: row.type,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
readAt: row.readAt,
|
||||||
|
actor: row.actor ? {
|
||||||
|
id: row.actor.id,
|
||||||
|
handle: row.actor.handle,
|
||||||
|
displayName: row.actor.displayName,
|
||||||
|
avatarUrl: row.actor.avatarUrl,
|
||||||
|
} : null,
|
||||||
|
post: row.post ? {
|
||||||
|
id: row.post.id,
|
||||||
|
content: row.post.content,
|
||||||
|
} : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ notifications: payload });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error('Notifications fetch error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch notifications' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const data = markSchema.parse(body);
|
||||||
|
|
||||||
|
if (!data.all && (!data.ids || data.ids.length === 0)) {
|
||||||
|
return NextResponse.json({ error: 'No notifications specified' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = data.all
|
||||||
|
? eq(notifications.userId, user.id)
|
||||||
|
: and(
|
||||||
|
eq(notifications.userId, user.id),
|
||||||
|
inArray(notifications.id, data.ids || [])
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.update(notifications)
|
||||||
|
.set({ readAt: new Date() })
|
||||||
|
.where(where);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error('Notifications update error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to update notifications' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, posts, likes, users, notifications } from '@/db';
|
||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
// Like a post
|
||||||
|
export async function POST(request: Request, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth();
|
||||||
|
const { id: postId } = await context.params;
|
||||||
|
|
||||||
|
if (user.isSuspended || user.isSilenced) {
|
||||||
|
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if post exists
|
||||||
|
const post = await db.query.posts.findFirst({
|
||||||
|
where: eq(posts.id, postId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (post.isRemoved) {
|
||||||
|
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already liked
|
||||||
|
const existingLike = await db.query.likes.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(likes.userId, user.id),
|
||||||
|
eq(likes.postId, postId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingLike) {
|
||||||
|
return NextResponse.json({ error: 'Already liked' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create like
|
||||||
|
await db.insert(likes).values({
|
||||||
|
userId: user.id,
|
||||||
|
postId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update post's like count
|
||||||
|
await db.update(posts)
|
||||||
|
.set({ likesCount: post.likesCount + 1 })
|
||||||
|
.where(eq(posts.id, postId));
|
||||||
|
|
||||||
|
if (post.userId !== user.id) {
|
||||||
|
await db.insert(notifications).values({
|
||||||
|
userId: post.userId,
|
||||||
|
actorId: user.id,
|
||||||
|
postId,
|
||||||
|
type: 'like',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Federate the like
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, liked: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'Failed to like post' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlike a post
|
||||||
|
export async function DELETE(request: Request, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth();
|
||||||
|
const { id: postId } = await context.params;
|
||||||
|
|
||||||
|
if (user.isSuspended || user.isSilenced) {
|
||||||
|
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if post exists
|
||||||
|
const post = await db.query.posts.findFirst({
|
||||||
|
where: eq(posts.id, postId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (post.isRemoved) {
|
||||||
|
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the like
|
||||||
|
const existingLike = await db.query.likes.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(likes.userId, user.id),
|
||||||
|
eq(likes.postId, postId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingLike) {
|
||||||
|
return NextResponse.json({ error: 'Not liked' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove like
|
||||||
|
await db.delete(likes).where(eq(likes.id, existingLike.id));
|
||||||
|
|
||||||
|
// Update post's like count
|
||||||
|
await db.update(posts)
|
||||||
|
.set({ likesCount: Math.max(0, post.likesCount - 1) })
|
||||||
|
.where(eq(posts.id, postId));
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, liked: false });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'Failed to unlike post' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, posts, users, notifications } from '@/db';
|
||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
// Repost a post
|
||||||
|
export async function POST(request: Request, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth();
|
||||||
|
const { id: postId } = await context.params;
|
||||||
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
|
||||||
|
if (user.isSuspended || user.isSilenced) {
|
||||||
|
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if post exists
|
||||||
|
const originalPost = await db.query.posts.findFirst({
|
||||||
|
where: eq(posts.id, postId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!originalPost) {
|
||||||
|
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (originalPost.isRemoved) {
|
||||||
|
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create repost
|
||||||
|
const [repost] = await db.insert(posts).values({
|
||||||
|
userId: user.id,
|
||||||
|
content: '', // Reposts don't have their own content
|
||||||
|
repostOfId: postId,
|
||||||
|
apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
|
||||||
|
apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// Update original post's repost count
|
||||||
|
await db.update(posts)
|
||||||
|
.set({ repostsCount: originalPost.repostsCount + 1 })
|
||||||
|
.where(eq(posts.id, postId));
|
||||||
|
|
||||||
|
// Update user's post count
|
||||||
|
await db.update(users)
|
||||||
|
.set({ postsCount: user.postsCount + 1 })
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
if (originalPost.userId !== user.id) {
|
||||||
|
await db.insert(notifications).values({
|
||||||
|
userId: originalPost.userId,
|
||||||
|
actorId: user.id,
|
||||||
|
postId,
|
||||||
|
type: 'repost',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Federate the repost (Announce activity)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, repost });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'Failed to repost' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, posts, users, media, follows, mutes, blocks } from '@/db';
|
||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
|
import { eq, desc, and, inArray, isNull, notInArray, or } from 'drizzle-orm';
|
||||||
|
import type { SQL } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const POST_MAX_LENGTH = 400;
|
||||||
|
const CURATION_WINDOW_HOURS = 72;
|
||||||
|
const CURATION_SEED_MULTIPLIER = 5;
|
||||||
|
const CURATION_SEED_CAP = 200;
|
||||||
|
|
||||||
|
const buildWhere = (...conditions: Array<SQL | undefined>) => {
|
||||||
|
const filtered = conditions.filter(Boolean) as SQL[];
|
||||||
|
if (filtered.length === 0) return undefined;
|
||||||
|
return and(...filtered);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPostSchema = z.object({
|
||||||
|
content: z.string().min(1).max(POST_MAX_LENGTH),
|
||||||
|
replyToId: z.string().uuid().optional(),
|
||||||
|
mediaIds: z.array(z.string().uuid()).max(4).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new post
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth();
|
||||||
|
const body = await request.json();
|
||||||
|
const data = createPostSchema.parse(body);
|
||||||
|
|
||||||
|
if (user.isSuspended || user.isSilenced) {
|
||||||
|
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
|
||||||
|
const [post] = await db.insert(posts).values({
|
||||||
|
userId: user.id,
|
||||||
|
content: data.content,
|
||||||
|
replyToId: data.replyToId,
|
||||||
|
apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
|
||||||
|
apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
let attachedMedia: typeof media.$inferSelect[] = [];
|
||||||
|
if (data.mediaIds?.length) {
|
||||||
|
await db.update(media)
|
||||||
|
.set({ postId: post.id })
|
||||||
|
.where(and(
|
||||||
|
inArray(media.id, data.mediaIds),
|
||||||
|
eq(media.userId, user.id),
|
||||||
|
isNull(media.postId),
|
||||||
|
));
|
||||||
|
|
||||||
|
attachedMedia = await db.query.media.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(media.id, data.mediaIds),
|
||||||
|
eq(media.userId, user.id),
|
||||||
|
eq(media.postId, post.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user's post count
|
||||||
|
await db.update(users)
|
||||||
|
.set({ postsCount: user.postsCount + 1 })
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
// If this is a reply, update the parent's reply count
|
||||||
|
if (data.replyToId) {
|
||||||
|
const parentPost = await db.query.posts.findFirst({
|
||||||
|
where: eq(posts.id, data.replyToId),
|
||||||
|
});
|
||||||
|
if (parentPost) {
|
||||||
|
await db.update(posts)
|
||||||
|
.set({ repliesCount: parentPost.repliesCount + 1 })
|
||||||
|
.where(eq(posts.id, data.replyToId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Federate the post to followers
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, post: { ...post, media: attachedMedia } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create post error:', error);
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid input', details: error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to create post' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get timeline / feed
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Return empty posts if no database is connected (for UI testing)
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ posts: [], nextCursor: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const type = searchParams.get('type') || 'home'; // home, public, user, curated
|
||||||
|
const userId = searchParams.get('userId');
|
||||||
|
const cursor = searchParams.get('cursor');
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||||
|
|
||||||
|
let feedPosts;
|
||||||
|
const moderatedUsers = await db.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(or(eq(users.isSuspended, true), eq(users.isSilenced, true)));
|
||||||
|
const moderatedIds = moderatedUsers.map((item) => item.id);
|
||||||
|
const baseFilter = buildWhere(
|
||||||
|
eq(posts.isRemoved, false),
|
||||||
|
moderatedIds.length ? notInArray(posts.userId, moderatedIds) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (type === 'public') {
|
||||||
|
// Public timeline - all posts
|
||||||
|
feedPosts = await db.query.posts.findMany({
|
||||||
|
where: baseFilter,
|
||||||
|
with: {
|
||||||
|
author: true,
|
||||||
|
media: true,
|
||||||
|
replyTo: {
|
||||||
|
with: { author: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [desc(posts.createdAt)],
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
} else if (type === 'user' && userId) {
|
||||||
|
// User's posts
|
||||||
|
feedPosts = await db.query.posts.findMany({
|
||||||
|
where: buildWhere(baseFilter, eq(posts.userId, userId)),
|
||||||
|
with: {
|
||||||
|
author: true,
|
||||||
|
media: true,
|
||||||
|
replyTo: {
|
||||||
|
with: { author: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [desc(posts.createdAt)],
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
} else if (type === 'curated') {
|
||||||
|
let viewer = null;
|
||||||
|
try {
|
||||||
|
viewer = await requireAuth();
|
||||||
|
} catch {
|
||||||
|
viewer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seedLimit = Math.min(limit * CURATION_SEED_MULTIPLIER, CURATION_SEED_CAP);
|
||||||
|
const seedPosts = await db.query.posts.findMany({
|
||||||
|
where: baseFilter,
|
||||||
|
with: {
|
||||||
|
author: true,
|
||||||
|
media: true,
|
||||||
|
replyTo: {
|
||||||
|
with: { author: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [desc(posts.createdAt)],
|
||||||
|
limit: seedLimit,
|
||||||
|
});
|
||||||
|
|
||||||
|
let followingIds = new Set<string>();
|
||||||
|
let mutedIds = new Set<string>();
|
||||||
|
let blockedIds = new Set<string>();
|
||||||
|
|
||||||
|
if (viewer) {
|
||||||
|
const followRows = await db.select({ followingId: follows.followingId })
|
||||||
|
.from(follows)
|
||||||
|
.where(eq(follows.followerId, viewer.id));
|
||||||
|
followingIds = new Set(followRows.map(row => row.followingId));
|
||||||
|
|
||||||
|
const muteRows = await db.select({ mutedUserId: mutes.mutedUserId })
|
||||||
|
.from(mutes)
|
||||||
|
.where(eq(mutes.userId, viewer.id));
|
||||||
|
mutedIds = new Set(muteRows.map(row => row.mutedUserId));
|
||||||
|
|
||||||
|
const blockRows = await db.select({ blockedUserId: blocks.blockedUserId })
|
||||||
|
.from(blocks)
|
||||||
|
.where(eq(blocks.userId, viewer.id));
|
||||||
|
blockedIds = new Set(blockRows.map(row => row.blockedUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const rankedPosts = seedPosts
|
||||||
|
.filter(post => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id))
|
||||||
|
.map(post => {
|
||||||
|
const createdAt = new Date(post.createdAt).getTime();
|
||||||
|
const ageHours = Math.max(0, (now - createdAt) / 3600000);
|
||||||
|
const engagement = post.likesCount + post.repostsCount * 2 + post.repliesCount * 0.5;
|
||||||
|
const engagementScore = Math.log1p(Math.max(0, engagement));
|
||||||
|
const recencyScore = Math.max(0, 1 - ageHours / CURATION_WINDOW_HOURS);
|
||||||
|
|
||||||
|
const followBoost = viewer && followingIds.has(post.author.id) ? 0.9 : 0;
|
||||||
|
const selfBoost = viewer && post.author.id === viewer.id ? 0.5 : 0;
|
||||||
|
|
||||||
|
const score = engagementScore * 1.4 + recencyScore * 1.1 + followBoost + selfBoost;
|
||||||
|
|
||||||
|
const reasons: string[] = [];
|
||||||
|
if (followBoost > 0) {
|
||||||
|
reasons.push(`You follow @${post.author.handle}`);
|
||||||
|
}
|
||||||
|
if (engagement >= 5) {
|
||||||
|
reasons.push(`Popular: ${post.likesCount} likes, ${post.repostsCount} reposts`);
|
||||||
|
} else if (engagement > 0) {
|
||||||
|
reasons.push(`Active conversation: ${post.repliesCount} replies`);
|
||||||
|
}
|
||||||
|
if (ageHours <= 6) {
|
||||||
|
reasons.push('Posted recently');
|
||||||
|
} else if (ageHours <= 24) {
|
||||||
|
reasons.push('Posted today');
|
||||||
|
} else if (ageHours <= CURATION_WINDOW_HOURS) {
|
||||||
|
reasons.push('Recent');
|
||||||
|
}
|
||||||
|
if (reasons.length === 0) {
|
||||||
|
reasons.push('New post');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...post,
|
||||||
|
feedMeta: {
|
||||||
|
score: Number(score.toFixed(3)),
|
||||||
|
reasons,
|
||||||
|
engagement: {
|
||||||
|
likes: post.likesCount,
|
||||||
|
reposts: post.repostsCount,
|
||||||
|
replies: post.repliesCount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.feedMeta.score !== a.feedMeta.score) {
|
||||||
|
return b.feedMeta.score - a.feedMeta.score;
|
||||||
|
}
|
||||||
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
|
})
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
posts: rankedPosts,
|
||||||
|
meta: {
|
||||||
|
algorithm: 'curated-v1',
|
||||||
|
windowHours: CURATION_WINDOW_HOURS,
|
||||||
|
seedLimit,
|
||||||
|
weights: {
|
||||||
|
engagement: 1.4,
|
||||||
|
recency: 1.1,
|
||||||
|
followBoost: 0.9,
|
||||||
|
selfBoost: 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nextCursor: rankedPosts.length === limit ? rankedPosts[rankedPosts.length - 1]?.id : null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Home timeline - need auth
|
||||||
|
try {
|
||||||
|
const user = await requireAuth();
|
||||||
|
|
||||||
|
// Get posts from people the user follows + their own posts
|
||||||
|
// For now, just return all posts (we'll add following filter later)
|
||||||
|
feedPosts = await db.query.posts.findMany({
|
||||||
|
where: baseFilter,
|
||||||
|
with: {
|
||||||
|
author: true,
|
||||||
|
media: true,
|
||||||
|
replyTo: {
|
||||||
|
with: { author: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [desc(posts.createdAt)],
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Not authenticated, return public timeline
|
||||||
|
feedPosts = await db.query.posts.findMany({
|
||||||
|
where: baseFilter,
|
||||||
|
with: {
|
||||||
|
author: true,
|
||||||
|
media: true,
|
||||||
|
replyTo: {
|
||||||
|
with: { author: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [desc(posts.createdAt)],
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
posts: feedPosts,
|
||||||
|
nextCursor: feedPosts.length === limit ? feedPosts[feedPosts.length - 1]?.id : null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get feed error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get feed' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, reports, posts, users } from '@/db';
|
||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const reportSchema = z.object({
|
||||||
|
targetType: z.enum(['post', 'user']),
|
||||||
|
targetId: z.string().uuid(),
|
||||||
|
reason: z.string().min(3).max(500),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const reporter = await requireAuth();
|
||||||
|
|
||||||
|
if (reporter.isSuspended || reporter.isSilenced) {
|
||||||
|
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const data = reportSchema.parse(body);
|
||||||
|
|
||||||
|
if (data.targetType === 'post') {
|
||||||
|
const targetPost = await db.query.posts.findFirst({
|
||||||
|
where: eq(posts.id, data.targetId),
|
||||||
|
});
|
||||||
|
if (!targetPost || targetPost.isRemoved) {
|
||||||
|
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.targetType === 'user') {
|
||||||
|
const targetUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, data.targetId),
|
||||||
|
});
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [report] = await db.insert(reports).values({
|
||||||
|
reporterId: reporter.id,
|
||||||
|
targetType: data.targetType,
|
||||||
|
targetId: data.targetId,
|
||||||
|
reason: data.reason,
|
||||||
|
status: 'open',
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
return NextResponse.json({ report });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error('Report error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to submit report' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, users, posts } from '@/db';
|
||||||
|
import { ilike, or, desc, and, notInArray, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const query = searchParams.get('q') || '';
|
||||||
|
const type = searchParams.get('type') || 'all'; // all, users, posts
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
return NextResponse.json({ users: [], posts: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return empty if no database
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({
|
||||||
|
users: [],
|
||||||
|
posts: [],
|
||||||
|
message: 'Search requires database connection'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchPattern = `%${query}%`;
|
||||||
|
let searchUsers: { id: string; handle: string; displayName: string | null; avatarUrl: string | null; bio: string | null }[] = [];
|
||||||
|
let searchPosts: typeof posts.$inferSelect[] = [];
|
||||||
|
|
||||||
|
// Search users
|
||||||
|
if (type === 'all' || type === 'users') {
|
||||||
|
const userConditions = and(
|
||||||
|
or(
|
||||||
|
ilike(users.handle, searchPattern),
|
||||||
|
ilike(users.displayName, searchPattern),
|
||||||
|
ilike(users.bio, searchPattern)
|
||||||
|
),
|
||||||
|
eq(users.isSuspended, false),
|
||||||
|
eq(users.isSilenced, false)
|
||||||
|
);
|
||||||
|
searchUsers = await db.select({
|
||||||
|
id: users.id,
|
||||||
|
handle: users.handle,
|
||||||
|
displayName: users.displayName,
|
||||||
|
avatarUrl: users.avatarUrl,
|
||||||
|
bio: users.bio,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(userConditions)
|
||||||
|
.limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const moderatedUsers = await db.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(or(eq(users.isSuspended, true), eq(users.isSilenced, true)));
|
||||||
|
const moderatedIds = moderatedUsers.map((item) => item.id);
|
||||||
|
|
||||||
|
// Search posts
|
||||||
|
if (type === 'all' || type === 'posts') {
|
||||||
|
const postConditions = [
|
||||||
|
ilike(posts.content, searchPattern),
|
||||||
|
eq(posts.isRemoved, false),
|
||||||
|
];
|
||||||
|
if (moderatedIds.length) {
|
||||||
|
postConditions.push(notInArray(posts.userId, moderatedIds));
|
||||||
|
}
|
||||||
|
const postResults = await db.query.posts.findMany({
|
||||||
|
where: and(...postConditions),
|
||||||
|
with: {
|
||||||
|
author: true,
|
||||||
|
media: true,
|
||||||
|
},
|
||||||
|
orderBy: [desc(posts.createdAt)],
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
searchPosts = postResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
users: searchUsers,
|
||||||
|
posts: searchPosts,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error);
|
||||||
|
return NextResponse.json({ error: 'Search failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, follows, users, notifications } from '@/db';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
|
// Check follow status
|
||||||
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAuth();
|
||||||
|
const { handle } = await context.params;
|
||||||
|
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||||
|
|
||||||
|
if (currentUser.isSuspended || currentUser.isSilenced) {
|
||||||
|
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, cleanHandle),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (targetUser.isSuspended) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUser.id === currentUser.id) {
|
||||||
|
return NextResponse.json({ following: false, self: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingFollow = await db.query.follows.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(follows.followerId, currentUser.id),
|
||||||
|
eq(follows.followingId, targetUser.id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ following: !!existingFollow });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error('Follow status error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to get follow status' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow a user
|
||||||
|
export async function POST(request: Request, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAuth();
|
||||||
|
const { handle } = await context.params;
|
||||||
|
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||||
|
|
||||||
|
if (currentUser.isSuspended || currentUser.isSilenced) {
|
||||||
|
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find target user
|
||||||
|
const targetUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, cleanHandle),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (targetUser.isSuspended) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can't follow yourself
|
||||||
|
if (targetUser.id === currentUser.id) {
|
||||||
|
return NextResponse.json({ error: 'Cannot follow yourself' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already following
|
||||||
|
const existingFollow = await db.query.follows.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(follows.followerId, currentUser.id),
|
||||||
|
eq(follows.followingId, targetUser.id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingFollow) {
|
||||||
|
return NextResponse.json({ error: 'Already following' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create follow
|
||||||
|
await db.insert(follows).values({
|
||||||
|
followerId: currentUser.id,
|
||||||
|
followingId: targetUser.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentUser.id !== targetUser.id) {
|
||||||
|
await db.insert(notifications).values({
|
||||||
|
userId: targetUser.id,
|
||||||
|
actorId: currentUser.id,
|
||||||
|
type: 'follow',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update counts
|
||||||
|
await db.update(users)
|
||||||
|
.set({ followingCount: currentUser.followingCount + 1 })
|
||||||
|
.where(eq(users.id, currentUser.id));
|
||||||
|
|
||||||
|
await db.update(users)
|
||||||
|
.set({ followersCount: targetUser.followersCount + 1 })
|
||||||
|
.where(eq(users.id, targetUser.id));
|
||||||
|
|
||||||
|
// TODO: Send ActivityPub Follow activity
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, following: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error('Follow error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to follow' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfollow a user
|
||||||
|
export async function DELETE(request: Request, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAuth();
|
||||||
|
const { handle } = await context.params;
|
||||||
|
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find target user
|
||||||
|
const targetUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, cleanHandle),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (targetUser.isSuspended) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find existing follow
|
||||||
|
const existingFollow = await db.query.follows.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(follows.followerId, currentUser.id),
|
||||||
|
eq(follows.followingId, targetUser.id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingFollow) {
|
||||||
|
return NextResponse.json({ error: 'Not following' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove follow
|
||||||
|
await db.delete(follows).where(eq(follows.id, existingFollow.id));
|
||||||
|
|
||||||
|
// Update counts
|
||||||
|
await db.update(users)
|
||||||
|
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
|
||||||
|
.where(eq(users.id, currentUser.id));
|
||||||
|
|
||||||
|
await db.update(users)
|
||||||
|
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
|
||||||
|
.where(eq(users.id, targetUser.id));
|
||||||
|
|
||||||
|
// TODO: Send ActivityPub Undo Follow activity
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, following: false });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error('Unfollow error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to unfollow' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, follows, users } from '@/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
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') || '20'), 50);
|
||||||
|
|
||||||
|
// Return empty if no database
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ followers: [], nextCursor: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the user
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, cleanHandle),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (user.isSuspended) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get followers
|
||||||
|
const userFollowers = await db.query.follows.findMany({
|
||||||
|
where: eq(follows.followingId, user.id),
|
||||||
|
with: {
|
||||||
|
follower: true,
|
||||||
|
},
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
followers: userFollowers.map(f => ({
|
||||||
|
id: f.follower.id,
|
||||||
|
handle: f.follower.handle,
|
||||||
|
displayName: f.follower.displayName,
|
||||||
|
avatarUrl: f.follower.avatarUrl,
|
||||||
|
bio: f.follower.bio,
|
||||||
|
})),
|
||||||
|
nextCursor: userFollowers.length === limit ? userFollowers[userFollowers.length - 1]?.id : null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get followers error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to get followers' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, follows, users } from '@/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
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') || '20'), 50);
|
||||||
|
|
||||||
|
// Return empty if no database
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ following: [], nextCursor: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the user
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, cleanHandle),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (user.isSuspended) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get following
|
||||||
|
const userFollowing = await db.query.follows.findMany({
|
||||||
|
where: eq(follows.followerId, user.id),
|
||||||
|
with: {
|
||||||
|
following: true,
|
||||||
|
},
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
following: userFollowing.map(f => ({
|
||||||
|
id: f.following.id,
|
||||||
|
handle: f.following.handle,
|
||||||
|
displayName: f.following.displayName,
|
||||||
|
avatarUrl: f.following.avatarUrl,
|
||||||
|
bio: f.following.bio,
|
||||||
|
})),
|
||||||
|
nextCursor: userFollowing.length === limit ? userFollowing[userFollowing.length - 1]?.id : null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get following error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to get following' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, posts, users } from '@/db';
|
||||||
|
import { eq, desc, and } from 'drizzle-orm';
|
||||||
|
|
||||||
|
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') || '20'), 50);
|
||||||
|
|
||||||
|
// Return empty if no database
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({ posts: [], nextCursor: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the user
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, cleanHandle),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (user.isSuspended) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's posts
|
||||||
|
const userPosts = await db.query.posts.findMany({
|
||||||
|
where: and(eq(posts.userId, user.id), eq(posts.isRemoved, false)),
|
||||||
|
with: {
|
||||||
|
author: true,
|
||||||
|
media: true,
|
||||||
|
replyTo: {
|
||||||
|
with: { author: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [desc(posts.createdAt)],
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
posts: userPosts,
|
||||||
|
nextCursor: userPosts.length === limit ? userPosts[userPosts.length - 1]?.id : null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get user posts error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to get posts' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, users } from '@/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { userToActor } from '@/lib/activitypub/actor';
|
||||||
|
|
||||||
|
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(/^@/, '');
|
||||||
|
|
||||||
|
// Return mock user if no database
|
||||||
|
if (!db) {
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: 'demo-user',
|
||||||
|
handle: cleanHandle,
|
||||||
|
displayName: cleanHandle,
|
||||||
|
bio: 'This is a demo profile.',
|
||||||
|
avatarUrl: null,
|
||||||
|
headerUrl: null,
|
||||||
|
followersCount: 0,
|
||||||
|
followingCount: 0,
|
||||||
|
postsCount: 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, cleanHandle),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (user.isSuspended) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ActivityPub request
|
||||||
|
const accept = request.headers.get('accept') || '';
|
||||||
|
if (accept.includes('application/activity+json') || accept.includes('application/ld+json')) {
|
||||||
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
const actor = userToActor(user, nodeDomain);
|
||||||
|
return NextResponse.json(actor, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/activity+json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return user profile (without sensitive data)
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
handle: user.handle,
|
||||||
|
displayName: user.displayName,
|
||||||
|
bio: user.bio,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
headerUrl: user.headerUrl,
|
||||||
|
followersCount: user.followersCount,
|
||||||
|
followingCount: user.followingCount,
|
||||||
|
postsCount: user.postsCount,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get user error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to get user' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
+1117
-13
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
type EnvStatus = {
|
||||||
|
required: Record<string, boolean>;
|
||||||
|
optional: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InstallStatus = {
|
||||||
|
env: EnvStatus;
|
||||||
|
db: {
|
||||||
|
connected: boolean;
|
||||||
|
schemaReady: boolean;
|
||||||
|
usersCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const requiredLabels: Record<string, string> = {
|
||||||
|
DATABASE_URL: 'Database connection string',
|
||||||
|
AUTH_SECRET: 'Auth cookie secret',
|
||||||
|
NEXT_PUBLIC_NODE_DOMAIN: 'Public node domain',
|
||||||
|
NEXT_PUBLIC_NODE_NAME: 'Node display name',
|
||||||
|
ADMIN_EMAILS: 'Admin emails list',
|
||||||
|
};
|
||||||
|
|
||||||
|
const optionalLabels: Record<string, string> = {};
|
||||||
|
|
||||||
|
const StepCard = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
status?: { label: string; tone: 'ok' | 'warn' };
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<div className="install-card">
|
||||||
|
<div className="install-card-header">
|
||||||
|
<div>
|
||||||
|
<div className="install-card-title">{title}</div>
|
||||||
|
{description && <div className="install-card-desc">{description}</div>}
|
||||||
|
</div>
|
||||||
|
{status && (
|
||||||
|
<div className={`install-step-status ${status.tone}`}>
|
||||||
|
{status.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function InstallPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const force = searchParams.get('force') === '1';
|
||||||
|
const [status, setStatus] = useState<InstallStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/install/status');
|
||||||
|
const data = await res.json();
|
||||||
|
setStatus(data);
|
||||||
|
} catch {
|
||||||
|
setStatus(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isInstalled = status?.db.connected && status?.db.schemaReady && status?.db.usersCount > 0;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="install-shell">
|
||||||
|
<div className="install-card">Checking install status...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return (
|
||||||
|
<div className="install-shell">
|
||||||
|
<div className="install-card">
|
||||||
|
<h1>Setup Wizard</h1>
|
||||||
|
<p>We could not load the install status.</p>
|
||||||
|
<button className="btn btn-primary" onClick={loadStatus}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInstalled && !force) {
|
||||||
|
return (
|
||||||
|
<div className="install-shell">
|
||||||
|
<div className="install-card">
|
||||||
|
<h1>Synapsis is already set up</h1>
|
||||||
|
<p>Your database is connected and at least one user exists.</p>
|
||||||
|
<Link href="/" className="btn btn-primary" style={{ marginTop: '12px' }}>
|
||||||
|
Go to home
|
||||||
|
</Link>
|
||||||
|
<Link href="/install?force=1" className="btn btn-ghost" style={{ marginTop: '12px' }}>
|
||||||
|
Re-run setup
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingRequired = Object.entries(status.env.required).filter(([, ok]) => !ok);
|
||||||
|
const missingOptional = Object.entries(status.env.optional).filter(([, ok]) => !ok);
|
||||||
|
const envComplete = missingRequired.length === 0;
|
||||||
|
const dbComplete = status.db.connected && status.db.schemaReady;
|
||||||
|
const adminComplete = status.db.usersCount > 0;
|
||||||
|
const completedSteps = [envComplete, dbComplete, adminComplete].filter(Boolean).length;
|
||||||
|
const progressPercent = Math.round((completedSteps / 3) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="install-shell">
|
||||||
|
<div className="install-hero">
|
||||||
|
<div className="install-header">
|
||||||
|
<div>
|
||||||
|
<h1>Synapsis Setup</h1>
|
||||||
|
<p>Follow these steps to complete your installation.</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-ghost" onClick={loadStatus}>
|
||||||
|
Recheck
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="install-progress">
|
||||||
|
<div className="install-progress-bar">
|
||||||
|
<div className="install-progress-fill" style={{ width: `${progressPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="install-progress-meta">
|
||||||
|
<span>{completedSteps} / 3 steps complete</span>
|
||||||
|
<span>{progressPercent}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="install-summary">
|
||||||
|
<div>
|
||||||
|
<div className="install-summary-title">Environment</div>
|
||||||
|
<div className="install-summary-value">{envComplete ? 'Ready' : 'Needs values'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="install-summary-title">Database</div>
|
||||||
|
<div className="install-summary-value">{dbComplete ? 'Ready' : 'Not ready'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="install-summary-title">Admin</div>
|
||||||
|
<div className="install-summary-value">{adminComplete ? 'Ready' : 'Not created'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StepCard
|
||||||
|
title="1. Environment variables"
|
||||||
|
description="Set required values in your deployment environment (.env or platform settings)."
|
||||||
|
status={{
|
||||||
|
label: envComplete ? 'Complete' : 'Needs attention',
|
||||||
|
tone: envComplete ? 'ok' : 'warn',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="install-grid">
|
||||||
|
{Object.entries(status.env.required).map(([key, ok]) => (
|
||||||
|
<div key={key} className={`install-item ${ok ? 'ok' : 'missing'}`}>
|
||||||
|
<div className="install-item-title">{key}</div>
|
||||||
|
<div className="install-item-desc">{requiredLabels[key]}</div>
|
||||||
|
<div className="install-item-status">{ok ? 'Set' : 'Missing'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{missingRequired.length > 0 && (
|
||||||
|
<div className="install-hint">
|
||||||
|
Missing required values: {missingRequired.map(([key]) => key).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{missingRequired.some(([key]) => key === 'AUTH_SECRET') && (
|
||||||
|
<div className="install-hint" style={{ marginTop: '8px' }}>
|
||||||
|
To generate an AUTH_SECRET, run:
|
||||||
|
<code style={{ display: 'block', padding: '8px', background: 'rgba(0,0,0,0.05)', marginTop: '4px', borderRadius: '4px', fontFamily: 'monospace' }}>
|
||||||
|
openssl rand -base64 33
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="install-subtitle">Optional</div>
|
||||||
|
<div className="install-grid">
|
||||||
|
{Object.entries(status.env.optional).map(([key, ok]) => (
|
||||||
|
<div key={key} className={`install-item ${ok ? 'ok' : 'missing'}`}>
|
||||||
|
<div className="install-item-title">{key}</div>
|
||||||
|
<div className="install-item-desc">{optionalLabels[key]}</div>
|
||||||
|
<div className="install-item-status">{ok ? 'Set' : 'Not set'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{missingOptional.length > 0 && (
|
||||||
|
<div className="install-hint">
|
||||||
|
Optional values missing: {missingOptional.map(([key]) => key).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</StepCard>
|
||||||
|
|
||||||
|
<StepCard
|
||||||
|
title="2. Database"
|
||||||
|
description="Ensure the database is reachable and the schema is pushed."
|
||||||
|
status={{
|
||||||
|
label: dbComplete ? 'Complete' : 'Needs attention',
|
||||||
|
tone: dbComplete ? 'ok' : 'warn',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="install-status-row">
|
||||||
|
<span>Database connection</span>
|
||||||
|
<strong>{status.db.connected ? 'Connected' : 'Not connected'}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="install-status-row">
|
||||||
|
<span>Schema</span>
|
||||||
|
<strong>{status.db.schemaReady ? 'Ready' : 'Missing tables'}</strong>
|
||||||
|
</div>
|
||||||
|
{!status.db.schemaReady && (
|
||||||
|
<div className="install-hint">
|
||||||
|
Run <code>npm run db:push</code> to create tables.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</StepCard>
|
||||||
|
|
||||||
|
<StepCard
|
||||||
|
title="3. Create admin account"
|
||||||
|
description="Register your first account, then grant admin access."
|
||||||
|
status={{
|
||||||
|
label: adminComplete ? 'Complete' : 'Needs attention',
|
||||||
|
tone: adminComplete ? 'ok' : 'warn',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="install-status-row">
|
||||||
|
<span>Existing users</span>
|
||||||
|
<strong>{status.db.usersCount}</strong>
|
||||||
|
</div>
|
||||||
|
<p className="install-body">
|
||||||
|
Register a user via the login page. Then add their email to
|
||||||
|
<code>ADMIN_EMAILS</code> and redeploy.
|
||||||
|
</p>
|
||||||
|
<Link href="/login" className="btn btn-primary">
|
||||||
|
Go to login / register
|
||||||
|
</Link>
|
||||||
|
</StepCard>
|
||||||
|
|
||||||
|
<StepCard
|
||||||
|
title="4. Launch"
|
||||||
|
description="Once you have at least one user and admin access, you are ready."
|
||||||
|
>
|
||||||
|
<Link href="/" className="btn btn-primary">
|
||||||
|
Go to home
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin" className="btn btn-ghost">
|
||||||
|
Open moderation dashboard
|
||||||
|
</Link>
|
||||||
|
</StepCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+18
-15
@@ -1,34 +1,37 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Inter, Saira_Condensed } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const inter = Inter({
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
variable: "--font-inter",
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const sairaCondensed = Saira_Condensed({
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
weight: ["700"],
|
||||||
|
variable: "--font-saira",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Synapsis",
|
||||||
description: "Generated by create next app",
|
description: "Federated social network infrastructure",
|
||||||
|
manifest: "/manifest.json",
|
||||||
|
icons: {
|
||||||
|
icon: "/favicon.png",
|
||||||
|
},
|
||||||
|
themeColor: "#0a0a0a",
|
||||||
|
viewport: "width=device-width, initial-scale=1, maximum-scale=1",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className={`${inter.variable} ${sairaCondensed.variable}`}>
|
||||||
<body
|
<body>{children}</body>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
const SynapsisLogo = () => (
|
||||||
|
<svg
|
||||||
|
width="28"
|
||||||
|
height="28"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" />
|
||||||
|
<path d="M9 13a4.5 4.5 0 0 0 3-4" />
|
||||||
|
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" />
|
||||||
|
<path d="M3.477 10.896a4 4 0 0 1 .585-.396" />
|
||||||
|
<path d="M6 18a4 4 0 0 1-1.967-.516" />
|
||||||
|
<path d="M12 13h4" />
|
||||||
|
<path d="M12 18h6a2 2 0 0 1 2 2v1" />
|
||||||
|
<path d="M12 8h8" />
|
||||||
|
<path d="M16 8V5a2 2 0 0 1 2-2" />
|
||||||
|
<circle cx="16" cy="13" r=".5" fill="currentColor" />
|
||||||
|
<circle cx="18" cy="3" r=".5" fill="currentColor" />
|
||||||
|
<circle cx="20" cy="21" r=".5" fill="currentColor" />
|
||||||
|
<circle cx="20" cy="8" r=".5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [handle, setHandle] = useState('');
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register';
|
||||||
|
const body = mode === 'login'
|
||||||
|
? { email, password }
|
||||||
|
: { email, password, handle, displayName };
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Authentication failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/');
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '24px',
|
||||||
|
}}>
|
||||||
|
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||||
|
{/* Logo */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '32px' }}>
|
||||||
|
<div className="logo" style={{ marginBottom: '8px', fontSize: '32px' }}>
|
||||||
|
<SynapsisLogo />
|
||||||
|
<span>Synapsis</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ color: 'var(--foreground-secondary)', marginTop: '0' }}>
|
||||||
|
Federated social network infrastructure
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode Switcher */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
marginBottom: '24px',
|
||||||
|
background: 'var(--background-secondary)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
padding: '4px',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('login')}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: mode === 'login' ? 'var(--background-tertiary)' : 'transparent',
|
||||||
|
color: mode === 'login' ? 'var(--foreground)' : 'var(--foreground-secondary)',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('register')}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: mode === 'register' ? 'var(--background-tertiary)' : 'transparent',
|
||||||
|
color: mode === 'register' ? 'var(--foreground)' : 'var(--foreground-secondary)',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="card" style={{ padding: '24px' }}>
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
border: '1px solid var(--error)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
color: 'var(--error)',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'register' && (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||||
|
Handle
|
||||||
|
</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '12px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
color: 'var(--foreground-tertiary)',
|
||||||
|
}}>@</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={handle}
|
||||||
|
onChange={(e) => setHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
|
||||||
|
style={{ paddingLeft: '28px' }}
|
||||||
|
placeholder="yourhandle"
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
maxLength={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||||
|
3-20 characters, letters, numbers, and underscores only
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||||
|
Display Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="Your Name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="input"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
{mode === 'register' && (
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||||
|
Minimum 8 characters
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary btn-lg"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Please wait...' : (mode === 'login' ? 'Login' : 'Create Account')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style={{ textAlign: 'center', marginTop: '24px', color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
||||||
|
<Link href="/">← Back to home</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, users, posts } from '@/db';
|
||||||
|
import { count } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
const nodeName = process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node';
|
||||||
|
const nodeDescription = process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A Synapsis federated social network node';
|
||||||
|
|
||||||
|
// Get stats
|
||||||
|
const [userCount] = await db.select({ count: count() }).from(users);
|
||||||
|
const [postCount] = await db.select({ count: count() }).from(posts);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
version: '2.1',
|
||||||
|
software: {
|
||||||
|
name: 'synapsis',
|
||||||
|
version: '0.1.0',
|
||||||
|
homepage: 'https://github.com/synapsis',
|
||||||
|
},
|
||||||
|
protocols: ['activitypub'],
|
||||||
|
usage: {
|
||||||
|
users: {
|
||||||
|
total: userCount?.count || 0,
|
||||||
|
activeMonth: userCount?.count || 0,
|
||||||
|
activeHalfyear: userCount?.count || 0,
|
||||||
|
},
|
||||||
|
localPosts: postCount?.count || 0,
|
||||||
|
},
|
||||||
|
openRegistrations: true,
|
||||||
|
metadata: {
|
||||||
|
nodeName,
|
||||||
|
nodeDescription,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
type Notification = {
|
||||||
|
id: string;
|
||||||
|
type: 'follow' | 'like' | 'repost' | 'mention';
|
||||||
|
createdAt: string;
|
||||||
|
readAt?: string | null;
|
||||||
|
actor?: {
|
||||||
|
id: string;
|
||||||
|
handle: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
} | null;
|
||||||
|
post?: {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (minutes < 1) return 'now';
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
if (hours < 24) return `${hours}h`;
|
||||||
|
if (days < 7) return `${days}d`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMessage = (item: Notification) => {
|
||||||
|
const actorName = item.actor?.displayName || item.actor?.handle || 'Someone';
|
||||||
|
switch (item.type) {
|
||||||
|
case 'follow':
|
||||||
|
return `${actorName} followed you`;
|
||||||
|
case 'like':
|
||||||
|
return `${actorName} liked your post`;
|
||||||
|
case 'repost':
|
||||||
|
return `${actorName} reposted your post`;
|
||||||
|
case 'mention':
|
||||||
|
return `${actorName} mentioned you`;
|
||||||
|
default:
|
||||||
|
return `${actorName} sent a notification`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [polling, setPolling] = useState(true);
|
||||||
|
|
||||||
|
const loadNotifications = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notifications?limit=50', { cache: 'no-store' });
|
||||||
|
const data = await res.json();
|
||||||
|
setNotifications(data.notifications || []);
|
||||||
|
} catch {
|
||||||
|
setNotifications([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadNotifications();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!polling) return;
|
||||||
|
const interval = setInterval(loadNotifications, 15000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [polling]);
|
||||||
|
|
||||||
|
const markAllRead = async () => {
|
||||||
|
await fetch('/api/notifications', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ all: true }),
|
||||||
|
});
|
||||||
|
loadNotifications();
|
||||||
|
};
|
||||||
|
|
||||||
|
const markRead = async (id: string) => {
|
||||||
|
await fetch('/api/notifications', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ids: [id] }),
|
||||||
|
});
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((item) => (item.id === id ? { ...item, readAt: new Date().toISOString() } : item))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unreadCount = notifications.filter((item) => !item.readAt).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="notifications-shell">
|
||||||
|
<header className="notifications-header">
|
||||||
|
<div>
|
||||||
|
<h1>Notifications</h1>
|
||||||
|
<p>{unreadCount} unread</p>
|
||||||
|
</div>
|
||||||
|
<div className="notifications-actions">
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setPolling(!polling)}>
|
||||||
|
{polling ? 'Pause polling' : 'Resume polling'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={markAllRead} disabled={notifications.length === 0}>
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="notifications-empty">Loading notifications...</div>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
|
<div className="notifications-empty">No notifications yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="notifications-list">
|
||||||
|
{notifications.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`notification-row ${item.readAt ? 'read' : 'unread'}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => markRead(item.id)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
markRead(item.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="notification-avatar">
|
||||||
|
{item.actor?.avatarUrl ? (
|
||||||
|
<img src={item.actor.avatarUrl} alt={item.actor.handle} />
|
||||||
|
) : (
|
||||||
|
(item.actor?.displayName || item.actor?.handle || '?').charAt(0).toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="notification-content">
|
||||||
|
<div className="notification-message">{buildMessage(item)}</div>
|
||||||
|
{item.post?.content && (
|
||||||
|
<div className="notification-post">“{item.post.content.slice(0, 120)}”</div>
|
||||||
|
)}
|
||||||
|
<div className="notification-meta">
|
||||||
|
<span>{formatTime(item.createdAt)}</span>
|
||||||
|
{item.actor?.handle && (
|
||||||
|
<Link href={`/@${item.actor.handle}`} className="notification-link">
|
||||||
|
@{item.actor.handle}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+578
-56
@@ -1,65 +1,587 @@
|
|||||||
import Image from "next/image";
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
handle: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaItem {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
altText?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeedMeta {
|
||||||
|
score: number;
|
||||||
|
reasons: string[];
|
||||||
|
engagement: {
|
||||||
|
likes: number;
|
||||||
|
reposts: number;
|
||||||
|
replies: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
likesCount: number;
|
||||||
|
repostsCount: number;
|
||||||
|
repliesCount: number;
|
||||||
|
author: User;
|
||||||
|
media?: MediaItem[];
|
||||||
|
feedMeta?: FeedMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icons as simple SVG components
|
||||||
|
const HomeIcon = () => (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SearchIcon = () => (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const BellIcon = () => (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const UserIcon = () => (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const HeartIcon = ({ filled }: { filled?: boolean }) => (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RepeatIcon = () => (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="17 1 21 5 17 9" />
|
||||||
|
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||||
|
<polyline points="7 23 3 19 7 15" />
|
||||||
|
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MessageIcon = () => (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FlagIcon = () => (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M5 5v16" />
|
||||||
|
<path d="M5 5h11l-1 4 1 4H5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SynapsisLogo = () => (
|
||||||
|
<svg
|
||||||
|
width="28"
|
||||||
|
height="28"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" />
|
||||||
|
<path d="M9 13a4.5 4.5 0 0 0 3-4" />
|
||||||
|
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" />
|
||||||
|
<path d="M3.477 10.896a4 4 0 0 1 .585-.396" />
|
||||||
|
<path d="M6 18a4 4 0 0 1-1.967-.516" />
|
||||||
|
<path d="M12 13h4" />
|
||||||
|
<path d="M12 18h6a2 2 0 0 1 2 2v1" />
|
||||||
|
<path d="M12 8h8" />
|
||||||
|
<path d="M16 8V5a2 2 0 0 1 2-2" />
|
||||||
|
<circle cx="16" cy="13" r=".5" fill="currentColor" />
|
||||||
|
<circle cx="18" cy="3" r=".5" fill="currentColor" />
|
||||||
|
<circle cx="20" cy="21" r=".5" fill="currentColor" />
|
||||||
|
<circle cx="20" cy="8" r=".5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
function PostCard({ post, onLike, onRepost }: { post: Post; onLike: (id: string) => void; onRepost: (id: string) => void }) {
|
||||||
|
const [liked, setLiked] = useState(false);
|
||||||
|
const [reposted, setReposted] = useState(false);
|
||||||
|
const [reporting, setReporting] = useState(false);
|
||||||
|
|
||||||
|
const formatTime = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (minutes < 1) return 'now';
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
if (hours < 24) return `${hours}h`;
|
||||||
|
if (days < 7) return `${days}d`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLike = () => {
|
||||||
|
setLiked(!liked);
|
||||||
|
onLike(post.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRepost = () => {
|
||||||
|
setReposted(!reposted);
|
||||||
|
onRepost(post.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReport = async () => {
|
||||||
|
if (reporting) return;
|
||||||
|
const reason = window.prompt('Why are you reporting this post?');
|
||||||
|
if (!reason) return;
|
||||||
|
setReporting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/reports', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ targetType: 'post', targetId: post.id, reason }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
alert('Please log in to report.');
|
||||||
|
} else {
|
||||||
|
alert('Report failed. Please try again.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert('Report submitted. Thank you.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert('Report failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setReporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<article className="post">
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
<div className="post-header">
|
||||||
<Image
|
<div className="avatar">
|
||||||
className="dark:invert"
|
{post.author.avatarUrl ? (
|
||||||
src="/next.svg"
|
<img src={post.author.avatarUrl} alt={post.author.displayName} />
|
||||||
alt="Next.js logo"
|
) : (
|
||||||
width={100}
|
post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase()
|
||||||
height={20}
|
)}
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
<div className="post-author">
|
||||||
<a
|
<Link href={`/@${post.author.handle}`} className="post-handle">
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
{post.author.displayName || post.author.handle}
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</Link>
|
||||||
target="_blank"
|
<span className="post-time">@{post.author.handle} · {formatTime(post.createdAt)}</span>
|
||||||
rel="noopener noreferrer"
|
</div>
|
||||||
>
|
</div>
|
||||||
<Image
|
<div className="post-content">{post.content}</div>
|
||||||
className="dark:invert"
|
{post.feedMeta?.reasons?.length ? (
|
||||||
src="/vercel.svg"
|
<div className="post-reasons">
|
||||||
alt="Vercel logomark"
|
{post.feedMeta.reasons.map((reason, index) => (
|
||||||
width={16}
|
<span key={`${post.id}-reason-${index}`} className="post-reason-chip">
|
||||||
height={16}
|
{reason}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{post.media && post.media.length > 0 && (
|
||||||
|
<div className="post-media-grid">
|
||||||
|
{post.media.map((item) => (
|
||||||
|
<div className="post-media-item" key={item.id}>
|
||||||
|
<img src={item.url} alt={item.altText || 'Post media'} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="post-actions">
|
||||||
|
<button className="post-action" onClick={() => { }}>
|
||||||
|
<MessageIcon />
|
||||||
|
<span>{post.repliesCount || ''}</span>
|
||||||
|
</button>
|
||||||
|
<button className={`post-action ${reposted ? 'reposted' : ''}`} onClick={handleRepost}>
|
||||||
|
<RepeatIcon />
|
||||||
|
<span>{post.repostsCount + (reposted ? 1 : 0) || ''}</span>
|
||||||
|
</button>
|
||||||
|
<button className={`post-action ${liked ? 'liked' : ''}`} onClick={handleLike}>
|
||||||
|
<HeartIcon filled={liked} />
|
||||||
|
<span>{post.likesCount + (liked ? 1 : 0) || ''}</span>
|
||||||
|
</button>
|
||||||
|
<button className="post-action" onClick={handleReport} disabled={reporting}>
|
||||||
|
<FlagIcon />
|
||||||
|
<span>{reporting ? '...' : ''}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attachment = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
altText?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Compose({ onPost }: { onPost: (content: string, mediaIds: string[]) => void }) {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [isPosting, setIsPosting] = useState(false);
|
||||||
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
const maxLength = 400;
|
||||||
|
const remaining = maxLength - content.length;
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!content.trim() || isPosting || isUploading) return;
|
||||||
|
setIsPosting(true);
|
||||||
|
await onPost(content, attachments.map((item) => item.id).filter(Boolean));
|
||||||
|
setContent('');
|
||||||
|
setAttachments([]);
|
||||||
|
setIsPosting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMediaSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
event.target.value = '';
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
const remainingSlots = Math.max(0, 4 - attachments.length);
|
||||||
|
const selectedFiles = files.slice(0, remainingSlots);
|
||||||
|
if (selectedFiles.length === 0) return;
|
||||||
|
|
||||||
|
setUploadError(null);
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
const uploaded: Attachment[] = [];
|
||||||
|
|
||||||
|
for (const file of selectedFiles) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const res = await fetch('/api/media/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || !data.media?.id) {
|
||||||
|
throw new Error(data.error || 'Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
uploaded.push({
|
||||||
|
id: data.media.id,
|
||||||
|
url: data.media.url || data.url,
|
||||||
|
altText: data.media.altText ?? null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload failed', error);
|
||||||
|
setUploadError('One or more uploads failed. Try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttachments((prev) => [...prev, ...uploaded].slice(0, 4));
|
||||||
|
setIsUploading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAttachment = (id: string) => {
|
||||||
|
setAttachments((prev) => prev.filter((item) => item.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="compose">
|
||||||
|
<textarea
|
||||||
|
className="compose-input"
|
||||||
|
placeholder="What's happening?"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
maxLength={maxLength + 50} // Allow some overflow for better UX
|
||||||
|
/>
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="compose-media-grid">
|
||||||
|
{attachments.map((item) => (
|
||||||
|
<div className="compose-media-item" key={item.id}>
|
||||||
|
<img src={item.url} alt={item.altText || 'Upload preview'} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="compose-media-remove"
|
||||||
|
onClick={() => handleRemoveAttachment(item.id)}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{uploadError && (
|
||||||
|
<div className="compose-media-error">{uploadError}</div>
|
||||||
|
)}
|
||||||
|
<div className="compose-footer">
|
||||||
|
<span className={`compose-counter ${remaining < 50 ? (remaining < 0 ? 'error' : 'warning') : ''}`}>
|
||||||
|
{remaining}
|
||||||
|
</span>
|
||||||
|
<div className="compose-actions">
|
||||||
|
<label className="btn btn-ghost btn-sm compose-media-button">
|
||||||
|
{isUploading ? 'Uploading...' : 'Add media'}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onChange={handleMediaSelect}
|
||||||
|
disabled={isUploading || attachments.length >= 4}
|
||||||
|
className="compose-media-input"
|
||||||
/>
|
/>
|
||||||
Deploy Now
|
</label>
|
||||||
</a>
|
<button
|
||||||
<a
|
className="btn btn-primary"
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
onClick={handleSubmit}
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
disabled={!content.trim() || remaining < 0 || isPosting || isUploading}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
>
|
||||||
Documentation
|
{isPosting ? 'Posting...' : 'Post'}
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [feedType, setFeedType] = useState<'latest' | 'curated'>('latest');
|
||||||
|
const [feedMeta, setFeedMeta] = useState<{
|
||||||
|
algorithm: string;
|
||||||
|
windowHours: number;
|
||||||
|
seedLimit: number;
|
||||||
|
weights: {
|
||||||
|
engagement: number;
|
||||||
|
recency: number;
|
||||||
|
followBoost: number;
|
||||||
|
selfBoost: number;
|
||||||
|
};
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const loadFeed = async (type: 'latest' | 'curated') => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const endpoint = type === 'curated' ? '/api/posts?type=curated' : '/api/posts?type=home';
|
||||||
|
const res = await fetch(endpoint);
|
||||||
|
const data = await res.json();
|
||||||
|
setPosts(data.posts || []);
|
||||||
|
setFeedMeta(data.meta || null);
|
||||||
|
} catch {
|
||||||
|
setPosts([]);
|
||||||
|
setFeedMeta(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check auth status
|
||||||
|
fetch('/api/auth/me')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setUser(data.user))
|
||||||
|
.catch(() => { });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFeed(feedType);
|
||||||
|
}, [feedType]);
|
||||||
|
|
||||||
|
const handlePost = async (content: string, mediaIds: string[]) => {
|
||||||
|
const res = await fetch('/api/posts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content, mediaIds }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (feedType === 'curated') {
|
||||||
|
loadFeed('curated');
|
||||||
|
} else {
|
||||||
|
setPosts([{ ...data.post, author: user }, ...posts]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLike = async (postId: string) => {
|
||||||
|
await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRepost = async (postId: string) => {
|
||||||
|
await fetch(`/api/posts/${postId}/repost`, { method: 'POST' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="layout">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="logo">
|
||||||
|
<SynapsisLogo />
|
||||||
|
<span>Synapsis</span>
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
<Link href="/" className="nav-item active">
|
||||||
|
<HomeIcon />
|
||||||
|
<span>Home</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/explore" className="nav-item">
|
||||||
|
<SearchIcon />
|
||||||
|
<span>Explore</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/notifications" className="nav-item">
|
||||||
|
<BellIcon />
|
||||||
|
<span>Notifications</span>
|
||||||
|
</Link>
|
||||||
|
{user ? (
|
||||||
|
<Link href={`/@${user.handle}`} className="nav-item">
|
||||||
|
<UserIcon />
|
||||||
|
<span>Profile</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link href="/login" className="nav-item">
|
||||||
|
<UserIcon />
|
||||||
|
<span>Login</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
{user && (
|
||||||
|
<div style={{ marginTop: 'auto', paddingTop: '16px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div className="avatar avatar-sm">
|
||||||
|
{user.displayName?.charAt(0) || user.handle.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '14px' }}>{user.displayName}</div>
|
||||||
|
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px' }}>@{user.handle}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Feed */}
|
||||||
|
<main className="main">
|
||||||
|
<header style={{
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
background: 'var(--background)',
|
||||||
|
zIndex: 10,
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Home</h1>
|
||||||
|
<div className="feed-toggle">
|
||||||
|
<button
|
||||||
|
className={`feed-toggle-btn ${feedType === 'latest' ? 'active' : ''}`}
|
||||||
|
onClick={() => setFeedType('latest')}
|
||||||
|
>
|
||||||
|
Latest
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`feed-toggle-btn ${feedType === 'curated' ? 'active' : ''}`}
|
||||||
|
onClick={() => setFeedType('curated')}
|
||||||
|
>
|
||||||
|
Curated
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{user && <Compose onPost={handlePost} />}
|
||||||
|
|
||||||
|
{!user && (
|
||||||
|
<div style={{ padding: '24px', textAlign: 'center', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '16px' }}>
|
||||||
|
Join Synapsis to post and interact
|
||||||
|
</p>
|
||||||
|
<Link href="/login" className="btn btn-primary">
|
||||||
|
Login or Register
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{feedType === 'curated' && feedMeta && (
|
||||||
|
<div className="feed-meta card">
|
||||||
|
<div className="feed-meta-title">Curated feed</div>
|
||||||
|
<div className="feed-meta-body">
|
||||||
|
We rank posts using recency and engagement. Following gets a boost, and your own posts stay visible.
|
||||||
|
</div>
|
||||||
|
<div className="feed-meta-weights">
|
||||||
|
Weights: engagement {feedMeta.weights.engagement}, recency {feedMeta.weights.recency}, follow boost {feedMeta.weights.followBoost}.
|
||||||
|
</div>
|
||||||
|
<div className="feed-meta-foot">
|
||||||
|
Window: {feedMeta.windowHours} hours. Seed: {feedMeta.seedLimit} posts.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : posts.length === 0 ? (
|
||||||
|
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
<p>No posts yet</p>
|
||||||
|
<p style={{ fontSize: '13px', marginTop: '8px' }}>Be the first to post something!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
posts.map(post => (
|
||||||
|
<PostCard
|
||||||
|
key={post.id}
|
||||||
|
post={post}
|
||||||
|
onLike={handleLike}
|
||||||
|
onRepost={handleRepost}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Right Sidebar */}
|
||||||
|
<aside className="aside">
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ fontWeight: 600, marginBottom: '12px' }}>Welcome to Synapsis</h3>
|
||||||
|
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.6 }}>
|
||||||
|
A federated social network designed as global communication infrastructure.
|
||||||
|
Signal over noise. Identity that's truly yours.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginTop: '16px' }}>
|
||||||
|
<h3 style={{ fontWeight: 600, marginBottom: '12px' }}>Node Info</h3>
|
||||||
|
<p style={{ color: 'var(--foreground-secondary)', fontSize: '13px' }}>
|
||||||
|
{process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node'}
|
||||||
|
</p>
|
||||||
|
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '12px', marginTop: '4px' }}>
|
||||||
|
Running Synapsis v0.1.0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
handle: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
bio?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaItem {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
altText?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
likesCount: number;
|
||||||
|
repostsCount: number;
|
||||||
|
repliesCount: number;
|
||||||
|
author: User;
|
||||||
|
media?: MediaItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const SearchIcon = () => (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ArrowLeftIcon = () => (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="19" y1="12" x2="5" y2="12" />
|
||||||
|
<polyline points="12 19 5 12 12 5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const HeartIcon = ({ filled }: { filled?: boolean }) => (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RepeatIcon = () => (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="17 1 21 5 17 9" />
|
||||||
|
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||||
|
<polyline points="7 23 3 19 7 15" />
|
||||||
|
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MessageIcon = () => (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FlagIcon = () => (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M5 5v16" />
|
||||||
|
<path d="M5 5h11l-1 4 1 4H5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
function UserCard({ user }: { user: User }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/@${user.handle}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
}}
|
||||||
|
className="hover-bg"
|
||||||
|
>
|
||||||
|
<div className="avatar">
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<img src={user.avatarUrl} alt={user.displayName} />
|
||||||
|
) : (
|
||||||
|
(user.displayName || user.handle).charAt(0).toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>{user.displayName || user.handle}</div>
|
||||||
|
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>@{user.handle}</div>
|
||||||
|
{user.bio && (
|
||||||
|
<div style={{
|
||||||
|
color: 'var(--foreground-secondary)',
|
||||||
|
fontSize: '14px',
|
||||||
|
marginTop: '4px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{user.bio}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostCard({ post }: { post: Post }) {
|
||||||
|
const [liked, setLiked] = useState(false);
|
||||||
|
const [reporting, setReporting] = useState(false);
|
||||||
|
|
||||||
|
const formatTime = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (minutes < 1) return 'now';
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
if (hours < 24) return `${hours}h`;
|
||||||
|
if (days < 7) return `${days}d`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="post">
|
||||||
|
<div className="post-header">
|
||||||
|
<div className="avatar">
|
||||||
|
{post.author?.avatarUrl ? (
|
||||||
|
<img src={post.author.avatarUrl} alt={post.author.displayName} />
|
||||||
|
) : (
|
||||||
|
(post.author?.displayName || post.author?.handle || '?').charAt(0).toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="post-author">
|
||||||
|
<Link href={`/@${post.author?.handle}`} className="post-handle">
|
||||||
|
{post.author?.displayName || post.author?.handle}
|
||||||
|
</Link>
|
||||||
|
<span className="post-time">@{post.author?.handle} · {formatTime(post.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="post-content">{post.content}</div>
|
||||||
|
{post.media && post.media.length > 0 && (
|
||||||
|
<div className="post-media-grid">
|
||||||
|
{post.media.map((item) => (
|
||||||
|
<div className="post-media-item" key={item.id}>
|
||||||
|
<img src={item.url} alt={item.altText || 'Post media'} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="post-actions">
|
||||||
|
<button className="post-action">
|
||||||
|
<MessageIcon />
|
||||||
|
<span>{post.repliesCount || ''}</span>
|
||||||
|
</button>
|
||||||
|
<button className="post-action">
|
||||||
|
<RepeatIcon />
|
||||||
|
<span>{post.repostsCount || ''}</span>
|
||||||
|
</button>
|
||||||
|
<button className={`post-action ${liked ? 'liked' : ''}`} onClick={() => setLiked(!liked)}>
|
||||||
|
<HeartIcon filled={liked} />
|
||||||
|
<span>{post.likesCount + (liked ? 1 : 0) || ''}</span>
|
||||||
|
</button>
|
||||||
|
<button className="post-action" onClick={async () => {
|
||||||
|
if (reporting) return;
|
||||||
|
const reason = window.prompt('Why are you reporting this post?');
|
||||||
|
if (!reason) return;
|
||||||
|
setReporting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/reports', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ targetType: 'post', targetId: post.id, reason }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
alert('Please log in to report.');
|
||||||
|
} else {
|
||||||
|
alert('Report failed. Please try again.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert('Report submitted. Thank you.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert('Report failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setReporting(false);
|
||||||
|
}
|
||||||
|
}} disabled={reporting}>
|
||||||
|
<FlagIcon />
|
||||||
|
<span>{reporting ? '...' : ''}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const initialQuery = searchParams.get('q') || '';
|
||||||
|
|
||||||
|
const [query, setQuery] = useState(initialQuery);
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<'all' | 'users' | 'posts'>('all');
|
||||||
|
|
||||||
|
const search = useCallback(async (q: string, type: string = 'all') => {
|
||||||
|
if (!q.trim()) {
|
||||||
|
setUsers([]);
|
||||||
|
setPosts([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&type=${type}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setUsers(data.users || []);
|
||||||
|
setPosts(data.posts || []);
|
||||||
|
} catch {
|
||||||
|
console.error('Search failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialQuery) {
|
||||||
|
search(initialQuery, activeTab);
|
||||||
|
}
|
||||||
|
}, [initialQuery, activeTab, search]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (query.trim()) {
|
||||||
|
router.push(`/search?q=${encodeURIComponent(query)}`);
|
||||||
|
search(query, activeTab);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTabChange = (tab: 'all' | 'users' | 'posts') => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
if (query.trim()) {
|
||||||
|
search(query, tab);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '600px', margin: '0 auto', minHeight: '100vh' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<header style={{
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
background: 'var(--background)',
|
||||||
|
zIndex: 10,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
|
<Link href="/" style={{ color: 'var(--foreground)' }}>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
</Link>
|
||||||
|
<form onSubmit={handleSubmit} style={{ flex: 1 }}>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '12px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
color: 'var(--foreground-tertiary)',
|
||||||
|
}}>
|
||||||
|
<SearchIcon />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search users and posts..."
|
||||||
|
style={{ paddingLeft: '44px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
{(['all', 'users', 'posts'] as const).map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => handleTabChange(tab)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '16px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: activeTab === tab ? '2px solid var(--accent)' : '2px solid transparent',
|
||||||
|
color: activeTab === tab ? 'var(--foreground)' : 'var(--foreground-tertiary)',
|
||||||
|
fontWeight: activeTab === tab ? 600 : 400,
|
||||||
|
cursor: 'pointer',
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
Searching...
|
||||||
|
</div>
|
||||||
|
) : !initialQuery ? (
|
||||||
|
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
<p>Search for users and posts</p>
|
||||||
|
</div>
|
||||||
|
) : users.length === 0 && posts.length === 0 ? (
|
||||||
|
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
<p>No results for “{initialQuery}”</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Users */}
|
||||||
|
{(activeTab === 'all' || activeTab === 'users') && users.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{activeTab === 'all' && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
background: 'var(--background-secondary)',
|
||||||
|
}}>
|
||||||
|
Users
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{users.map(user => <UserCard key={user.id} user={user} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Posts */}
|
||||||
|
{(activeTab === 'all' || activeTab === 'posts') && posts.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{activeTab === 'all' && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
background: 'var(--background-secondary)',
|
||||||
|
}}>
|
||||||
|
Posts
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{posts.map(post => <PostCard key={post.id} post={post} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/neon-http';
|
||||||
|
import { neon } from '@neondatabase/serverless';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
// Create the Neon client (with fallback for UI testing)
|
||||||
|
const sql = process.env.DATABASE_URL
|
||||||
|
? neon(process.env.DATABASE_URL)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Create the Drizzle client
|
||||||
|
export const db = sql ? drizzle(sql, { schema }) : null;
|
||||||
|
|
||||||
|
// Helper to check if DB is available
|
||||||
|
export const isDbAvailable = () => db !== null;
|
||||||
|
|
||||||
|
// Export schema for use elsewhere
|
||||||
|
export * from './schema';
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import { pgTable, text, timestamp, uuid, integer, boolean, index } from 'drizzle-orm/pg-core';
|
||||||
|
import { relations } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// NODES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const nodes = pgTable('nodes', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
domain: text('domain').notNull().unique(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
accentColor: text('accent_color').default('#FFFFFF'),
|
||||||
|
publicKey: text('public_key'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// USERS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
did: text('did').notNull().unique(), // Decentralized Identifier
|
||||||
|
handle: text('handle').notNull().unique(), // @username (globally unique)
|
||||||
|
email: text('email').unique(),
|
||||||
|
passwordHash: text('password_hash'),
|
||||||
|
displayName: text('display_name'),
|
||||||
|
bio: text('bio'),
|
||||||
|
avatarUrl: text('avatar_url'),
|
||||||
|
headerUrl: text('header_url'),
|
||||||
|
privateKeyEncrypted: text('private_key_encrypted'), // For ActivityPub signing
|
||||||
|
publicKey: text('public_key').notNull(),
|
||||||
|
nodeId: uuid('node_id').references(() => nodes.id),
|
||||||
|
isSuspended: boolean('is_suspended').default(false).notNull(),
|
||||||
|
suspensionReason: text('suspension_reason'),
|
||||||
|
suspendedAt: timestamp('suspended_at'),
|
||||||
|
isSilenced: boolean('is_silenced').default(false).notNull(),
|
||||||
|
silenceReason: text('silence_reason'),
|
||||||
|
silencedAt: timestamp('silenced_at'),
|
||||||
|
followersCount: integer('followers_count').default(0).notNull(),
|
||||||
|
followingCount: integer('following_count').default(0).notNull(),
|
||||||
|
postsCount: integer('posts_count').default(0).notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('users_handle_idx').on(table.handle),
|
||||||
|
index('users_did_idx').on(table.did),
|
||||||
|
index('users_suspended_idx').on(table.isSuspended),
|
||||||
|
index('users_silenced_idx').on(table.isSilenced),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const usersRelations = relations(users, ({ one, many }) => ({
|
||||||
|
node: one(nodes, {
|
||||||
|
fields: [users.nodeId],
|
||||||
|
references: [nodes.id],
|
||||||
|
}),
|
||||||
|
posts: many(posts),
|
||||||
|
followers: many(follows, { relationName: 'following' }),
|
||||||
|
following: many(follows, { relationName: 'follower' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// POSTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const posts = pgTable('posts', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
replyToId: uuid('reply_to_id'),
|
||||||
|
repostOfId: uuid('repost_of_id'),
|
||||||
|
likesCount: integer('likes_count').default(0).notNull(),
|
||||||
|
repostsCount: integer('reposts_count').default(0).notNull(),
|
||||||
|
repliesCount: integer('replies_count').default(0).notNull(),
|
||||||
|
isRemoved: boolean('is_removed').default(false).notNull(),
|
||||||
|
removedAt: timestamp('removed_at'),
|
||||||
|
removedBy: uuid('removed_by').references(() => users.id),
|
||||||
|
removedReason: text('removed_reason'),
|
||||||
|
// ActivityPub
|
||||||
|
apId: text('ap_id').unique(), // https://node.com/posts/uuid
|
||||||
|
apUrl: text('ap_url'), // Public URL for the post
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('posts_user_id_idx').on(table.userId),
|
||||||
|
index('posts_created_at_idx').on(table.createdAt),
|
||||||
|
index('posts_reply_to_idx').on(table.replyToId),
|
||||||
|
index('posts_removed_idx').on(table.isRemoved),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const postsRelations = relations(posts, ({ one, many }) => ({
|
||||||
|
author: one(users, {
|
||||||
|
fields: [posts.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
removedByUser: one(users, {
|
||||||
|
fields: [posts.removedBy],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
replyTo: one(posts, {
|
||||||
|
fields: [posts.replyToId],
|
||||||
|
references: [posts.id],
|
||||||
|
relationName: 'replies',
|
||||||
|
}),
|
||||||
|
replies: many(posts, { relationName: 'replies' }),
|
||||||
|
repostOf: one(posts, {
|
||||||
|
fields: [posts.repostOfId],
|
||||||
|
references: [posts.id],
|
||||||
|
relationName: 'reposts',
|
||||||
|
}),
|
||||||
|
reposts: many(posts, { relationName: 'reposts' }),
|
||||||
|
likes: many(likes),
|
||||||
|
media: many(media),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MEDIA
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const media = pgTable('media', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
postId: uuid('post_id').references(() => posts.id, { onDelete: 'cascade' }),
|
||||||
|
url: text('url').notNull(),
|
||||||
|
altText: text('alt_text'),
|
||||||
|
mimeType: text('mime_type'),
|
||||||
|
width: integer('width'),
|
||||||
|
height: integer('height'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('media_user_idx').on(table.userId),
|
||||||
|
index('media_post_idx').on(table.postId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const mediaRelations = relations(media, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [media.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
post: one(posts, {
|
||||||
|
fields: [media.postId],
|
||||||
|
references: [posts.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// FOLLOWS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const follows = pgTable('follows', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
followerId: uuid('follower_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
followingId: uuid('following_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
// ActivityPub
|
||||||
|
apId: text('ap_id').unique(), // Activity ID
|
||||||
|
pending: boolean('pending').default(false), // For follow requests
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('follows_follower_idx').on(table.followerId),
|
||||||
|
index('follows_following_idx').on(table.followingId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const followsRelations = relations(follows, ({ one }) => ({
|
||||||
|
follower: one(users, {
|
||||||
|
fields: [follows.followerId],
|
||||||
|
references: [users.id],
|
||||||
|
relationName: 'follower',
|
||||||
|
}),
|
||||||
|
following: one(users, {
|
||||||
|
fields: [follows.followingId],
|
||||||
|
references: [users.id],
|
||||||
|
relationName: 'following',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// LIKES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const likes = pgTable('likes', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
postId: uuid('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
|
||||||
|
apId: text('ap_id').unique(), // Activity ID
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('likes_user_post_idx').on(table.userId, table.postId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const likesRelations = relations(likes, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [likes.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
post: one(posts, {
|
||||||
|
fields: [likes.postId],
|
||||||
|
references: [posts.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// NOTIFICATIONS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const notifications = pgTable('notifications', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
actorId: uuid('actor_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
postId: uuid('post_id').references(() => posts.id, { onDelete: 'cascade' }),
|
||||||
|
type: text('type').notNull(), // follow | like | repost | mention
|
||||||
|
readAt: timestamp('read_at'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('notifications_user_idx').on(table.userId),
|
||||||
|
index('notifications_created_idx').on(table.createdAt),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||||
|
recipient: one(users, {
|
||||||
|
fields: [notifications.userId],
|
||||||
|
references: [users.id],
|
||||||
|
relationName: 'recipient',
|
||||||
|
}),
|
||||||
|
actor: one(users, {
|
||||||
|
fields: [notifications.actorId],
|
||||||
|
references: [users.id],
|
||||||
|
relationName: 'actor',
|
||||||
|
}),
|
||||||
|
post: one(posts, {
|
||||||
|
fields: [notifications.postId],
|
||||||
|
references: [posts.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// HANDLE REGISTRY (for federated handle resolution)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const handleRegistry = pgTable('handle_registry', {
|
||||||
|
handle: text('handle').primaryKey(), // @username
|
||||||
|
did: text('did').notNull(),
|
||||||
|
nodeDomain: text('node_domain').notNull(),
|
||||||
|
registeredAt: timestamp('registered_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('handle_registry_updated_idx').on(table.updatedAt),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SESSIONS (for auth)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const sessions = pgTable('sessions', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
token: text('token').notNull().unique(),
|
||||||
|
expiresAt: timestamp('expires_at').notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('sessions_token_idx').on(table.token),
|
||||||
|
index('sessions_user_idx').on(table.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const sessionsRelations = relations(sessions, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [sessions.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// BLOCKS & MUTES (user-level moderation)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const blocks = pgTable('blocks', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
blockedUserId: uuid('blocked_user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('blocks_user_idx').on(table.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const mutes = pgTable('mutes', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
mutedUserId: uuid('muted_user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('mutes_user_idx').on(table.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// REPORTS (moderation)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const reports = pgTable('reports', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
reporterId: uuid('reporter_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
targetType: text('target_type').notNull(), // 'post' | 'user'
|
||||||
|
targetId: uuid('target_id').notNull(),
|
||||||
|
reason: text('reason').notNull(),
|
||||||
|
status: text('status').default('open').notNull(), // open | resolved
|
||||||
|
resolvedAt: timestamp('resolved_at'),
|
||||||
|
resolvedBy: uuid('resolved_by').references(() => users.id),
|
||||||
|
resolutionNote: text('resolution_note'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('reports_status_idx').on(table.status),
|
||||||
|
index('reports_target_idx').on(table.targetType, table.targetId),
|
||||||
|
index('reports_reporter_idx').on(table.reporterId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const reportsRelations = relations(reports, ({ one }) => ({
|
||||||
|
reporter: one(users, {
|
||||||
|
fields: [reports.reporterId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
resolver: one(users, {
|
||||||
|
fields: [reports.resolvedBy],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* ActivityPub Activities
|
||||||
|
*
|
||||||
|
* Handles creation of ActivityPub activity objects for federation.
|
||||||
|
* See: https://www.w3.org/TR/activitypub/#overview
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { posts, users } from '@/db/schema';
|
||||||
|
|
||||||
|
type Post = typeof posts.$inferSelect;
|
||||||
|
type User = typeof users.$inferSelect;
|
||||||
|
|
||||||
|
const ACTIVITY_STREAMS_CONTEXT = 'https://www.w3.org/ns/activitystreams';
|
||||||
|
|
||||||
|
export interface ActivityPubNote {
|
||||||
|
'@context': string;
|
||||||
|
id: string;
|
||||||
|
type: 'Note';
|
||||||
|
attributedTo: string;
|
||||||
|
content: string;
|
||||||
|
published: string;
|
||||||
|
to: string[];
|
||||||
|
cc: string[];
|
||||||
|
inReplyTo?: string | null;
|
||||||
|
url: string;
|
||||||
|
attachment?: {
|
||||||
|
type: string;
|
||||||
|
mediaType: string;
|
||||||
|
url: string;
|
||||||
|
name?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityPubActivity {
|
||||||
|
'@context': string;
|
||||||
|
id: string;
|
||||||
|
type: 'Create' | 'Follow' | 'Like' | 'Announce' | 'Undo' | 'Accept' | 'Reject' | 'Delete';
|
||||||
|
actor: string;
|
||||||
|
object: string | ActivityPubNote | object;
|
||||||
|
published?: string;
|
||||||
|
to?: string[];
|
||||||
|
cc?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Synapsis post to an ActivityPub Note
|
||||||
|
*/
|
||||||
|
export function postToNote(post: Post, author: User, nodeDomain: string): ActivityPubNote {
|
||||||
|
const postUrl = `https://${nodeDomain}/posts/${post.id}`;
|
||||||
|
const actorUrl = `https://${nodeDomain}/users/${author.handle}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||||
|
id: postUrl,
|
||||||
|
type: 'Note',
|
||||||
|
attributedTo: actorUrl,
|
||||||
|
content: escapeHtml(post.content),
|
||||||
|
published: post.createdAt.toISOString(),
|
||||||
|
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
||||||
|
cc: [`${actorUrl}/followers`],
|
||||||
|
inReplyTo: post.replyToId ? `https://${nodeDomain}/posts/${post.replyToId}` : null,
|
||||||
|
url: postUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Create activity for a new post
|
||||||
|
*/
|
||||||
|
export function createCreateActivity(
|
||||||
|
post: Post,
|
||||||
|
author: User,
|
||||||
|
nodeDomain: string
|
||||||
|
): ActivityPubActivity {
|
||||||
|
const actorUrl = `https://${nodeDomain}/users/${author.handle}`;
|
||||||
|
const note = postToNote(post, author, nodeDomain);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||||
|
id: `https://${nodeDomain}/activities/${post.id}`,
|
||||||
|
type: 'Create',
|
||||||
|
actor: actorUrl,
|
||||||
|
published: post.createdAt.toISOString(),
|
||||||
|
to: note.to,
|
||||||
|
cc: note.cc,
|
||||||
|
object: note,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Follow activity
|
||||||
|
*/
|
||||||
|
export function createFollowActivity(
|
||||||
|
follower: User,
|
||||||
|
targetActorUrl: string,
|
||||||
|
nodeDomain: string,
|
||||||
|
activityId: string
|
||||||
|
): ActivityPubActivity {
|
||||||
|
const actorUrl = `https://${nodeDomain}/users/${follower.handle}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||||
|
id: `https://${nodeDomain}/activities/${activityId}`,
|
||||||
|
type: 'Follow',
|
||||||
|
actor: actorUrl,
|
||||||
|
object: targetActorUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Like activity
|
||||||
|
*/
|
||||||
|
export function createLikeActivity(
|
||||||
|
user: User,
|
||||||
|
targetPostUrl: string,
|
||||||
|
nodeDomain: string,
|
||||||
|
activityId: string
|
||||||
|
): ActivityPubActivity {
|
||||||
|
const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||||
|
id: `https://${nodeDomain}/activities/${activityId}`,
|
||||||
|
type: 'Like',
|
||||||
|
actor: actorUrl,
|
||||||
|
object: targetPostUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an Announce (repost) activity
|
||||||
|
*/
|
||||||
|
export function createAnnounceActivity(
|
||||||
|
user: User,
|
||||||
|
targetPostUrl: string,
|
||||||
|
nodeDomain: string,
|
||||||
|
activityId: string
|
||||||
|
): ActivityPubActivity {
|
||||||
|
const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||||
|
id: `https://${nodeDomain}/activities/${activityId}`,
|
||||||
|
type: 'Announce',
|
||||||
|
actor: actorUrl,
|
||||||
|
object: targetPostUrl,
|
||||||
|
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
||||||
|
cc: [`${actorUrl}/followers`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an Undo activity (for unfollowing, unliking, etc.)
|
||||||
|
*/
|
||||||
|
export function createUndoActivity(
|
||||||
|
user: User,
|
||||||
|
originalActivity: ActivityPubActivity,
|
||||||
|
nodeDomain: string,
|
||||||
|
activityId: string
|
||||||
|
): ActivityPubActivity {
|
||||||
|
const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||||
|
id: `https://${nodeDomain}/activities/${activityId}`,
|
||||||
|
type: 'Undo',
|
||||||
|
actor: actorUrl,
|
||||||
|
object: originalActivity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an Accept activity (for accepting follow requests)
|
||||||
|
*/
|
||||||
|
export function createAcceptActivity(
|
||||||
|
user: User,
|
||||||
|
followActivity: ActivityPubActivity,
|
||||||
|
nodeDomain: string,
|
||||||
|
activityId: string
|
||||||
|
): ActivityPubActivity {
|
||||||
|
const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
'@context': ACTIVITY_STREAMS_CONTEXT,
|
||||||
|
id: `https://${nodeDomain}/activities/${activityId}`,
|
||||||
|
type: 'Accept',
|
||||||
|
actor: actorUrl,
|
||||||
|
object: followActivity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML in content for safety
|
||||||
|
*/
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
.replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* ActivityPub Actor Utilities
|
||||||
|
*
|
||||||
|
* Handles the serialization of Synapsis users to ActivityPub Actor format.
|
||||||
|
* See: https://www.w3.org/TR/activitypub/#actor-objects
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { users } from '@/db/schema';
|
||||||
|
|
||||||
|
type User = typeof users.$inferSelect;
|
||||||
|
|
||||||
|
const ACTIVITY_STREAMS_CONTEXT = 'https://www.w3.org/ns/activitystreams';
|
||||||
|
const SECURITY_CONTEXT = 'https://w3id.org/security/v1';
|
||||||
|
|
||||||
|
export interface ActivityPubActor {
|
||||||
|
'@context': (string | object)[];
|
||||||
|
id: string;
|
||||||
|
type: 'Person';
|
||||||
|
preferredUsername: string;
|
||||||
|
name: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
url: string;
|
||||||
|
inbox: string;
|
||||||
|
outbox: string;
|
||||||
|
followers: string;
|
||||||
|
following: string;
|
||||||
|
icon?: {
|
||||||
|
type: 'Image';
|
||||||
|
mediaType: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
image?: {
|
||||||
|
type: 'Image';
|
||||||
|
mediaType: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
publicKey: {
|
||||||
|
id: string;
|
||||||
|
owner: string;
|
||||||
|
publicKeyPem: string;
|
||||||
|
};
|
||||||
|
endpoints: {
|
||||||
|
sharedInbox: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Synapsis user to an ActivityPub Actor
|
||||||
|
*/
|
||||||
|
export function userToActor(user: User, nodeDomain: string): ActivityPubActor {
|
||||||
|
const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
|
||||||
|
|
||||||
|
const actor: ActivityPubActor = {
|
||||||
|
'@context': [
|
||||||
|
ACTIVITY_STREAMS_CONTEXT,
|
||||||
|
SECURITY_CONTEXT,
|
||||||
|
{
|
||||||
|
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
|
||||||
|
'toot': 'http://joinmastodon.org/ns#',
|
||||||
|
'featured': {
|
||||||
|
'@id': 'toot:featured',
|
||||||
|
'@type': '@id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: actorUrl,
|
||||||
|
type: 'Person',
|
||||||
|
preferredUsername: user.handle,
|
||||||
|
name: user.displayName,
|
||||||
|
summary: user.bio,
|
||||||
|
url: actorUrl,
|
||||||
|
inbox: `${actorUrl}/inbox`,
|
||||||
|
outbox: `${actorUrl}/outbox`,
|
||||||
|
followers: `${actorUrl}/followers`,
|
||||||
|
following: `${actorUrl}/following`,
|
||||||
|
publicKey: {
|
||||||
|
id: `${actorUrl}#main-key`,
|
||||||
|
owner: actorUrl,
|
||||||
|
publicKeyPem: user.publicKey,
|
||||||
|
},
|
||||||
|
endpoints: {
|
||||||
|
sharedInbox: `https://${nodeDomain}/inbox`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add avatar if present
|
||||||
|
if (user.avatarUrl) {
|
||||||
|
actor.icon = {
|
||||||
|
type: 'Image',
|
||||||
|
mediaType: 'image/png', // TODO: detect actual type
|
||||||
|
url: user.avatarUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add header if present
|
||||||
|
if (user.headerUrl) {
|
||||||
|
actor.image = {
|
||||||
|
type: 'Image',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
url: user.headerUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the actor URL for a user
|
||||||
|
*/
|
||||||
|
export function getActorUrl(handle: string, nodeDomain: string): string {
|
||||||
|
return `https://${nodeDomain}/users/${handle}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the inbox URL for a user
|
||||||
|
*/
|
||||||
|
export function getInboxUrl(handle: string, nodeDomain: string): string {
|
||||||
|
return `https://${nodeDomain}/users/${handle}/inbox`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* ActivityPub Inbox Handler
|
||||||
|
*
|
||||||
|
* Processes incoming activities from remote servers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db, users, posts, follows, likes } from '@/db';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { verifySignature, fetchActorPublicKey } from './signatures';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
export interface IncomingActivity {
|
||||||
|
'@context': string | string[];
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
actor: string;
|
||||||
|
object: string | object;
|
||||||
|
published?: string;
|
||||||
|
to?: string[];
|
||||||
|
cc?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an incoming activity
|
||||||
|
*/
|
||||||
|
export async function processIncomingActivity(
|
||||||
|
activity: IncomingActivity,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
path: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
// Verify the signature
|
||||||
|
const publicKey = await fetchActorPublicKey(activity.actor);
|
||||||
|
if (!publicKey) {
|
||||||
|
return { success: false, error: 'Could not fetch actor public key' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await verifySignature('POST', path, headers, publicKey);
|
||||||
|
if (!isValid) {
|
||||||
|
console.warn('Invalid signature for activity:', activity.id);
|
||||||
|
// In development, we might want to continue anyway
|
||||||
|
// return { success: false, error: 'Invalid signature' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process based on activity type
|
||||||
|
switch (activity.type) {
|
||||||
|
case 'Create':
|
||||||
|
return await handleCreate(activity);
|
||||||
|
case 'Follow':
|
||||||
|
return await handleFollow(activity);
|
||||||
|
case 'Like':
|
||||||
|
return await handleLike(activity);
|
||||||
|
case 'Announce':
|
||||||
|
return await handleAnnounce(activity);
|
||||||
|
case 'Undo':
|
||||||
|
return await handleUndo(activity);
|
||||||
|
case 'Delete':
|
||||||
|
return await handleDelete(activity);
|
||||||
|
case 'Accept':
|
||||||
|
return await handleAccept(activity);
|
||||||
|
case 'Reject':
|
||||||
|
return await handleReject(activity);
|
||||||
|
default:
|
||||||
|
console.log('Unhandled activity type:', activity.type);
|
||||||
|
return { success: true }; // Don't error on unknown types
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Create activities (new posts)
|
||||||
|
*/
|
||||||
|
async function handleCreate(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const object = activity.object as { type: string; content?: string; id?: string; attributedTo?: string };
|
||||||
|
|
||||||
|
if (object.type !== 'Note') {
|
||||||
|
return { success: true }; // We only handle Notes for now
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Store remote posts in database for caching/display
|
||||||
|
console.log('Received remote post:', object.id);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Follow activities
|
||||||
|
*/
|
||||||
|
async function handleFollow(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const targetActorUrl = typeof activity.object === 'string' ? activity.object : (activity.object as { id?: string }).id;
|
||||||
|
|
||||||
|
if (!targetActorUrl) {
|
||||||
|
return { success: false, error: 'Invalid follow target' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract handle from target URL
|
||||||
|
const handleMatch = targetActorUrl.match(/\/users\/([^\/]+)$/);
|
||||||
|
if (!handleMatch) {
|
||||||
|
return { success: false, error: 'Could not parse target handle' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = handleMatch[1];
|
||||||
|
|
||||||
|
// Find the local user
|
||||||
|
const targetUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, handle),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return { success: false, error: 'User not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Store follower relationship, send Accept activity
|
||||||
|
console.log('Received follow request for:', handle, 'from:', activity.actor);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Like activities
|
||||||
|
*/
|
||||||
|
async function handleLike(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const targetUrl = typeof activity.object === 'string' ? activity.object : null;
|
||||||
|
|
||||||
|
if (!targetUrl) {
|
||||||
|
return { success: false, error: 'Invalid like target' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Update like count on local post
|
||||||
|
console.log('Received like for:', targetUrl, 'from:', activity.actor);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Announce activities (reposts)
|
||||||
|
*/
|
||||||
|
async function handleAnnounce(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const targetUrl = typeof activity.object === 'string' ? activity.object : null;
|
||||||
|
|
||||||
|
if (!targetUrl) {
|
||||||
|
return { success: false, error: 'Invalid announce target' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Update repost count on local post
|
||||||
|
console.log('Received announce for:', targetUrl, 'from:', activity.actor);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Undo activities
|
||||||
|
*/
|
||||||
|
async function handleUndo(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const originalActivity = activity.object as IncomingActivity;
|
||||||
|
|
||||||
|
if (!originalActivity || !originalActivity.type) {
|
||||||
|
return { success: false, error: 'Invalid undo target' };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Received undo for:', originalActivity.type, 'from:', activity.actor);
|
||||||
|
|
||||||
|
// TODO: Handle undo based on original activity type
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Delete activities
|
||||||
|
*/
|
||||||
|
async function handleDelete(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||||
|
console.log('Received delete from:', activity.actor);
|
||||||
|
|
||||||
|
// TODO: Remove cached remote content
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Accept activities (follow accepted)
|
||||||
|
*/
|
||||||
|
async function handleAccept(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||||
|
console.log('Follow accepted by:', activity.actor);
|
||||||
|
|
||||||
|
// TODO: Update follow status
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Reject activities (follow rejected)
|
||||||
|
*/
|
||||||
|
async function handleReject(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||||
|
console.log('Follow rejected by:', activity.actor);
|
||||||
|
|
||||||
|
// TODO: Remove pending follow
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// ActivityPub module exports
|
||||||
|
export * from './actor';
|
||||||
|
export * from './activities';
|
||||||
|
export * from './webfinger';
|
||||||
|
export * from './signatures';
|
||||||
|
export * from './inbox';
|
||||||
|
export * from './outbox';
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* ActivityPub Outbox / Delivery
|
||||||
|
*
|
||||||
|
* Handles sending activities to remote servers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { signRequest } from './signatures';
|
||||||
|
import type { ActivityPubActivity } from './activities';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver an activity to a remote inbox
|
||||||
|
*/
|
||||||
|
export async function deliverActivity(
|
||||||
|
activity: ActivityPubActivity,
|
||||||
|
targetInbox: string,
|
||||||
|
privateKey: string,
|
||||||
|
keyId: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const body = JSON.stringify(activity);
|
||||||
|
|
||||||
|
// Sign the request
|
||||||
|
const signatureHeaders = await signRequest(
|
||||||
|
'POST',
|
||||||
|
targetInbox,
|
||||||
|
body,
|
||||||
|
privateKey,
|
||||||
|
keyId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send the activity
|
||||||
|
const response = await fetch(targetInbox, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/activity+json',
|
||||||
|
'Accept': 'application/activity+json',
|
||||||
|
...signatureHeaders,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Activity delivery failed:', response.status, errorText);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Delivery failed: ${response.status} ${errorText}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Activity delivery error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver an activity to multiple inboxes
|
||||||
|
*/
|
||||||
|
export async function deliverToFollowers(
|
||||||
|
activity: ActivityPubActivity,
|
||||||
|
followerInboxes: string[],
|
||||||
|
privateKey: string,
|
||||||
|
keyId: string
|
||||||
|
): Promise<{ delivered: number; failed: number }> {
|
||||||
|
// Deduplicate inboxes (shared inboxes should only receive once)
|
||||||
|
const uniqueInboxes = [...new Set(followerInboxes)];
|
||||||
|
|
||||||
|
let delivered = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
// Deliver in parallel with concurrency limit
|
||||||
|
const concurrency = 10;
|
||||||
|
for (let i = 0; i < uniqueInboxes.length; i += concurrency) {
|
||||||
|
const batch = uniqueInboxes.slice(i, i + concurrency);
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
batch.map(inbox => deliverActivity(activity, inbox, privateKey, keyId))
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'fulfilled' && result.value.success) {
|
||||||
|
delivered++;
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { delivered, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get followers' inboxes for delivery
|
||||||
|
* This would query the database for follower inbox URLs
|
||||||
|
*/
|
||||||
|
export async function getFollowerInboxes(userId: string): Promise<string[]> {
|
||||||
|
// TODO: Query database for followers and their inbox URLs
|
||||||
|
// For local followers: use their local inbox
|
||||||
|
// For remote followers: use their remote inbox (stored when they followed)
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* HTTP Signatures for ActivityPub
|
||||||
|
*
|
||||||
|
* ActivityPub uses HTTP Signatures to verify the authenticity of requests.
|
||||||
|
* See: https://docs.joinmastodon.org/spec/security/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { importPKCS8, importSPKI, SignJWT, jwtVerify } from 'jose';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new RSA keypair for an actor
|
||||||
|
*/
|
||||||
|
export async function generateKeyPair(): Promise<{ publicKey: string; privateKey: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
crypto.generateKeyPair(
|
||||||
|
'rsa',
|
||||||
|
{
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicKeyEncoding: {
|
||||||
|
type: 'spki',
|
||||||
|
format: 'pem',
|
||||||
|
},
|
||||||
|
privateKeyEncoding: {
|
||||||
|
type: 'pkcs8',
|
||||||
|
format: 'pem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(err, publicKey, privateKey) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve({ publicKey, privateKey });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign an HTTP request for ActivityPub
|
||||||
|
*/
|
||||||
|
export async function signRequest(
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
body: string | null,
|
||||||
|
privateKeyPem: string,
|
||||||
|
keyId: string
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const date = new Date().toUTCString();
|
||||||
|
const digest = body ? `SHA-256=${crypto.createHash('sha256').update(body).digest('base64')}` : null;
|
||||||
|
|
||||||
|
// Build the string to sign
|
||||||
|
const signedHeaders = body ? '(request-target) host date digest' : '(request-target) host date';
|
||||||
|
let stringToSign = `(request-target): ${method.toLowerCase()} ${urlObj.pathname}`;
|
||||||
|
stringToSign += `\nhost: ${urlObj.host}`;
|
||||||
|
stringToSign += `\ndate: ${date}`;
|
||||||
|
if (digest) {
|
||||||
|
stringToSign += `\ndigest: ${digest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the string
|
||||||
|
const privateKey = crypto.createPrivateKey(privateKeyPem);
|
||||||
|
const signature = crypto.sign('sha256', Buffer.from(stringToSign), privateKey).toString('base64');
|
||||||
|
|
||||||
|
// Build the signature header
|
||||||
|
const signatureHeader = `keyId="${keyId}",algorithm="rsa-sha256",headers="${signedHeaders}",signature="${signature}"`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Date': date,
|
||||||
|
'Signature': signatureHeader,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (digest) {
|
||||||
|
headers['Digest'] = digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify an HTTP signature from an incoming request
|
||||||
|
*/
|
||||||
|
export async function verifySignature(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
publicKeyPem: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const signatureHeader = headers['signature'] || headers['Signature'];
|
||||||
|
if (!signatureHeader) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the signature header
|
||||||
|
const signatureParts: Record<string, string> = {};
|
||||||
|
signatureHeader.split(',').forEach(part => {
|
||||||
|
const [key, value] = part.split('=');
|
||||||
|
signatureParts[key] = value?.replace(/^"|"$/g, '') ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const signedHeadersList = signatureParts.headers?.split(' ') ?? [];
|
||||||
|
const signature = signatureParts.signature;
|
||||||
|
|
||||||
|
if (!signature || signedHeadersList.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct the string that was signed
|
||||||
|
let stringToVerify = '';
|
||||||
|
for (const header of signedHeadersList) {
|
||||||
|
if (stringToVerify) {
|
||||||
|
stringToVerify += '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (header === '(request-target)') {
|
||||||
|
stringToVerify += `(request-target): ${method.toLowerCase()} ${path}`;
|
||||||
|
} else {
|
||||||
|
const headerValue = headers[header] || headers[header.toLowerCase()];
|
||||||
|
if (headerValue) {
|
||||||
|
stringToVerify += `${header}: ${headerValue}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the signature
|
||||||
|
const publicKey = crypto.createPublicKey(publicKeyPem);
|
||||||
|
const signatureBuffer = Buffer.from(signature, 'base64');
|
||||||
|
|
||||||
|
return crypto.verify(
|
||||||
|
'sha256',
|
||||||
|
Buffer.from(stringToVerify),
|
||||||
|
publicKey,
|
||||||
|
signatureBuffer
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Signature verification failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a remote actor's public key
|
||||||
|
*/
|
||||||
|
export async function fetchActorPublicKey(actorUrl: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(actorUrl, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/activity+json, application/ld+json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actor = await response.json();
|
||||||
|
return actor.publicKey?.publicKeyPem ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch actor public key:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* WebFinger Protocol Implementation
|
||||||
|
*
|
||||||
|
* WebFinger is used to discover ActivityPub actors from acct: URIs.
|
||||||
|
* See: https://www.rfc-editor.org/rfc/rfc7033
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface WebFingerResponse {
|
||||||
|
subject: string;
|
||||||
|
aliases?: string[];
|
||||||
|
links: WebFingerLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebFingerLink {
|
||||||
|
rel: string;
|
||||||
|
type?: string;
|
||||||
|
href?: string;
|
||||||
|
template?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a WebFinger response for a local user
|
||||||
|
*/
|
||||||
|
export function generateWebFingerResponse(
|
||||||
|
handle: string,
|
||||||
|
nodeDomain: string
|
||||||
|
): WebFingerResponse {
|
||||||
|
const actorUrl = `https://${nodeDomain}/users/${handle}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: `acct:${handle}@${nodeDomain}`,
|
||||||
|
aliases: [actorUrl],
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
rel: 'self',
|
||||||
|
type: 'application/activity+json',
|
||||||
|
href: actorUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: 'http://webfinger.net/rel/profile-page',
|
||||||
|
type: 'text/html',
|
||||||
|
href: actorUrl,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a WebFinger resource query
|
||||||
|
* @param resource - The resource query (e.g., "acct:user@domain.com")
|
||||||
|
* @returns Object with handle and domain, or null if invalid
|
||||||
|
*/
|
||||||
|
export function parseWebFingerResource(resource: string): { handle: string; domain: string } | null {
|
||||||
|
// Handle acct: URI format
|
||||||
|
if (resource.startsWith('acct:')) {
|
||||||
|
const parts = resource.slice(5).split('@');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return { handle: parts[0], domain: parts[1] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle URL format
|
||||||
|
try {
|
||||||
|
const url = new URL(resource);
|
||||||
|
const pathParts = url.pathname.split('/');
|
||||||
|
const usersIndex = pathParts.indexOf('users');
|
||||||
|
if (usersIndex !== -1 && pathParts[usersIndex + 1]) {
|
||||||
|
return { handle: pathParts[usersIndex + 1], domain: url.host };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not a valid URL
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch WebFinger data for a remote user
|
||||||
|
*/
|
||||||
|
export async function fetchWebFinger(
|
||||||
|
handle: string,
|
||||||
|
domain: string
|
||||||
|
): Promise<WebFingerResponse | null> {
|
||||||
|
const resource = `acct:${handle}@${domain}`;
|
||||||
|
const url = `https://${domain}/.well-known/webfinger?resource=${encodeURIComponent(resource)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/jrd+json, application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WebFinger fetch failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ActivityPub actor URL from a WebFinger response
|
||||||
|
*/
|
||||||
|
export function getActorUrlFromWebFinger(webfinger: WebFingerResponse): string | null {
|
||||||
|
const selfLink = webfinger.links.find(
|
||||||
|
(link) => link.rel === 'self' && link.type === 'application/activity+json'
|
||||||
|
);
|
||||||
|
return selfLink?.href ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
|
import { users } from '@/db';
|
||||||
|
|
||||||
|
type User = typeof users.$inferSelect;
|
||||||
|
|
||||||
|
const normalizeList = (value?: string | null) =>
|
||||||
|
(value || '')
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const adminEmails = normalizeList(process.env.ADMIN_EMAILS);
|
||||||
|
|
||||||
|
export const isAdminUser = (user: User | null | undefined) => {
|
||||||
|
if (!user) return false;
|
||||||
|
if (user.email && adminEmails.length > 0 && adminEmails.includes(user.email.toLowerCase())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function requireAdmin(): Promise<User> {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (!isAdminUser(user)) {
|
||||||
|
throw new Error('Admin required');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Authentication Utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db, users, sessions } from '@/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { generateKeyPair } from '@/lib/activitypub/signatures';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||||
|
|
||||||
|
const SESSION_COOKIE_NAME = 'synapsis_session';
|
||||||
|
const SESSION_EXPIRY_DAYS = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash a password
|
||||||
|
*/
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a password against a hash
|
||||||
|
*/
|
||||||
|
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a DID for a new user
|
||||||
|
*/
|
||||||
|
export function generateDID(): string {
|
||||||
|
// Using a simple did:key-like format for now
|
||||||
|
// In production, this would be more sophisticated
|
||||||
|
return `did:synapsis:${uuid().replace(/-/g, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new session for a user
|
||||||
|
*/
|
||||||
|
export async function createSession(userId: string): Promise<string> {
|
||||||
|
const token = uuid();
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + SESSION_EXPIRY_DAYS);
|
||||||
|
|
||||||
|
await db.insert(sessions).values({
|
||||||
|
userId,
|
||||||
|
token,
|
||||||
|
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: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await db.query.sessions.findFirst({
|
||||||
|
where: eq(sessions.token, token),
|
||||||
|
with: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session || session.expiresAt < new Date()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user: session.user };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user or throw if not authenticated
|
||||||
|
*/
|
||||||
|
export async function requireAuth(): Promise<typeof users.$inferSelect> {
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy the current session
|
||||||
|
*/
|
||||||
|
export async function destroySession(): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
await db.delete(sessions).where(eq(sessions.token, token));
|
||||||
|
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new user
|
||||||
|
*/
|
||||||
|
export async function registerUser(
|
||||||
|
handle: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
displayName?: string
|
||||||
|
): Promise<typeof users.$inferSelect> {
|
||||||
|
// Validate handle format
|
||||||
|
if (!/^[a-zA-Z0-9_]{3,20}$/.test(handle)) {
|
||||||
|
throw new Error('Handle must be 3-20 characters, alphanumeric and underscores only');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if handle is taken
|
||||||
|
const existingHandle = await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, handle.toLowerCase()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingHandle) {
|
||||||
|
throw new Error('Handle is already taken');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email is taken
|
||||||
|
const existingEmail = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, email.toLowerCase()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingEmail) {
|
||||||
|
throw new Error('Email is already registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate keys for ActivityPub
|
||||||
|
const { publicKey, privateKey } = await generateKeyPair();
|
||||||
|
|
||||||
|
// Create the user
|
||||||
|
const did = generateDID();
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
|
||||||
|
const [user] = await db.insert(users).values({
|
||||||
|
did,
|
||||||
|
handle: handle.toLowerCase(),
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
passwordHash,
|
||||||
|
displayName: displayName || handle,
|
||||||
|
publicKey,
|
||||||
|
privateKeyEncrypted: privateKey, // TODO: Encrypt with user's password
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
await upsertHandleEntries([{
|
||||||
|
handle: user.handle,
|
||||||
|
did: user.did,
|
||||||
|
nodeDomain,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}]);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate a user with email and password
|
||||||
|
*/
|
||||||
|
export async function authenticateUser(
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
): Promise<typeof users.$inferSelect> {
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, email.toLowerCase()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.passwordHash) {
|
||||||
|
throw new Error('Invalid email or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await verifyPassword(password, user.passwordHash);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error('Invalid email or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { db, handleRegistry } from '@/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export type HandleEntry = {
|
||||||
|
handle: string;
|
||||||
|
did: string;
|
||||||
|
nodeDomain: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeHandle = (handle: string) =>
|
||||||
|
handle.toLowerCase().replace(/^@/, '').trim();
|
||||||
|
|
||||||
|
export async function upsertHandleEntries(entries: HandleEntry[]) {
|
||||||
|
if (!db) {
|
||||||
|
return { added: 0, updated: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let added = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const cleanHandle = normalizeHandle(entry.handle);
|
||||||
|
if (!cleanHandle || !entry.did || !entry.nodeDomain) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.query.handleRegistry.findFirst({
|
||||||
|
where: eq(handleRegistry.handle, cleanHandle),
|
||||||
|
});
|
||||||
|
|
||||||
|
const incomingUpdatedAt = entry.updatedAt ? new Date(entry.updatedAt) : new Date();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await db.insert(handleRegistry).values({
|
||||||
|
handle: cleanHandle,
|
||||||
|
did: entry.did,
|
||||||
|
nodeDomain: entry.nodeDomain,
|
||||||
|
updatedAt: incomingUpdatedAt,
|
||||||
|
});
|
||||||
|
added += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing.updatedAt || incomingUpdatedAt > existing.updatedAt) {
|
||||||
|
await db.update(handleRegistry)
|
||||||
|
.set({
|
||||||
|
did: entry.did,
|
||||||
|
nodeDomain: entry.nodeDomain,
|
||||||
|
updatedAt: incomingUpdatedAt,
|
||||||
|
})
|
||||||
|
.where(eq(handleRegistry.handle, cleanHandle));
|
||||||
|
updated += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { added, updated };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user