feat: Implement core API endpoints, database schema, ActivityPub federation, and initial user interface for the application.

This commit is contained in:
Christopher
2026-01-22 02:44:46 -08:00
parent 537fc5e8c2
commit b76e586bbc
62 changed files with 9447 additions and 115 deletions
+201
View File
@@ -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.
+95 -23
View File
@@ -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
[![Deploy with Vercel](https://vercel.com/button)](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.
+10
View File
@@ -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!,
},
});
+1396 -4
View File
File diff suppressed because it is too large Load Diff
+21 -4
View File
@@ -2,25 +2,42 @@
"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",
"typescript": "^5" "typescript": "^5"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

+1
View File
@@ -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

+36
View File
@@ -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"
}
]
}
+16
View File
@@ -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 });
}
}
+56
View File
@@ -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': '*',
},
});
}
+3
View File
@@ -0,0 +1,3 @@
export default function Default() {
return null;
}
+638
View File
@@ -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>
);
}
+385
View File
@@ -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>
);
}
+29
View File
@@ -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 });
}
}
+69
View File
@@ -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 });
}
}
+55
View File
@@ -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 });
}
}
+55
View File
@@ -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 });
}
}
+97
View File
@@ -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 });
}
}
+133
View File
@@ -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 });
}
}
+40
View File
@@ -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 });
}
}
+43
View File
@@ -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 }
);
}
}
+16
View File
@@ -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 }
);
}
}
+113
View File
@@ -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 });
}
}
+50
View File
@@ -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 }
);
}
}
+60
View File
@@ -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 });
}
}
+33
View File
@@ -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 });
}
}
+69
View File
@@ -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 });
}
}
+54
View File
@@ -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 });
}
}
+82
View File
@@ -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 });
}
}
+103
View File
@@ -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 });
}
}
+122
View File
@@ -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 });
}
}
+68
View File
@@ -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 });
}
}
+319
View File
@@ -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 }
);
}
}
+65
View File
@@ -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 });
}
}
+86
View File
@@ -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 });
}
}
+191
View File
@@ -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 });
}
}
+53
View File
@@ -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 });
}
}
+73
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+271
View File
@@ -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
View File
@@ -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>
); );
} }
+250
View File
@@ -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>
);
}
+36
View File
@@ -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,
},
});
}
+167
View File
@@ -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
View File
@@ -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&apos;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>
); );
} }
+375
View File
@@ -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 &ldquo;{initialQuery}&rdquo;</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>
);
}
+17
View File
@@ -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';
+325
View File
@@ -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],
}),
}));
+202
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
.replace(/\n/g, '<br>');
}
+119
View File
@@ -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`;
}
+197
View File
@@ -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 };
}
+7
View File
@@ -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';
+105
View File
@@ -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 [];
}
+165
View File
@@ -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;
}
}
+113
View File
@@ -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;
}
+28
View File
@@ -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;
}
+199
View File
@@ -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;
}
+58
View File
@@ -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 };
}