Skip to content
This repository was archived by the owner on Apr 21, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions app/api/cdn/ingest/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';

function resolvePublicOrigin(req: NextRequest): string {
const fromEnv = process.env.PUBLIC_ORIGIN || process.env.NEXT_PUBLIC_PUBLIC_ORIGIN;
if (fromEnv) return fromEnv.replace(/\/$/, '');
const host = req.headers.get('x-forwarded-host') || req.headers.get('host') || '';
const proto = req.headers.get('x-forwarded-proto') || 'http';
return `${proto}://${host}`;
}

export async function POST(request: NextRequest) {
try {
// Prefer server env, but allow Authorization header or NEXT_PUBLIC_API_TOKEN for dev
const headerAuth = request.headers.get('authorization') || '';
const headerToken = headerAuth.toLowerCase().startsWith('bearer ')
? headerAuth.slice(7)
: '';
const token = process.env.API_TOKEN || process.env.NEXT_PUBLIC_API_TOKEN || headerToken;
if (!token) {
return NextResponse.json({ error: 'Missing API token. Set API_TOKEN, or send Authorization: Bearer <token>.' }, { status: 400 });
}
const { tempPath } = await request.json();
if (typeof tempPath !== 'string' || !tempPath.startsWith('/')) {
return NextResponse.json({ error: 'Invalid tempPath' }, { status: 400 });
}

const base = resolvePublicOrigin(request);
const fileUrl = `${base}${tempPath}`;

const res = await fetch('https://cdn.hackclub.com/api/v3/new', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify([fileUrl]),
});

if (!res.ok) {
const text = await res.text();
console.error('CDN ingest failed', res.status, text);
return NextResponse.json({ error: 'CDN ingest failed', status: res.status, details: text }, { status: 502 });
}

const data = await res.json();
const deployedUrl = data?.files?.[0]?.deployedUrl;
return NextResponse.json({ deployedUrl });
} catch (e: any) {
console.error('Ingest error', e);
return NextResponse.json({ error: 'Ingest error', details: e?.message || String(e) }, { status: 500 });
}
}


53 changes: 52 additions & 1 deletion app/api/projects/[projectId]/chat/messages/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,4 +246,55 @@ export async function POST(
}
}
);
}
}

// DELETE - Delete a chat message (admins or project owners only)
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const session = await getServerSession(opts);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const { projectId } = await params;
const { searchParams } = new URL(request.url);
const messageId = searchParams.get('messageId');
if (!messageId) {
return NextResponse.json({ error: 'messageId is required' }, { status: 400 });
}

// Load project to check ownership
const project = await prisma.project.findUnique({
where: { projectID: projectId },
select: { userId: true },
});
if (!project) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}

// Permission: admins or project owner
const isAdmin = session.user.role === 'Admin' || session.user.isAdmin === true;
const isOwner = session.user.id === project.userId;
if (!isAdmin && !isOwner) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}

// Ensure message exists and belongs to this project's room
const message = await prisma.chatMessage.findUnique({
where: { id: messageId },
select: { id: true, room: { select: { projectID: true } } },
});
if (!message || message.room.projectID !== projectId) {
return NextResponse.json({ error: 'Message not found' }, { status: 404 });
}

await prisma.chatMessage.delete({ where: { id: messageId } });
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting chat message:', error);
return NextResponse.json({ error: 'Failed to delete message' }, { status: 500 });
}
}
25 changes: 25 additions & 0 deletions app/api/uploads/[...key]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NextRequest } from 'next/server';
import { redis } from '@/lib/redis/redis';

export async function GET(_req: NextRequest, { params }: { params: { key: string[] } }) {
const key = decodeURIComponent(params.key.join('/'));
try {
const buf = await redis.getBuffer(key);
if (!buf) {
return new Response('Not Found', { status: 404 });
}
const meta = await redis.hgetall(`${key}:meta`);
const mime = meta?.mime || 'application/octet-stream';
return new Response(buf, {
status: 200,
headers: {
'Content-Type': mime,
'Cache-Control': 'public, max-age=60',
},
});
} catch {
return new Response('Error', { status: 500 });
}
}


39 changes: 39 additions & 0 deletions app/api/uploads/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { opts } from '../auth/[...nextauth]/route';
import { randomBytes } from 'crypto';
import { redis } from '@/lib/redis/redis';

// Accept multipart/form-data, store file bytes in Redis (10 min TTL), return a temporary URL
export async function POST(request: NextRequest) {
const session = await getServerSession(opts);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

try {
const form = await request.formData();
const file = form.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
const mime = file.type;
if (mime !== 'image/png' && mime !== 'image/jpeg') {
return NextResponse.json({ error: 'Invalid file type' }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
const buf = Buffer.from(arrayBuffer);
const key = `tmp:chat:${new Date().toISOString().slice(0,10)}:${session.user.id}:${randomBytes(8).toString('hex')}`;
await redis.set(key, buf, 'EX', 60 * 10);
await redis.hset(`${key}:meta`, { mime, size: String(buf.length) });
await redis.expire(`${key}:meta`, 60 * 10);
const tempUrl = `/api/uploads/${encodeURIComponent(key)}`;
return NextResponse.json({ tempUrl });
} catch (e: any) {
console.error('Upload failed', e);
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
}



8 changes: 6 additions & 2 deletions app/gallery/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,9 @@ function GalleryInner() {
)
);
} else {
let errText = 'Failed to upvote project';
try { const d = await response.json(); if (d?.error) errText = d.error; } catch {}
showToast(errText, 'error');
console.error('Failed to upvote project');
}
} catch (error) {
Expand Down Expand Up @@ -530,13 +533,14 @@ function GalleryInner() {
{/* Upvote button */}
<button
onClick={() => handleUpvote(project.projectID)}
disabled={upvotingProjects.has(project.projectID)}
disabled={upvotingProjects.has(project.projectID) || session?.user?.id === project.userId}
title={session?.user?.id === project.userId ? 'You cannot upvote your own project' : undefined}
className={`flex items-center justify-center gap-1 px-2 py-1 rounded-full text-sm font-medium transition-all min-w-[60px] ${
project.userUpvoted
? 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
} ${
upvotingProjects.has(project.projectID)
upvotingProjects.has(project.projectID) || session?.user?.id === project.userId
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:scale-105'
}`}
Expand Down
Loading
Loading