Restore from Gitea ZIP snapshot (12.08.2026) after full instance reinstall

Git history was lost when the previous Gitea instance was wiped and
reinstalled due to an unresolved corruption bug — this commit is the
last known-good file content, exported before the reinstall. Prior
commit history is not recoverable through this path.
This commit is contained in:
Claude Sonnet 5
2026-08-12 19:24:03 +00:00
commit 52ca317e9c
138 changed files with 24016 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { authenticateLdapUser } from "@/lib/ldap/client";
import { getLdapSettings } from "@/lib/auth/ldap-config";
import { findOrCreateCustomerByEmail } from "@/lib/tickets/service";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
// Simple in-memory rate limit — mirrors /api/auth/login.
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 15 * 60 * 1000;
function isRateLimited(key: string): boolean {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || entry.resetAt < now) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
return false;
}
entry.count += 1;
return entry.count > MAX_ATTEMPTS;
}
// Customer portal access has no local password — LDAP is the only login
// method here (the personal link stays the primary path). Unlike the admin
// login, we surface "LDAP isn't set up" as its own message: with no local
// fallback to quietly degrade to, a generic "invalid credentials" would be
// actively misleading while LDAP is disabled.
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const email = parsed.data.email.toLowerCase().trim();
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (isRateLimited(`${ip}:${email}`)) {
return NextResponse.json({ error: "Слишком много попыток — попробуйте позже" }, { status: 429 });
}
const ldapSettings = await getLdapSettings();
if (!ldapSettings) {
return NextResponse.json(
{ error: "Вход по LDAP пока недоступен. Используйте ссылку из письма или Telegram." },
{ status: 400 },
);
}
const ldapUser = await authenticateLdapUser(email, parsed.data.password);
if (!ldapUser) {
return NextResponse.json({ error: "Неверный email или пароль" }, { status: 401 });
}
const customer = await findOrCreateCustomerByEmail(ldapUser.email, ldapUser.name);
return NextResponse.json({ ok: true, portalUrl: `/t/${customer.portalToken}` });
}
+31
View File
@@ -0,0 +1,31 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { getCustomerByPortalToken } from "@/lib/tickets/service";
import { createEventStream } from "@/lib/events/sse";
import { ticketEventCustomerId } from "@/lib/events/bus";
/**
* Realtime feed for a customer's portal — scoped to their own tickets only.
* EventSource can't send custom headers, so the token travels as a query param.
*/
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get("token");
if (!token) {
return NextResponse.json({ error: "Missing token" }, { status: 400 });
}
const customer = await getCustomerByPortalToken(token);
if (!customer) {
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
}
return createEventStream((event) => {
if (ticketEventCustomerId(event) !== customer.id) return false;
// Internal notes must never reach a customer-facing stream, even though
// the initial page-load fetch (getTicketForCustomer) already filters
// them out — SSE is a second, independent delivery path for the same data.
if (event.type === "message.created" && event.message.visibility === "internal") return false;
return true;
});
}
@@ -0,0 +1,65 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { getCustomerByPortalToken, appendCustomerReply } from "@/lib/tickets/service";
import { saveAttachment, AttachmentTooLargeError, MAX_ATTACHMENT_BYTES } from "@/lib/attachments/storage";
export async function POST(
request: Request,
{ params }: { params: Promise<{ ticketId: string }> },
) {
const { ticketId } = await params;
const formData = await request.formData().catch(() => null);
const token = formData?.get("token");
if (typeof token !== "string" || !token) {
return NextResponse.json({ error: "Missing token" }, { status: 400 });
}
const file = formData?.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "Missing file" }, { status: 400 });
}
if (file.size > MAX_ATTACHMENT_BYTES) {
return NextResponse.json({ error: "File too large" }, { status: 413 });
}
const customer = await getCustomerByPortalToken(token);
if (!customer) {
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
}
const captionRaw = formData?.get("caption");
const caption = typeof captionRaw === "string" ? captionRaw.trim() : "";
const buffer = Buffer.from(await file.arrayBuffer());
let saved;
try {
saved = await saveAttachment(buffer);
} catch (err) {
if (err instanceof AttachmentTooLargeError) {
return NextResponse.json({ error: "File too large" }, { status: 413 });
}
throw err;
}
try {
const result = await appendCustomerReply({
ticketId,
customerId: customer.id,
authorName: customer.displayName,
body: caption || file.name || "Вложение",
attachments: [
{
filename: file.name || "file",
mimeType: file.type || "application/octet-stream",
sizeBytes: saved.sizeBytes,
storageKey: saved.storageKey,
},
],
});
return NextResponse.json(result, { status: 201 });
} catch {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
}
}
@@ -0,0 +1,39 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getCustomerByPortalToken, appendCustomerReply } from "@/lib/tickets/service";
const schema = z.object({
token: z.string().min(1),
body: z.string().min(1).max(10_000),
});
export async function POST(
request: Request,
{ params }: { params: Promise<{ ticketId: string }> },
) {
const { ticketId } = await params;
const body = await request.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const customer = await getCustomerByPortalToken(parsed.data.token);
if (!customer) {
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
}
try {
const result = await appendCustomerReply({
ticketId,
customerId: customer.id,
authorName: customer.displayName,
body: parsed.data.body,
});
return NextResponse.json(result, { status: 201 });
} catch {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
}
}