diff --git a/Dockerfile b/Dockerfile index 645933e..27a8181 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ FROM node:22-alpine AS base -RUN apk add --no-cache libc6-compat openssl +RUN apk add --no-cache libc6-compat openssl su-exec WORKDIR /app FROM base AS deps @@ -18,7 +18,7 @@ ENV NODE_ENV=production RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs -RUN mkdir -p public/uploads && chown nextjs:nodejs public/uploads +RUN mkdir -p public/uploads COPY --from=builder /app/public ./public COPY --from=builder /app/.next ./.next @@ -26,10 +26,11 @@ COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/next.config.ts ./next.config.ts +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh && chown -R nextjs:nodejs /app -USER nextjs EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -CMD ["sh", "-c", "npx prisma db push --skip-generate && npm start"] +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..e48216b --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +mkdir -p /app/public/uploads +chown -R nextjs:nodejs /app/public/uploads + +cd /app + +exec su-exec nextjs sh -c "npx prisma db push --skip-generate && exec npm start" diff --git a/next.config.ts b/next.config.ts index e0c829f..3785a4b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,11 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + experimental: { + serverActions: { + bodySizeLimit: "10mb", + }, + }, images: { remotePatterns: [ { diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index ae01a07..9d04f4e 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { writeFile, mkdir } from "fs/promises"; +import { writeFile, mkdir, access } from "fs/promises"; +import { constants } from "fs"; import path from "path"; import { prisma } from "@/lib/prisma"; @@ -10,7 +11,44 @@ const ALLOWED_TYPES = [ "image/webp", "image/svg+xml", ]; + +const MIME_BY_EXT: Record = { + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", +}; + const MAX_SIZE = 10 * 1024 * 1024; +const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads"); + +function resolveMimeType(file: File): string | null { + if (file.type && ALLOWED_TYPES.includes(file.type)) { + return file.type; + } + + const ext = file.name.split(".").pop()?.toLowerCase() || ""; + return MIME_BY_EXT[ext] ?? null; +} + +function safeExtension(filename: string, mimeType: string): string { + const ext = filename.split(".").pop()?.toLowerCase(); + if (ext && MIME_BY_EXT[ext]) { + return ext; + } + + const byMime: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/gif": "gif", + "image/webp": "webp", + "image/svg+xml": "svg", + }; + + return byMime[mimeType] || "jpg"; +} export async function POST(request: NextRequest) { try { @@ -18,13 +56,11 @@ export async function POST(request: NextRequest) { const file = formData.get("file") as File | null; if (!file) { - return NextResponse.json( - { error: "Файл не найден" }, - { status: 400 } - ); + return NextResponse.json({ error: "Файл не найден" }, { status: 400 }); } - if (!ALLOWED_TYPES.includes(file.type)) { + const mimeType = resolveMimeType(file); + if (!mimeType) { return NextResponse.json( { error: "Недопустимый тип файла" }, { status: 400 } @@ -38,15 +74,24 @@ export async function POST(request: NextRequest) { ); } - const ext = file.name.split(".").pop()?.toLowerCase() || "jpg"; - const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}.${ext}`; - const uploadDir = path.join(process.cwd(), "public", "uploads"); + await mkdir(UPLOAD_DIR, { recursive: true }); - await mkdir(uploadDir, { recursive: true }); + try { + await access(UPLOAD_DIR, constants.W_OK); + } catch { + console.error("Upload dir not writable:", UPLOAD_DIR); + return NextResponse.json( + { error: "Каталог загрузок недоступен для записи" }, + { status: 500 } + ); + } + + const ext = safeExtension(file.name, mimeType); + const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}.${ext}`; + const filepath = path.join(UPLOAD_DIR, filename); const bytes = await file.arrayBuffer(); - const buffer = Buffer.from(bytes); - await writeFile(path.join(uploadDir, filename), buffer); + await writeFile(filepath, Buffer.from(bytes)); const url = `/uploads/${filename}`; @@ -54,13 +99,16 @@ export async function POST(request: NextRequest) { data: { filename, originalName: file.name, - mimeType: file.type, + mimeType, size: file.size, url, }, }); - return NextResponse.json(photo); + return NextResponse.json({ + ...photo, + createdAt: photo.createdAt.toISOString(), + }); } catch (error) { console.error("Upload error:", error); return NextResponse.json(