fix: upload permissions in Docker and improve file validation
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+5
-4
@@ -1,5 +1,5 @@
|
|||||||
FROM node:22-alpine AS base
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
@@ -18,7 +18,7 @@ ENV NODE_ENV=production
|
|||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
adduser --system --uid 1001 nextjs
|
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/public ./public
|
||||||
COPY --from=builder /app/.next ./.next
|
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/package.json ./package.json
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
COPY --from=builder /app/next.config.ts ./next.config.ts
|
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
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
CMD ["sh", "-c", "npx prisma db push --skip-generate && npm start"]
|
ENTRYPOINT ["/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"
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: "10mb",
|
||||||
|
},
|
||||||
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
|
|||||||
+62
-14
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
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 path from "path";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
@@ -10,7 +11,44 @@ const ALLOWED_TYPES = [
|
|||||||
"image/webp",
|
"image/webp",
|
||||||
"image/svg+xml",
|
"image/svg+xml",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const MIME_BY_EXT: Record<string, string> = {
|
||||||
|
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 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<string, string> = {
|
||||||
|
"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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -18,13 +56,11 @@ export async function POST(request: NextRequest) {
|
|||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Файл не найден" }, { status: 400 });
|
||||||
{ error: "Файл не найден" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
const mimeType = resolveMimeType(file);
|
||||||
|
if (!mimeType) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Недопустимый тип файла" },
|
{ error: "Недопустимый тип файла" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -38,15 +74,24 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = file.name.split(".").pop()?.toLowerCase() || "jpg";
|
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||||
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}.${ext}`;
|
|
||||||
const uploadDir = path.join(process.cwd(), "public", "uploads");
|
|
||||||
|
|
||||||
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 bytes = await file.arrayBuffer();
|
||||||
const buffer = Buffer.from(bytes);
|
await writeFile(filepath, Buffer.from(bytes));
|
||||||
await writeFile(path.join(uploadDir, filename), buffer);
|
|
||||||
|
|
||||||
const url = `/uploads/${filename}`;
|
const url = `/uploads/${filename}`;
|
||||||
|
|
||||||
@@ -54,13 +99,16 @@ export async function POST(request: NextRequest) {
|
|||||||
data: {
|
data: {
|
||||||
filename,
|
filename,
|
||||||
originalName: file.name,
|
originalName: file.name,
|
||||||
mimeType: file.type,
|
mimeType,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
url,
|
url,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(photo);
|
return NextResponse.json({
|
||||||
|
...photo,
|
||||||
|
createdAt: photo.createdAt.toISOString(),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Upload error:", error);
|
console.error("Upload error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
Reference in New Issue
Block a user