feat: Implement core API endpoints, database schema, ActivityPub federation, and initial user interface for the application.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { isAdminUser } from '@/lib/auth/admin';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ isAdmin: false, user: null });
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ isAdmin: false, user: null });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
isAdmin: isAdminUser(session.user),
|
||||
user: {
|
||||
id: session.user.id,
|
||||
handle: session.user.handle,
|
||||
displayName: session.user.displayName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Admin status error:', error);
|
||||
return NextResponse.json({ isAdmin: false, user: null });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts } from '@/db';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
const moderationSchema = z.object({
|
||||
action: z.enum(['remove', 'restore']),
|
||||
reason: z.string().max(240).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const body = await request.json();
|
||||
const data = moderationSchema.parse(body);
|
||||
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, id),
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (data.action === 'remove') {
|
||||
const [updated] = await db.update(posts)
|
||||
.set({
|
||||
isRemoved: true,
|
||||
removedAt: new Date(),
|
||||
removedBy: admin.id,
|
||||
removedReason: data.reason || null,
|
||||
})
|
||||
.where(eq(posts.id, id))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ post: updated });
|
||||
}
|
||||
|
||||
const [restored] = await db.update(posts)
|
||||
.set({
|
||||
isRemoved: false,
|
||||
removedAt: null,
|
||||
removedBy: null,
|
||||
removedReason: null,
|
||||
})
|
||||
.where(eq(posts.id, id))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ post: restored });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
console.error('Post moderation error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update post' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts } from '@/db';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status') || 'active'; // active | removed | all
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
|
||||
const where =
|
||||
status === 'active'
|
||||
? eq(posts.isRemoved, false)
|
||||
: status === 'removed'
|
||||
? eq(posts.isRemoved, true)
|
||||
: undefined;
|
||||
|
||||
const results = await db.query.posts.findMany({
|
||||
where,
|
||||
with: {
|
||||
author: true,
|
||||
},
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
|
||||
const sanitized = results.map((post) => ({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt,
|
||||
isRemoved: post.isRemoved,
|
||||
removedReason: post.removedReason,
|
||||
author: {
|
||||
id: post.author.id,
|
||||
handle: post.author.handle,
|
||||
displayName: post.author.displayName,
|
||||
},
|
||||
}));
|
||||
|
||||
return NextResponse.json({ posts: sanitized });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
console.error('Admin posts error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch posts' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, reports } from '@/db';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
const updateSchema = z.object({
|
||||
status: z.enum(['open', 'resolved']),
|
||||
note: z.string().max(240).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const body = await request.json();
|
||||
const data = updateSchema.parse(body);
|
||||
|
||||
const report = await db.query.reports.findFirst({
|
||||
where: eq(reports.id, id),
|
||||
});
|
||||
|
||||
if (!report) {
|
||||
return NextResponse.json({ error: 'Report not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const [updated] = await db.update(reports)
|
||||
.set({
|
||||
status: data.status,
|
||||
resolvedAt: data.status === 'resolved' ? new Date() : null,
|
||||
resolvedBy: data.status === 'resolved' ? admin.id : null,
|
||||
resolutionNote: data.note || null,
|
||||
})
|
||||
.where(eq(reports.id, id))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ report: updated });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
console.error('Report update error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update report' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, reports, posts, users } from '@/db';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { desc, inArray, eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status') || 'open'; // open | resolved | all
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
|
||||
const reportRows = await db.query.reports.findMany({
|
||||
where: status === 'all' ? undefined : eq(reports.status, status),
|
||||
orderBy: [desc(reports.createdAt)],
|
||||
limit,
|
||||
with: {
|
||||
reporter: true,
|
||||
resolver: true,
|
||||
},
|
||||
});
|
||||
|
||||
const postIds = reportRows
|
||||
.filter((report) => report.targetType === 'post')
|
||||
.map((report) => report.targetId);
|
||||
const userIds = reportRows
|
||||
.filter((report) => report.targetType === 'user')
|
||||
.map((report) => report.targetId);
|
||||
|
||||
const postTargetsRaw = postIds.length
|
||||
? await db.query.posts.findMany({
|
||||
where: inArray(posts.id, postIds),
|
||||
with: { author: true },
|
||||
})
|
||||
: [];
|
||||
const userTargetsRaw = userIds.length
|
||||
? await db.query.users.findMany({
|
||||
where: inArray(users.id, userIds),
|
||||
})
|
||||
: [];
|
||||
|
||||
const postTargets = postTargetsRaw.map((post) => ({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt,
|
||||
isRemoved: post.isRemoved,
|
||||
author: {
|
||||
id: post.author.id,
|
||||
handle: post.author.handle,
|
||||
displayName: post.author.displayName,
|
||||
},
|
||||
}));
|
||||
|
||||
const userTargets = userTargetsRaw.map((user) => ({
|
||||
id: user.id,
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
isSuspended: user.isSuspended,
|
||||
isSilenced: user.isSilenced,
|
||||
}));
|
||||
|
||||
const postMap = new Map(postTargets.map((post) => [post.id, post]));
|
||||
const userMap = new Map(userTargets.map((user) => [user.id, user]));
|
||||
|
||||
const reportsWithTargets = reportRows.map((report) => ({
|
||||
id: report.id,
|
||||
targetType: report.targetType,
|
||||
targetId: report.targetId,
|
||||
reason: report.reason,
|
||||
status: report.status,
|
||||
createdAt: report.createdAt,
|
||||
reporter: report.reporter
|
||||
? { id: report.reporter.id, handle: report.reporter.handle }
|
||||
: null,
|
||||
resolver: report.resolver
|
||||
? { id: report.resolver.id, handle: report.resolver.handle }
|
||||
: null,
|
||||
target:
|
||||
report.targetType === 'post'
|
||||
? postMap.get(report.targetId) || null
|
||||
: userMap.get(report.targetId) || null,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ reports: reportsWithTargets });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
console.error('Admin reports error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch reports' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
const moderationSchema = z.object({
|
||||
action: z.enum(['suspend', 'unsuspend', 'silence', 'unsilence']),
|
||||
reason: z.string().max(240).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const body = await request.json();
|
||||
const data = moderationSchema.parse(body);
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, id),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
if (user.id === admin.id && (data.action === 'suspend' || data.action === 'silence')) {
|
||||
return NextResponse.json({ error: 'Cannot apply this action to yourself' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (data.action === 'suspend') {
|
||||
const [updated] = await db.update(users)
|
||||
.set({
|
||||
isSuspended: true,
|
||||
suspendedAt: new Date(),
|
||||
suspensionReason: data.reason || null,
|
||||
})
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
return NextResponse.json({ user: {
|
||||
id: updated.id,
|
||||
handle: updated.handle,
|
||||
displayName: updated.displayName,
|
||||
email: updated.email,
|
||||
isSuspended: updated.isSuspended,
|
||||
suspensionReason: updated.suspensionReason,
|
||||
isSilenced: updated.isSilenced,
|
||||
silenceReason: updated.silenceReason,
|
||||
createdAt: updated.createdAt,
|
||||
} });
|
||||
}
|
||||
|
||||
if (data.action === 'unsuspend') {
|
||||
const [updated] = await db.update(users)
|
||||
.set({
|
||||
isSuspended: false,
|
||||
suspendedAt: null,
|
||||
suspensionReason: null,
|
||||
})
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
return NextResponse.json({ user: {
|
||||
id: updated.id,
|
||||
handle: updated.handle,
|
||||
displayName: updated.displayName,
|
||||
email: updated.email,
|
||||
isSuspended: updated.isSuspended,
|
||||
suspensionReason: updated.suspensionReason,
|
||||
isSilenced: updated.isSilenced,
|
||||
silenceReason: updated.silenceReason,
|
||||
createdAt: updated.createdAt,
|
||||
} });
|
||||
}
|
||||
|
||||
if (data.action === 'silence') {
|
||||
const [updated] = await db.update(users)
|
||||
.set({
|
||||
isSilenced: true,
|
||||
silencedAt: new Date(),
|
||||
silenceReason: data.reason || null,
|
||||
})
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
return NextResponse.json({ user: {
|
||||
id: updated.id,
|
||||
handle: updated.handle,
|
||||
displayName: updated.displayName,
|
||||
email: updated.email,
|
||||
isSuspended: updated.isSuspended,
|
||||
suspensionReason: updated.suspensionReason,
|
||||
isSilenced: updated.isSilenced,
|
||||
silenceReason: updated.silenceReason,
|
||||
createdAt: updated.createdAt,
|
||||
} });
|
||||
}
|
||||
|
||||
const [updated] = await db.update(users)
|
||||
.set({
|
||||
isSilenced: false,
|
||||
silencedAt: null,
|
||||
silenceReason: null,
|
||||
})
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ user: {
|
||||
id: updated.id,
|
||||
handle: updated.handle,
|
||||
displayName: updated.displayName,
|
||||
email: updated.email,
|
||||
isSuspended: updated.isSuspended,
|
||||
suspensionReason: updated.suspensionReason,
|
||||
isSilenced: updated.isSilenced,
|
||||
silenceReason: updated.silenceReason,
|
||||
createdAt: updated.createdAt,
|
||||
} });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
console.error('Admin user moderation error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update user' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { desc } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
|
||||
const results = await db.select({
|
||||
id: users.id,
|
||||
handle: users.handle,
|
||||
displayName: users.displayName,
|
||||
email: users.email,
|
||||
isSuspended: users.isSuspended,
|
||||
suspensionReason: users.suspensionReason,
|
||||
isSilenced: users.isSilenced,
|
||||
silenceReason: users.silenceReason,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
.from(users)
|
||||
.orderBy(desc(users.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
return NextResponse.json({ users: results });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
console.error('Admin users error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch users' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { authenticateUser, createSession } from '@/lib/auth';
|
||||
import { z } from 'zod';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = loginSchema.parse(body);
|
||||
|
||||
const user = await authenticateUser(data.email, data.password);
|
||||
|
||||
// Create session
|
||||
await createSession(user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Login failed' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { destroySession } from '@/lib/auth';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
await destroySession();
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Logout failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSession, requireAuth } from '@/lib/auth';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
displayName: z.string().min(1).max(50).optional(),
|
||||
bio: z.string().max(160).optional().nullable(),
|
||||
avatarUrl: z.string().url().optional().nullable(),
|
||||
headerUrl: z.string().url().optional().nullable(),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Return null user if no database is connected (for UI testing)
|
||||
if (!db) {
|
||||
return NextResponse.json({ user: null });
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ user: null });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: session.user.id,
|
||||
handle: session.user.handle,
|
||||
displayName: session.user.displayName,
|
||||
avatarUrl: session.user.avatarUrl,
|
||||
bio: session.user.bio,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Session check error:', error);
|
||||
return NextResponse.json({ user: null });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const currentUser = await requireAuth();
|
||||
const body = await request.json();
|
||||
const data = updateProfileSchema.parse(body);
|
||||
|
||||
const updateData: {
|
||||
displayName?: string;
|
||||
bio?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
headerUrl?: string | null;
|
||||
updatedAt?: Date;
|
||||
} = {};
|
||||
|
||||
if (data.displayName !== undefined) updateData.displayName = data.displayName;
|
||||
if (data.bio !== undefined) updateData.bio = data.bio === '' ? null : data.bio;
|
||||
if (data.avatarUrl !== undefined) updateData.avatarUrl = data.avatarUrl === '' ? null : data.avatarUrl;
|
||||
if (data.headerUrl !== undefined) updateData.headerUrl = data.headerUrl === '' ? null : data.headerUrl;
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: currentUser.id,
|
||||
handle: currentUser.handle,
|
||||
displayName: currentUser.displayName,
|
||||
avatarUrl: currentUser.avatarUrl,
|
||||
bio: currentUser.bio,
|
||||
headerUrl: currentUser.headerUrl,
|
||||
followersCount: currentUser.followersCount,
|
||||
followingCount: currentUser.followingCount,
|
||||
postsCount: currentUser.postsCount,
|
||||
createdAt: currentUser.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateData.updatedAt = new Date();
|
||||
|
||||
const [updatedUser] = await db.update(users)
|
||||
.set(updateData)
|
||||
.where(eq(users.id, currentUser.id))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
handle: updatedUser.handle,
|
||||
displayName: updatedUser.displayName,
|
||||
avatarUrl: updatedUser.avatarUrl,
|
||||
bio: updatedUser.bio,
|
||||
headerUrl: updatedUser.headerUrl,
|
||||
followersCount: updatedUser.followersCount,
|
||||
followingCount: updatedUser.followingCount,
|
||||
postsCount: updatedUser.postsCount,
|
||||
createdAt: updatedUser.createdAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Profile update error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { registerUser, createSession } from '@/lib/auth';
|
||||
import { z } from 'zod';
|
||||
|
||||
const registerSchema = z.object({
|
||||
handle: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
displayName: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = registerSchema.parse(body);
|
||||
|
||||
const user = await registerUser(
|
||||
data.handle,
|
||||
data.email,
|
||||
data.password,
|
||||
data.displayName
|
||||
);
|
||||
|
||||
// Create session for new user
|
||||
await createSession(user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Registration failed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||
|
||||
const gossipSchema = z.object({
|
||||
nodes: z.array(z.string().min(1)).min(1),
|
||||
since: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const body = await request.json();
|
||||
const data = gossipSchema.parse(body);
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const node of data.nodes) {
|
||||
const baseUrl = node.startsWith('http') ? node : `https://${node}`;
|
||||
const url = new URL('/.well-known/synapsis-handles', baseUrl);
|
||||
if (data.since) {
|
||||
url.searchParams.set('since', data.since);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url.toString(), { method: 'GET' });
|
||||
if (!res.ok) {
|
||||
results.push({ node, success: false, error: `HTTP ${res.status}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = await res.json();
|
||||
const handles = Array.isArray(payload?.handles) ? payload.handles : [];
|
||||
const merged = await upsertHandleEntries(handles);
|
||||
|
||||
results.push({
|
||||
node,
|
||||
success: true,
|
||||
added: merged.added,
|
||||
updated: merged.updated,
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({ node, success: false, error: 'Fetch failed' });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ results });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
console.error('Gossip error:', error);
|
||||
return NextResponse.json({ error: 'Failed to gossip handles' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||
|
||||
const payloadSchema = z.object({
|
||||
handles: z.array(z.object({
|
||||
handle: z.string().min(1),
|
||||
did: z.string().min(1),
|
||||
nodeDomain: z.string().min(1),
|
||||
updatedAt: z.string().optional(),
|
||||
})).min(1),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = payloadSchema.parse(body);
|
||||
|
||||
const result = await upsertHandleEntries(data.handles);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
added: result.added,
|
||||
updated: result.updated,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('Handle ingest error:', error);
|
||||
return NextResponse.json({ error: 'Failed to ingest handles' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, handleRegistry } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { normalizeHandle, upsertHandleEntries } from '@/lib/federation/handles';
|
||||
|
||||
const parseHandleWithDomain = (handle: string) => {
|
||||
const clean = normalizeHandle(handle);
|
||||
const parts = clean.split('@').filter(Boolean);
|
||||
if (parts.length === 2) {
|
||||
return { handle: parts[0], domain: parts[1] };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const handleParam = searchParams.get('handle');
|
||||
|
||||
if (!handleParam) {
|
||||
return NextResponse.json({ error: 'Handle is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = parseHandleWithDomain(handleParam);
|
||||
const lookupHandle = parsed ? parsed.handle : normalizeHandle(handleParam);
|
||||
const localEntry = await db.query.handleRegistry.findFirst({
|
||||
where: eq(handleRegistry.handle, lookupHandle),
|
||||
});
|
||||
|
||||
if (localEntry) {
|
||||
return NextResponse.json({
|
||||
handle: localEntry.handle,
|
||||
did: localEntry.did,
|
||||
nodeDomain: localEntry.nodeDomain,
|
||||
updatedAt: localEntry.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
return NextResponse.json({ error: 'Handle not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const url = new URL('/.well-known/synapsis-handles', `https://${parsed.domain}`);
|
||||
url.searchParams.set('handle', parsed.handle);
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: 'Handle not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const entry = Array.isArray(data?.handles) ? data.handles[0] : null;
|
||||
|
||||
if (!entry) {
|
||||
return NextResponse.json({ error: 'Handle not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await upsertHandleEntries([entry]);
|
||||
|
||||
return NextResponse.json(entry);
|
||||
} catch (error) {
|
||||
console.error('Handle resolve error:', error);
|
||||
return NextResponse.json({ error: 'Failed to resolve handle' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { count, sql } from 'drizzle-orm';
|
||||
|
||||
const requiredEnv = [
|
||||
'DATABASE_URL',
|
||||
'AUTH_SECRET',
|
||||
'NEXT_PUBLIC_NODE_DOMAIN',
|
||||
'NEXT_PUBLIC_NODE_NAME',
|
||||
'ADMIN_EMAILS',
|
||||
];
|
||||
|
||||
const optionalEnv: string[] = [];
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const envStatus = {
|
||||
required: requiredEnv.reduce<Record<string, boolean>>((acc, key) => {
|
||||
acc[key] = Boolean(process.env[key]);
|
||||
return acc;
|
||||
}, {}),
|
||||
optional: optionalEnv.reduce<Record<string, boolean>>((acc, key) => {
|
||||
acc[key] = Boolean(process.env[key]);
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({
|
||||
env: envStatus,
|
||||
db: { connected: false, schemaReady: false, usersCount: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
let schemaReady = true;
|
||||
let usersCount = 0;
|
||||
|
||||
try {
|
||||
await db.execute(sql`select 1 from users limit 1`);
|
||||
const [result] = await db.select({ count: count() }).from(users);
|
||||
usersCount = Number(result?.count || 0);
|
||||
} catch {
|
||||
schemaReady = false;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
env: envStatus,
|
||||
db: { connected: true, schemaReady, usersCount },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Install status error:', error);
|
||||
return NextResponse.json({ error: 'Failed to check status' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, media } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const UPLOAD_DIR = join(process.cwd(), 'public', 'uploads');
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const altText = (formData.get('alt') as string | null) || null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json({
|
||||
error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({
|
||||
error: 'File too large. Maximum size: 10MB'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const ext = file.name.split('.').pop() || 'jpg';
|
||||
const filename = `${randomUUID()}.${ext}`;
|
||||
const filepath = join(UPLOAD_DIR, filename);
|
||||
|
||||
// Ensure upload directory exists
|
||||
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
// Write file
|
||||
const bytes = await file.arrayBuffer();
|
||||
await writeFile(filepath, Buffer.from(bytes));
|
||||
|
||||
const url = `/uploads/${filename}`;
|
||||
|
||||
// If database is available, store media record
|
||||
if (db) {
|
||||
const [mediaRecord] = await db.insert(media).values({
|
||||
userId: user.id,
|
||||
postId: null,
|
||||
url,
|
||||
altText,
|
||||
mimeType: file.type,
|
||||
width: 0, // TODO: Get actual dimensions
|
||||
height: 0,
|
||||
}).returning();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
media: mediaRecord,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, notifications } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { and, desc, eq, inArray, isNull } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const markSchema = z.object({
|
||||
ids: z.array(z.string().uuid()).optional(),
|
||||
all: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ notifications: [] });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '30'), 50);
|
||||
const unreadOnly = searchParams.get('unread') === 'true';
|
||||
|
||||
const conditions = [eq(notifications.userId, user.id)];
|
||||
if (unreadOnly) {
|
||||
conditions.push(isNull(notifications.readAt));
|
||||
}
|
||||
|
||||
const rows = await db.query.notifications.findMany({
|
||||
where: and(...conditions),
|
||||
with: {
|
||||
actor: true,
|
||||
post: true,
|
||||
},
|
||||
orderBy: [desc(notifications.createdAt)],
|
||||
limit,
|
||||
});
|
||||
|
||||
const payload = rows.map((row) => ({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
createdAt: row.createdAt,
|
||||
readAt: row.readAt,
|
||||
actor: row.actor ? {
|
||||
id: row.actor.id,
|
||||
handle: row.actor.handle,
|
||||
displayName: row.actor.displayName,
|
||||
avatarUrl: row.actor.avatarUrl,
|
||||
} : null,
|
||||
post: row.post ? {
|
||||
id: row.post.id,
|
||||
content: row.post.content,
|
||||
} : null,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ notifications: payload });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Notifications fetch error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch notifications' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const data = markSchema.parse(body);
|
||||
|
||||
if (!data.all && (!data.ids || data.ids.length === 0)) {
|
||||
return NextResponse.json({ error: 'No notifications specified' }, { status: 400 });
|
||||
}
|
||||
|
||||
const where = data.all
|
||||
? eq(notifications.userId, user.id)
|
||||
: and(
|
||||
eq(notifications.userId, user.id),
|
||||
inArray(notifications.id, data.ids || [])
|
||||
);
|
||||
|
||||
await db.update(notifications)
|
||||
.set({ readAt: new Date() })
|
||||
.where(where);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Notifications update error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update notifications' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, likes, users, notifications } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// Like a post
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: postId } = await context.params;
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if post exists
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, postId),
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
if (post.isRemoved) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if already liked
|
||||
const existingLike = await db.query.likes.findFirst({
|
||||
where: and(
|
||||
eq(likes.userId, user.id),
|
||||
eq(likes.postId, postId)
|
||||
),
|
||||
});
|
||||
|
||||
if (existingLike) {
|
||||
return NextResponse.json({ error: 'Already liked' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create like
|
||||
await db.insert(likes).values({
|
||||
userId: user.id,
|
||||
postId,
|
||||
});
|
||||
|
||||
// Update post's like count
|
||||
await db.update(posts)
|
||||
.set({ likesCount: post.likesCount + 1 })
|
||||
.where(eq(posts.id, postId));
|
||||
|
||||
if (post.userId !== user.id) {
|
||||
await db.insert(notifications).values({
|
||||
userId: post.userId,
|
||||
actorId: user.id,
|
||||
postId,
|
||||
type: 'like',
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Federate the like
|
||||
|
||||
return NextResponse.json({ success: true, liked: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to like post' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Unlike a post
|
||||
export async function DELETE(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: postId } = await context.params;
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if post exists
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, postId),
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
if (post.isRemoved) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Find the like
|
||||
const existingLike = await db.query.likes.findFirst({
|
||||
where: and(
|
||||
eq(likes.userId, user.id),
|
||||
eq(likes.postId, postId)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existingLike) {
|
||||
return NextResponse.json({ error: 'Not liked' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Remove like
|
||||
await db.delete(likes).where(eq(likes.id, existingLike.id));
|
||||
|
||||
// Update post's like count
|
||||
await db.update(posts)
|
||||
.set({ likesCount: Math.max(0, post.likesCount - 1) })
|
||||
.where(eq(posts.id, postId));
|
||||
|
||||
return NextResponse.json({ success: true, liked: false });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to unlike post' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, notifications } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// Repost a post
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: postId } = await context.params;
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if post exists
|
||||
const originalPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, postId),
|
||||
});
|
||||
|
||||
if (!originalPost) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
if (originalPost.isRemoved) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Create repost
|
||||
const [repost] = await db.insert(posts).values({
|
||||
userId: user.id,
|
||||
content: '', // Reposts don't have their own content
|
||||
repostOfId: postId,
|
||||
apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
|
||||
apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
|
||||
}).returning();
|
||||
|
||||
// Update original post's repost count
|
||||
await db.update(posts)
|
||||
.set({ repostsCount: originalPost.repostsCount + 1 })
|
||||
.where(eq(posts.id, postId));
|
||||
|
||||
// Update user's post count
|
||||
await db.update(users)
|
||||
.set({ postsCount: user.postsCount + 1 })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
if (originalPost.userId !== user.id) {
|
||||
await db.insert(notifications).values({
|
||||
userId: originalPost.userId,
|
||||
actorId: user.id,
|
||||
postId,
|
||||
type: 'repost',
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Federate the repost (Announce activity)
|
||||
|
||||
return NextResponse.json({ success: true, repost });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to repost' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, follows, mutes, blocks } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { eq, desc, and, inArray, isNull, notInArray, or } from 'drizzle-orm';
|
||||
import type { SQL } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const POST_MAX_LENGTH = 400;
|
||||
const CURATION_WINDOW_HOURS = 72;
|
||||
const CURATION_SEED_MULTIPLIER = 5;
|
||||
const CURATION_SEED_CAP = 200;
|
||||
|
||||
const buildWhere = (...conditions: Array<SQL | undefined>) => {
|
||||
const filtered = conditions.filter(Boolean) as SQL[];
|
||||
if (filtered.length === 0) return undefined;
|
||||
return and(...filtered);
|
||||
};
|
||||
|
||||
const createPostSchema = z.object({
|
||||
content: z.string().min(1).max(POST_MAX_LENGTH),
|
||||
replyToId: z.string().uuid().optional(),
|
||||
mediaIds: z.array(z.string().uuid()).max(4).optional(),
|
||||
});
|
||||
|
||||
// Create a new post
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const body = await request.json();
|
||||
const data = createPostSchema.parse(body);
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
const [post] = await db.insert(posts).values({
|
||||
userId: user.id,
|
||||
content: data.content,
|
||||
replyToId: data.replyToId,
|
||||
apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
|
||||
apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
|
||||
}).returning();
|
||||
|
||||
let attachedMedia: typeof media.$inferSelect[] = [];
|
||||
if (data.mediaIds?.length) {
|
||||
await db.update(media)
|
||||
.set({ postId: post.id })
|
||||
.where(and(
|
||||
inArray(media.id, data.mediaIds),
|
||||
eq(media.userId, user.id),
|
||||
isNull(media.postId),
|
||||
));
|
||||
|
||||
attachedMedia = await db.query.media.findMany({
|
||||
where: and(
|
||||
inArray(media.id, data.mediaIds),
|
||||
eq(media.userId, user.id),
|
||||
eq(media.postId, post.id),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Update user's post count
|
||||
await db.update(users)
|
||||
.set({ postsCount: user.postsCount + 1 })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
// If this is a reply, update the parent's reply count
|
||||
if (data.replyToId) {
|
||||
const parentPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.replyToId),
|
||||
});
|
||||
if (parentPost) {
|
||||
await db.update(posts)
|
||||
.set({ repliesCount: parentPost.repliesCount + 1 })
|
||||
.where(eq(posts.id, data.replyToId));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Federate the post to followers
|
||||
|
||||
return NextResponse.json({ success: true, post: { ...post, media: attachedMedia } });
|
||||
} catch (error) {
|
||||
console.error('Create post error:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create post' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get timeline / feed
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// Return empty posts if no database is connected (for UI testing)
|
||||
if (!db) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type') || 'home'; // home, public, user, curated
|
||||
const userId = searchParams.get('userId');
|
||||
const cursor = searchParams.get('cursor');
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||
|
||||
let feedPosts;
|
||||
const moderatedUsers = await db.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(or(eq(users.isSuspended, true), eq(users.isSilenced, true)));
|
||||
const moderatedIds = moderatedUsers.map((item) => item.id);
|
||||
const baseFilter = buildWhere(
|
||||
eq(posts.isRemoved, false),
|
||||
moderatedIds.length ? notInArray(posts.userId, moderatedIds) : undefined
|
||||
);
|
||||
|
||||
if (type === 'public') {
|
||||
// Public timeline - all posts
|
||||
feedPosts = await db.query.posts.findMany({
|
||||
where: baseFilter,
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true },
|
||||
},
|
||||
},
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
} else if (type === 'user' && userId) {
|
||||
// User's posts
|
||||
feedPosts = await db.query.posts.findMany({
|
||||
where: buildWhere(baseFilter, eq(posts.userId, userId)),
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true },
|
||||
},
|
||||
},
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
} else if (type === 'curated') {
|
||||
let viewer = null;
|
||||
try {
|
||||
viewer = await requireAuth();
|
||||
} catch {
|
||||
viewer = null;
|
||||
}
|
||||
|
||||
const seedLimit = Math.min(limit * CURATION_SEED_MULTIPLIER, CURATION_SEED_CAP);
|
||||
const seedPosts = await db.query.posts.findMany({
|
||||
where: baseFilter,
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true },
|
||||
},
|
||||
},
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit: seedLimit,
|
||||
});
|
||||
|
||||
let followingIds = new Set<string>();
|
||||
let mutedIds = new Set<string>();
|
||||
let blockedIds = new Set<string>();
|
||||
|
||||
if (viewer) {
|
||||
const followRows = await db.select({ followingId: follows.followingId })
|
||||
.from(follows)
|
||||
.where(eq(follows.followerId, viewer.id));
|
||||
followingIds = new Set(followRows.map(row => row.followingId));
|
||||
|
||||
const muteRows = await db.select({ mutedUserId: mutes.mutedUserId })
|
||||
.from(mutes)
|
||||
.where(eq(mutes.userId, viewer.id));
|
||||
mutedIds = new Set(muteRows.map(row => row.mutedUserId));
|
||||
|
||||
const blockRows = await db.select({ blockedUserId: blocks.blockedUserId })
|
||||
.from(blocks)
|
||||
.where(eq(blocks.userId, viewer.id));
|
||||
blockedIds = new Set(blockRows.map(row => row.blockedUserId));
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const rankedPosts = seedPosts
|
||||
.filter(post => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id))
|
||||
.map(post => {
|
||||
const createdAt = new Date(post.createdAt).getTime();
|
||||
const ageHours = Math.max(0, (now - createdAt) / 3600000);
|
||||
const engagement = post.likesCount + post.repostsCount * 2 + post.repliesCount * 0.5;
|
||||
const engagementScore = Math.log1p(Math.max(0, engagement));
|
||||
const recencyScore = Math.max(0, 1 - ageHours / CURATION_WINDOW_HOURS);
|
||||
|
||||
const followBoost = viewer && followingIds.has(post.author.id) ? 0.9 : 0;
|
||||
const selfBoost = viewer && post.author.id === viewer.id ? 0.5 : 0;
|
||||
|
||||
const score = engagementScore * 1.4 + recencyScore * 1.1 + followBoost + selfBoost;
|
||||
|
||||
const reasons: string[] = [];
|
||||
if (followBoost > 0) {
|
||||
reasons.push(`You follow @${post.author.handle}`);
|
||||
}
|
||||
if (engagement >= 5) {
|
||||
reasons.push(`Popular: ${post.likesCount} likes, ${post.repostsCount} reposts`);
|
||||
} else if (engagement > 0) {
|
||||
reasons.push(`Active conversation: ${post.repliesCount} replies`);
|
||||
}
|
||||
if (ageHours <= 6) {
|
||||
reasons.push('Posted recently');
|
||||
} else if (ageHours <= 24) {
|
||||
reasons.push('Posted today');
|
||||
} else if (ageHours <= CURATION_WINDOW_HOURS) {
|
||||
reasons.push('Recent');
|
||||
}
|
||||
if (reasons.length === 0) {
|
||||
reasons.push('New post');
|
||||
}
|
||||
|
||||
return {
|
||||
...post,
|
||||
feedMeta: {
|
||||
score: Number(score.toFixed(3)),
|
||||
reasons,
|
||||
engagement: {
|
||||
likes: post.likesCount,
|
||||
reposts: post.repostsCount,
|
||||
replies: post.repliesCount,
|
||||
},
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (b.feedMeta.score !== a.feedMeta.score) {
|
||||
return b.feedMeta.score - a.feedMeta.score;
|
||||
}
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return NextResponse.json({
|
||||
posts: rankedPosts,
|
||||
meta: {
|
||||
algorithm: 'curated-v1',
|
||||
windowHours: CURATION_WINDOW_HOURS,
|
||||
seedLimit,
|
||||
weights: {
|
||||
engagement: 1.4,
|
||||
recency: 1.1,
|
||||
followBoost: 0.9,
|
||||
selfBoost: 0.5,
|
||||
},
|
||||
},
|
||||
nextCursor: rankedPosts.length === limit ? rankedPosts[rankedPosts.length - 1]?.id : null,
|
||||
});
|
||||
} else {
|
||||
// Home timeline - need auth
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
|
||||
// Get posts from people the user follows + their own posts
|
||||
// For now, just return all posts (we'll add following filter later)
|
||||
feedPosts = await db.query.posts.findMany({
|
||||
where: baseFilter,
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true },
|
||||
},
|
||||
},
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
} catch {
|
||||
// Not authenticated, return public timeline
|
||||
feedPosts = await db.query.posts.findMany({
|
||||
where: baseFilter,
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true },
|
||||
},
|
||||
},
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
posts: feedPosts,
|
||||
nextCursor: feedPosts.length === limit ? feedPosts[feedPosts.length - 1]?.id : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get feed error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get feed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, reports, posts, users } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const reportSchema = z.object({
|
||||
targetType: z.enum(['post', 'user']),
|
||||
targetId: z.string().uuid(),
|
||||
reason: z.string().min(3).max(500),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const reporter = await requireAuth();
|
||||
|
||||
if (reporter.isSuspended || reporter.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const data = reportSchema.parse(body);
|
||||
|
||||
if (data.targetType === 'post') {
|
||||
const targetPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.targetId),
|
||||
});
|
||||
if (!targetPost || targetPost.isRemoved) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
if (data.targetType === 'user') {
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.id, data.targetId),
|
||||
});
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
const [report] = await db.insert(reports).values({
|
||||
reporterId: reporter.id,
|
||||
targetType: data.targetType,
|
||||
targetId: data.targetId,
|
||||
reason: data.reason,
|
||||
status: 'open',
|
||||
}).returning();
|
||||
|
||||
return NextResponse.json({ report });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Report error:', error);
|
||||
return NextResponse.json({ error: 'Failed to submit report' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users, posts } from '@/db';
|
||||
import { ilike, or, desc, and, notInArray, eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q') || '';
|
||||
const type = searchParams.get('type') || 'all'; // all, users, posts
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||
|
||||
if (!query.trim()) {
|
||||
return NextResponse.json({ users: [], posts: [] });
|
||||
}
|
||||
|
||||
// Return empty if no database
|
||||
if (!db) {
|
||||
return NextResponse.json({
|
||||
users: [],
|
||||
posts: [],
|
||||
message: 'Search requires database connection'
|
||||
});
|
||||
}
|
||||
|
||||
const searchPattern = `%${query}%`;
|
||||
let searchUsers: { id: string; handle: string; displayName: string | null; avatarUrl: string | null; bio: string | null }[] = [];
|
||||
let searchPosts: typeof posts.$inferSelect[] = [];
|
||||
|
||||
// Search users
|
||||
if (type === 'all' || type === 'users') {
|
||||
const userConditions = and(
|
||||
or(
|
||||
ilike(users.handle, searchPattern),
|
||||
ilike(users.displayName, searchPattern),
|
||||
ilike(users.bio, searchPattern)
|
||||
),
|
||||
eq(users.isSuspended, false),
|
||||
eq(users.isSilenced, false)
|
||||
);
|
||||
searchUsers = await db.select({
|
||||
id: users.id,
|
||||
handle: users.handle,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
bio: users.bio,
|
||||
})
|
||||
.from(users)
|
||||
.where(userConditions)
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
const moderatedUsers = await db.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(or(eq(users.isSuspended, true), eq(users.isSilenced, true)));
|
||||
const moderatedIds = moderatedUsers.map((item) => item.id);
|
||||
|
||||
// Search posts
|
||||
if (type === 'all' || type === 'posts') {
|
||||
const postConditions = [
|
||||
ilike(posts.content, searchPattern),
|
||||
eq(posts.isRemoved, false),
|
||||
];
|
||||
if (moderatedIds.length) {
|
||||
postConditions.push(notInArray(posts.userId, moderatedIds));
|
||||
}
|
||||
const postResults = await db.query.posts.findMany({
|
||||
where: and(...postConditions),
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
},
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
searchPosts = postResults;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
users: searchUsers,
|
||||
posts: searchPosts,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
return NextResponse.json({ error: 'Search failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, follows, users, notifications } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
// Check follow status
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
|
||||
if (currentUser.isSuspended || currentUser.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
if (targetUser.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (targetUser.id === currentUser.id) {
|
||||
return NextResponse.json({ following: false, self: true });
|
||||
}
|
||||
|
||||
const existingFollow = await db.query.follows.findFirst({
|
||||
where: and(
|
||||
eq(follows.followerId, currentUser.id),
|
||||
eq(follows.followingId, targetUser.id)
|
||||
),
|
||||
});
|
||||
|
||||
return NextResponse.json({ following: !!existingFollow });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Follow status error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get follow status' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Follow a user
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
|
||||
if (currentUser.isSuspended || currentUser.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
// Find target user
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
if (targetUser.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Can't follow yourself
|
||||
if (targetUser.id === currentUser.id) {
|
||||
return NextResponse.json({ error: 'Cannot follow yourself' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if already following
|
||||
const existingFollow = await db.query.follows.findFirst({
|
||||
where: and(
|
||||
eq(follows.followerId, currentUser.id),
|
||||
eq(follows.followingId, targetUser.id)
|
||||
),
|
||||
});
|
||||
|
||||
if (existingFollow) {
|
||||
return NextResponse.json({ error: 'Already following' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create follow
|
||||
await db.insert(follows).values({
|
||||
followerId: currentUser.id,
|
||||
followingId: targetUser.id,
|
||||
});
|
||||
|
||||
if (currentUser.id !== targetUser.id) {
|
||||
await db.insert(notifications).values({
|
||||
userId: targetUser.id,
|
||||
actorId: currentUser.id,
|
||||
type: 'follow',
|
||||
});
|
||||
}
|
||||
|
||||
// Update counts
|
||||
await db.update(users)
|
||||
.set({ followingCount: currentUser.followingCount + 1 })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
await db.update(users)
|
||||
.set({ followersCount: targetUser.followersCount + 1 })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
// TODO: Send ActivityPub Follow activity
|
||||
|
||||
return NextResponse.json({ success: true, following: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Follow error:', error);
|
||||
return NextResponse.json({ error: 'Failed to follow' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Unfollow a user
|
||||
export async function DELETE(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
// Find target user
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
if (targetUser.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Find existing follow
|
||||
const existingFollow = await db.query.follows.findFirst({
|
||||
where: and(
|
||||
eq(follows.followerId, currentUser.id),
|
||||
eq(follows.followingId, targetUser.id)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existingFollow) {
|
||||
return NextResponse.json({ error: 'Not following' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Remove follow
|
||||
await db.delete(follows).where(eq(follows.id, existingFollow.id));
|
||||
|
||||
// Update counts
|
||||
await db.update(users)
|
||||
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
await db.update(users)
|
||||
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
// TODO: Send ActivityPub Undo Follow activity
|
||||
|
||||
return NextResponse.json({ success: true, following: false });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Unfollow error:', error);
|
||||
return NextResponse.json({ error: 'Failed to unfollow' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, follows, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||
|
||||
// Return empty if no database
|
||||
if (!db) {
|
||||
return NextResponse.json({ followers: [], nextCursor: null });
|
||||
}
|
||||
|
||||
// Find the user
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
if (user.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get followers
|
||||
const userFollowers = await db.query.follows.findMany({
|
||||
where: eq(follows.followingId, user.id),
|
||||
with: {
|
||||
follower: true,
|
||||
},
|
||||
limit,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
followers: userFollowers.map(f => ({
|
||||
id: f.follower.id,
|
||||
handle: f.follower.handle,
|
||||
displayName: f.follower.displayName,
|
||||
avatarUrl: f.follower.avatarUrl,
|
||||
bio: f.follower.bio,
|
||||
})),
|
||||
nextCursor: userFollowers.length === limit ? userFollowers[userFollowers.length - 1]?.id : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get followers error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get followers' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, follows, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||
|
||||
// Return empty if no database
|
||||
if (!db) {
|
||||
return NextResponse.json({ following: [], nextCursor: null });
|
||||
}
|
||||
|
||||
// Find the user
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
if (user.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get following
|
||||
const userFollowing = await db.query.follows.findMany({
|
||||
where: eq(follows.followerId, user.id),
|
||||
with: {
|
||||
following: true,
|
||||
},
|
||||
limit,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
following: userFollowing.map(f => ({
|
||||
id: f.following.id,
|
||||
handle: f.following.handle,
|
||||
displayName: f.following.displayName,
|
||||
avatarUrl: f.following.avatarUrl,
|
||||
bio: f.following.bio,
|
||||
})),
|
||||
nextCursor: userFollowing.length === limit ? userFollowing[userFollowing.length - 1]?.id : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get following error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get following' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users } from '@/db';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||
|
||||
// Return empty if no database
|
||||
if (!db) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
// Find the user
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
if (user.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get user's posts
|
||||
const userPosts = await db.query.posts.findMany({
|
||||
where: and(eq(posts.userId, user.id), eq(posts.isRemoved, false)),
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true },
|
||||
},
|
||||
},
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
posts: userPosts,
|
||||
nextCursor: userPosts.length === limit ? userPosts[userPosts.length - 1]?.id : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get user posts error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get posts' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { userToActor } from '@/lib/activitypub/actor';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
|
||||
// Return mock user if no database
|
||||
if (!db) {
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: 'demo-user',
|
||||
handle: cleanHandle,
|
||||
displayName: cleanHandle,
|
||||
bio: 'This is a demo profile.',
|
||||
avatarUrl: null,
|
||||
headerUrl: null,
|
||||
followersCount: 0,
|
||||
followingCount: 0,
|
||||
postsCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
if (user.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if ActivityPub request
|
||||
const accept = request.headers.get('accept') || '';
|
||||
if (accept.includes('application/activity+json') || accept.includes('application/ld+json')) {
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const actor = userToActor(user, nodeDomain);
|
||||
return NextResponse.json(actor, {
|
||||
headers: {
|
||||
'Content-Type': 'application/activity+json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Return user profile (without sensitive data)
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
bio: user.bio,
|
||||
avatarUrl: user.avatarUrl,
|
||||
headerUrl: user.headerUrl,
|
||||
followersCount: user.followersCount,
|
||||
followingCount: user.followingCount,
|
||||
postsCount: user.postsCount,
|
||||
createdAt: user.createdAt,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get user error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get user' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user