Initial commit
This commit is contained in:
51
app/api/agent/heartbeat/route.ts
Normal file
51
app/api/agent/heartbeat/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { machines, sessionCodes } from '@/lib/db/schema'
|
||||
import { eq, and, isNotNull, gt } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { accessKey } = await request.json()
|
||||
|
||||
if (!accessKey) {
|
||||
return NextResponse.json({ error: 'Access key required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.update(machines)
|
||||
.set({ isOnline: true, lastSeen: new Date() })
|
||||
.where(eq(machines.accessKey, accessKey))
|
||||
.returning({ id: machines.id })
|
||||
|
||||
if (!result[0]) {
|
||||
return NextResponse.json({ error: 'Invalid access key' }, { status: 401 })
|
||||
}
|
||||
|
||||
const machineId = result[0].id
|
||||
|
||||
// Check for a pending connection (code used recently)
|
||||
const pending = await db
|
||||
.select()
|
||||
.from(sessionCodes)
|
||||
.where(
|
||||
and(
|
||||
eq(sessionCodes.machineId, machineId),
|
||||
eq(sessionCodes.isActive, true),
|
||||
gt(sessionCodes.expiresAt, new Date()),
|
||||
isNotNull(sessionCodes.usedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(sessionCodes.usedAt)
|
||||
.limit(1)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
pendingConnection: pending[0]
|
||||
? { sessionCodeId: pending[0].id, usedBy: pending[0].usedBy }
|
||||
: null,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Heartbeat] Error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
46
app/api/agent/register/route.ts
Normal file
46
app/api/agent/register/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { machines } from '@/lib/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { accessKey, name, hostname, os, osVersion, agentVersion, ipAddress } = body
|
||||
|
||||
if (!accessKey) {
|
||||
return NextResponse.json({ error: 'Access key required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(machines)
|
||||
.where(eq(machines.accessKey, accessKey))
|
||||
.limit(1)
|
||||
|
||||
const machine = result[0]
|
||||
if (!machine) {
|
||||
return NextResponse.json({ error: 'Invalid access key' }, { status: 401 })
|
||||
}
|
||||
|
||||
await db
|
||||
.update(machines)
|
||||
.set({
|
||||
name: name || machine.name,
|
||||
hostname: hostname || machine.hostname,
|
||||
os: os || machine.os,
|
||||
osVersion: osVersion || machine.osVersion,
|
||||
agentVersion: agentVersion || machine.agentVersion,
|
||||
ipAddress: ipAddress || machine.ipAddress,
|
||||
isOnline: true,
|
||||
lastSeen: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(machines.id, machine.id))
|
||||
|
||||
return NextResponse.json({ success: true, machineId: machine.id })
|
||||
} catch (error) {
|
||||
console.error('[Agent Register] Error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
69
app/api/agent/session-code/route.ts
Normal file
69
app/api/agent/session-code/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { machines, sessionCodes } from '@/lib/db/schema'
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
function generateSessionCode(): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
let code = ''
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { accessKey } = await request.json()
|
||||
|
||||
if (!accessKey) {
|
||||
return NextResponse.json({ error: 'Access key required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const machineResult = await db
|
||||
.select()
|
||||
.from(machines)
|
||||
.where(eq(machines.accessKey, accessKey))
|
||||
.limit(1)
|
||||
|
||||
const machine = machineResult[0]
|
||||
if (!machine) {
|
||||
return NextResponse.json({ error: 'Invalid access key' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Generate a unique code
|
||||
let code = ''
|
||||
for (let attempts = 0; attempts < 10; attempts++) {
|
||||
const candidate = generateSessionCode()
|
||||
const existing = await db
|
||||
.select({ id: sessionCodes.id })
|
||||
.from(sessionCodes)
|
||||
.where(and(eq(sessionCodes.code, candidate), eq(sessionCodes.isActive, true)))
|
||||
.limit(1)
|
||||
|
||||
if (!existing[0]) {
|
||||
code = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json({ error: 'Failed to generate unique code' }, { status: 500 })
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000)
|
||||
|
||||
await db.insert(sessionCodes).values({
|
||||
code,
|
||||
machineId: machine.id,
|
||||
createdBy: machine.userId,
|
||||
expiresAt,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, code, expiresAt: expiresAt.toISOString(), expiresIn: 600 })
|
||||
} catch (error) {
|
||||
console.error('[Session Code] Error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
3
app/api/auth/[...nextauth]/route.ts
Normal file
3
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from '@/auth'
|
||||
|
||||
export const { GET, POST } = handlers
|
||||
67
app/api/connect/route.ts
Normal file
67
app/api/connect/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { auth } from '@/auth'
|
||||
import { db } from '@/lib/db'
|
||||
import { sessionCodes, sessions, machines } from '@/lib/db/schema'
|
||||
import { eq, and, isNull, gt } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { code } = await request.json()
|
||||
if (!code || typeof code !== 'string') {
|
||||
return NextResponse.json({ error: 'Code required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const normalizedCode = code.replace(/\s/g, '').toUpperCase()
|
||||
|
||||
// Find valid, unused session code
|
||||
const codeResult = await db
|
||||
.select()
|
||||
.from(sessionCodes)
|
||||
.where(
|
||||
and(
|
||||
eq(sessionCodes.code, normalizedCode),
|
||||
eq(sessionCodes.isActive, true),
|
||||
gt(sessionCodes.expiresAt, new Date()),
|
||||
isNull(sessionCodes.usedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const sessionCode = codeResult[0]
|
||||
if (!sessionCode) {
|
||||
return NextResponse.json({ error: 'Invalid or expired session code' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Look up machine name
|
||||
const machineResult = await db
|
||||
.select({ name: machines.name })
|
||||
.from(machines)
|
||||
.where(eq(machines.id, sessionCode.machineId))
|
||||
.limit(1)
|
||||
|
||||
const machineName = machineResult[0]?.name ?? 'Remote Machine'
|
||||
|
||||
// Mark code as used
|
||||
await db
|
||||
.update(sessionCodes)
|
||||
.set({ usedAt: new Date(), usedBy: session.user.id, isActive: false })
|
||||
.where(eq(sessionCodes.id, sessionCode.id))
|
||||
|
||||
// Create session record
|
||||
const newSession = await db
|
||||
.insert(sessions)
|
||||
.values({
|
||||
machineId: sessionCode.machineId,
|
||||
machineName,
|
||||
viewerUserId: session.user.id,
|
||||
connectionType: 'session_code',
|
||||
sessionCode: normalizedCode,
|
||||
})
|
||||
.returning({ id: sessions.id })
|
||||
|
||||
return NextResponse.json({ sessionId: newSession[0].id })
|
||||
}
|
||||
72
app/api/invites/accept/route.ts
Normal file
72
app/api/invites/accept/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { invites, users } from '@/lib/db/schema'
|
||||
import { eq, and, isNull, gt } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { token, fullName, password } = await request.json()
|
||||
|
||||
if (!token || !fullName || !password) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password must be at least 8 characters' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate invite
|
||||
const inviteResult = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.where(eq(invites.token, token))
|
||||
.limit(1)
|
||||
|
||||
const invite = inviteResult[0]
|
||||
|
||||
if (!invite) {
|
||||
return NextResponse.json({ error: 'Invalid invite link' }, { status: 400 })
|
||||
}
|
||||
if (invite.usedAt) {
|
||||
return NextResponse.json({ error: 'This invite has already been used' }, { status: 400 })
|
||||
}
|
||||
if (new Date(invite.expiresAt) < new Date()) {
|
||||
return NextResponse.json({ error: 'This invite has expired' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if user with this email already exists
|
||||
const existingUser = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.email, invite.email))
|
||||
.limit(1)
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'An account with this email already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12)
|
||||
|
||||
const newUser = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: invite.email,
|
||||
passwordHash,
|
||||
fullName: fullName.trim(),
|
||||
})
|
||||
.returning({ id: users.id })
|
||||
|
||||
// Mark invite as used
|
||||
await db
|
||||
.update(invites)
|
||||
.set({ usedAt: new Date(), usedBy: newUser[0].id })
|
||||
.where(eq(invites.token, token))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
76
app/api/invites/route.ts
Normal file
76
app/api/invites/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { auth } from '@/auth'
|
||||
import { db } from '@/lib/db'
|
||||
import { invites, users } from '@/lib/db/schema'
|
||||
import { eq, and, isNull, gt } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return null
|
||||
const role = (session.user as { role: string }).role
|
||||
if (role !== 'admin') return null
|
||||
return session.user
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const admin = await requireAdmin()
|
||||
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { email } = await request.json()
|
||||
if (!email || typeof email !== 'string') {
|
||||
return NextResponse.json({ error: 'Valid email required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const normalizedEmail = email.toLowerCase().trim()
|
||||
|
||||
// Check for existing pending invite
|
||||
const existing = await db
|
||||
.select({ id: invites.id })
|
||||
.from(invites)
|
||||
.where(
|
||||
and(
|
||||
eq(invites.email, normalizedEmail),
|
||||
isNull(invites.usedAt),
|
||||
gt(invites.expiresAt, new Date())
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A pending invite already exists for this email' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.insert(invites)
|
||||
.values({ email: normalizedEmail, createdBy: admin.id })
|
||||
.returning()
|
||||
|
||||
return NextResponse.json({ invite: result[0] }, { status: 201 })
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const admin = await requireAdmin()
|
||||
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.orderBy(invites.createdAt)
|
||||
|
||||
return NextResponse.json({ invites: result.reverse() })
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const admin = await requireAdmin()
|
||||
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { id } = await request.json()
|
||||
if (!id) return NextResponse.json({ error: 'Invite ID required' }, { status: 400 })
|
||||
|
||||
await db.delete(invites).where(eq(invites.id, id))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
29
app/api/machines/[id]/route.ts
Normal file
29
app/api/machines/[id]/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { auth } from '@/auth'
|
||||
import { db } from '@/lib/db'
|
||||
import { machines } from '@/lib/db/schema'
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Only delete if the machine belongs to the requesting user
|
||||
const result = await db
|
||||
.delete(machines)
|
||||
.where(and(eq(machines.id, id), eq(machines.userId, session.user.id)))
|
||||
.returning({ id: machines.id })
|
||||
|
||||
if (!result[0]) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
32
app/api/profile/route.ts
Normal file
32
app/api/profile/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { auth } from '@/auth'
|
||||
import { db } from '@/lib/db'
|
||||
import { users } from '@/lib/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const result = await db
|
||||
.select({ fullName: users.fullName, company: users.company, email: users.email, role: users.role })
|
||||
.from(users)
|
||||
.where(eq(users.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
return NextResponse.json({ user: result[0] ?? null })
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { fullName, company } = await request.json()
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ fullName: fullName ?? null, company: company ?? null, updatedAt: new Date() })
|
||||
.where(eq(users.id, session.user.id))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
53
app/api/sessions/[id]/route.ts
Normal file
53
app/api/sessions/[id]/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { auth } from '@/auth'
|
||||
import { db } from '@/lib/db'
|
||||
import { sessions } from '@/lib/db/schema'
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (!result[0]) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ session: result[0] })
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
const updates: Record<string, unknown> = {}
|
||||
if (body.endedAt !== undefined) updates.endedAt = body.endedAt ? new Date(body.endedAt) : new Date()
|
||||
if (body.durationSeconds !== undefined) updates.durationSeconds = body.durationSeconds
|
||||
|
||||
await db
|
||||
.update(sessions)
|
||||
.set(updates)
|
||||
.where(and(eq(sessions.id, id), eq(sessions.viewerUserId, session.user.id)))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
89
app/api/signal/route.ts
Normal file
89
app/api/signal/route.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { auth } from '@/auth'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// In-memory signaling store (use Redis for multi-instance deployments)
|
||||
const signalingStore = new Map<string, {
|
||||
offer?: RTCSessionDescriptionInit
|
||||
answer?: RTCSessionDescriptionInit
|
||||
viewerCandidates: RTCIceCandidateInit[]
|
||||
hostCandidates: RTCIceCandidateInit[]
|
||||
createdAt: number
|
||||
}>()
|
||||
|
||||
function cleanupOldSessions() {
|
||||
const now = Date.now()
|
||||
for (const [key, value] of signalingStore.entries()) {
|
||||
if (now - value.createdAt > 5 * 60 * 1000) signalingStore.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { action, sessionId, data } = await request.json()
|
||||
cleanupOldSessions()
|
||||
|
||||
switch (action) {
|
||||
case 'create-session':
|
||||
signalingStore.set(sessionId, { viewerCandidates: [], hostCandidates: [], createdAt: Date.now() })
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
case 'send-offer': {
|
||||
const s = signalingStore.get(sessionId)
|
||||
if (!s) return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
s.offer = data.offer
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
case 'get-offer': {
|
||||
const s = signalingStore.get(sessionId)
|
||||
if (!s) return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
return NextResponse.json({ offer: s.offer ?? null })
|
||||
}
|
||||
|
||||
case 'send-answer': {
|
||||
const s = signalingStore.get(sessionId)
|
||||
if (!s) return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
s.answer = data.answer
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
case 'get-answer': {
|
||||
const s = signalingStore.get(sessionId)
|
||||
if (!s) return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
return NextResponse.json({ answer: s.answer ?? null })
|
||||
}
|
||||
|
||||
case 'send-ice-candidate': {
|
||||
const s = signalingStore.get(sessionId)
|
||||
if (!s) return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
if (data.role === 'viewer') s.viewerCandidates.push(data.candidate)
|
||||
else s.hostCandidates.push(data.candidate)
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
case 'get-ice-candidates': {
|
||||
const s = signalingStore.get(sessionId)
|
||||
if (!s) return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
const candidates = data.role === 'viewer' ? s.hostCandidates : s.viewerCandidates
|
||||
if (data.role === 'viewer') s.hostCandidates = []
|
||||
else s.viewerCandidates = []
|
||||
return NextResponse.json({ candidates })
|
||||
}
|
||||
|
||||
case 'close-session':
|
||||
signalingStore.delete(sessionId)
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Signal] Error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user