mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 14:21:15 +00:00
feat: add preview management, channel controls, invite polish, and recovery
- Auto-extract preview images from ZIP/RAR/7z archives during ingestion - Upload custom preview images via package drawer - Select preview from archive contents with on-demand extraction UI - Manually add Telegram channels by t.me link, username, or invite link - Invite code UX: bulk create, copy link, usage tracking, delete confirm - Incomplete upload recovery: verify dest messages on worker startup - Rebuild package DB by scanning destination channel with live progress Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
73
src/app/api/zips/[id]/extract/[requestId]/route.ts
Normal file
73
src/app/api/zips/[id]/extract/[requestId]/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateApiRequest } from "@/lib/telegram/api-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/zips/:id/extract/:requestId
|
||||
* Get the status and/or image data for an extraction request.
|
||||
* Query param: ?image=true returns the raw image bytes if completed.
|
||||
* Otherwise returns status JSON.
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; requestId: string }> }
|
||||
) {
|
||||
const authResult = await authenticateApiRequest(request);
|
||||
if ("error" in authResult) return authResult.error;
|
||||
|
||||
const { requestId } = await params;
|
||||
const url = new URL(request.url);
|
||||
const wantImage = url.searchParams.get("image") === "true";
|
||||
|
||||
if (wantImage) {
|
||||
// Return the raw image bytes
|
||||
const req = await prisma.archiveExtractRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { status: true, imageData: true, contentType: true, error: true },
|
||||
});
|
||||
|
||||
if (!req) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
if (req.status !== "COMPLETED" || !req.imageData) {
|
||||
return NextResponse.json(
|
||||
{ status: req.status, error: req.error },
|
||||
{ status: req.status === "FAILED" ? 400 : 202 }
|
||||
);
|
||||
}
|
||||
|
||||
const buffer =
|
||||
req.imageData instanceof Buffer
|
||||
? req.imageData
|
||||
: Buffer.from(req.imageData);
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": req.contentType || "image/jpeg",
|
||||
"Content-Length": String(buffer.length),
|
||||
"Cache-Control": "public, max-age=3600, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Return status JSON (without image data to avoid large payloads)
|
||||
const req = await prisma.archiveExtractRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { id: true, status: true, error: true, contentType: true },
|
||||
});
|
||||
|
||||
if (!req) {
|
||||
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
requestId: req.id,
|
||||
status: req.status,
|
||||
error: req.error,
|
||||
contentType: req.contentType,
|
||||
});
|
||||
}
|
||||
118
src/app/api/zips/[id]/extract/route.ts
Normal file
118
src/app/api/zips/[id]/extract/route.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateApiRequest } from "@/lib/telegram/api-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* POST /api/zips/:id/extract
|
||||
* Request extraction of an image from a package archive.
|
||||
* Body: { filePath: string }
|
||||
* Returns: { requestId: string, status: string }
|
||||
*
|
||||
* If a completed extraction already exists for this package+filePath,
|
||||
* returns it immediately.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const authResult = await authenticateApiRequest(request);
|
||||
if ("error" in authResult) return authResult.error;
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const filePath = body?.filePath;
|
||||
|
||||
if (!filePath || typeof filePath !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "filePath is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify package exists
|
||||
const pkg = await prisma.package.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, destChannelId: true, destMessageId: true, archiveType: true, isMultipart: true, partCount: true },
|
||||
});
|
||||
|
||||
if (!pkg) {
|
||||
return NextResponse.json({ error: "Package not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!pkg.destChannelId || !pkg.destMessageId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Package has not been uploaded to destination channel" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (pkg.archiveType === "DOCUMENT") {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot extract images from standalone documents" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (pkg.isMultipart && pkg.partCount > 1) {
|
||||
return NextResponse.json(
|
||||
{ error: "Image extraction is not supported for multipart archives" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for an existing completed extraction
|
||||
const existing = await prisma.archiveExtractRequest.findFirst({
|
||||
where: {
|
||||
packageId: id,
|
||||
filePath,
|
||||
status: "COMPLETED",
|
||||
imageData: { not: null },
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({
|
||||
requestId: existing.id,
|
||||
status: "COMPLETED",
|
||||
});
|
||||
}
|
||||
|
||||
// Check for an in-progress request
|
||||
const pending = await prisma.archiveExtractRequest.findFirst({
|
||||
where: {
|
||||
packageId: id,
|
||||
filePath,
|
||||
status: { in: ["PENDING", "IN_PROGRESS"] },
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
if (pending) {
|
||||
return NextResponse.json({
|
||||
requestId: pending.id,
|
||||
status: pending.status,
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new extraction request
|
||||
const extractRequest = await prisma.archiveExtractRequest.create({
|
||||
data: {
|
||||
packageId: id,
|
||||
filePath,
|
||||
},
|
||||
});
|
||||
|
||||
// Notify the worker via pg_notify
|
||||
await prisma.$queryRawUnsafe(
|
||||
`SELECT pg_notify('archive_extract', $1)`,
|
||||
extractRequest.id
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
requestId: extractRequest.id,
|
||||
status: "PENDING",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user