feat: Docker audit + Telegram bot service + send UI

Docker:
- Harden docker-compose.yml: parameterized DB creds, required AUTH_SECRET,
  health checks, resource limits, network isolation, removed exposed DB port
- Add profiles (telegram/bot/full) so base 'docker compose up' needs only AUTH_SECRET
- Fix docker-entrypoint.sh: AUTH_SECRET startup guard
- Fix Dockerfile: copy prisma.config.ts + dotenv into production image
- Update .env.example with all new variables
- Update .dockerignore

Telegram Bot Service (bot/):
- TDLib-based bot using bot token auth (not HTTP Bot API)
- Commands: /search, /latest, /package, /link, /unlink, /subscribe, /unsubscribe
- pg_notify listener for send requests (bot_send) and new packages (new_package)
- Subscription-based notifications when matching packages arrive
- Dockerfile with multi-stage build (bookworm-slim for glibc/TDLib)

API & Database:
- Prisma: TelegramLink, BotSendRequest, BotSubscription models + migration
- POST /api/telegram/bot/send - queue package delivery to linked TG account
- GET /api/telegram/bot/send/[id] - poll send request status
- Server actions: generateTelegramLinkCode, unlinkTelegram, getBotSendHistory
- Worker: emit pg_notify('new_package') after creating packages

Frontend:
- Settings: TelegramLinkCard for account linking via one-time code
- STL table + drawer: SendToTelegramButton with send dialog and status polling
- Telegram admin: Bot Sends tab with delivery history table
- Shared SendHistoryRow type

README: Updated with bot docs, profiles, config vars, project structure
This commit is contained in:
2026-03-03 21:36:57 +01:00
parent 4d0df6b1a4
commit 575ffdbc31
36 changed files with 4516 additions and 37 deletions

View File

@@ -0,0 +1,98 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
/**
* POST /api/telegram/bot/send
* Queue a package to be sent to a user's linked Telegram account via the bot.
*
* Body: { packageId: string, targetUserId?: string }
* - targetUserId: optional, admin-only — send to another user's linked TG
*/
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: { packageId?: string; targetUserId?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
if (!body.packageId) {
return NextResponse.json({ error: "packageId is required" }, { status: 400 });
}
// Determine whose TelegramLink to use
const targetUserId = body.targetUserId ?? session.user.id;
// Only admins can send to other users
if (body.targetUserId && body.targetUserId !== session.user.id) {
if (session.user.role !== "ADMIN") {
return NextResponse.json(
{ error: "Only admins can send to other users" },
{ status: 403 }
);
}
}
// Verify the target user has a linked Telegram account
const telegramLink = await prisma.telegramLink.findUnique({
where: { userId: targetUserId },
});
if (!telegramLink) {
return NextResponse.json(
{ error: "Target user has no linked Telegram account. Link one in Settings → Telegram." },
{ status: 400 }
);
}
// Verify the package exists and has a destination message
const pkg = await prisma.package.findUnique({
where: { id: body.packageId },
select: { id: true, fileName: true, destChannelId: true, destMessageId: 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 a destination channel yet" },
{ status: 400 }
);
}
// Create the send request
const sendRequest = await prisma.botSendRequest.create({
data: {
packageId: body.packageId,
telegramLinkId: telegramLink.id,
requestedByUserId: session.user.id,
status: "PENDING",
},
});
// Notify the bot via pg_notify
try {
await prisma.$queryRawUnsafe(
`SELECT pg_notify('bot_send', $1)`,
sendRequest.id
);
} catch {
// Best-effort — the bot also polls periodically
}
return NextResponse.json({
requestId: sendRequest.id,
status: "PENDING",
message: `Queued "${pkg.fileName}" for delivery to Telegram`,
});
}