Files
files/nextjs/app/%5Ftoken-upload/[...path]/route.js
2026-06-18 19:17:51 +00:00

124 lines
3.8 KiB
JavaScript

import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { NextResponse } from 'next/server';
import { maxUploadBytes } from '@/src/lib/config.js';
import { get, logEvent, run, runCleanupIfNeeded } from '@/src/lib/db.js';
import { resolveScopedAdminUploadPath } from '@/src/lib/files.js';
import { streamBodyToPath } from '@/src/lib/multipart.js';
import { getRequestMeta } from '@/src/lib/security.js';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
function textResponse(message, status = 200) {
return new NextResponse(`${message}\n`, {
status,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
function bearerToken(request) {
const authorization = String(request.headers.get('authorization') || '');
const match = authorization.match(/^Bearer\s+(.+)$/i);
return match ? match[1].trim() : '';
}
function hashUploadToken(token) {
return crypto.createHash('sha256').update(String(token || '')).digest('hex');
}
function requestedUploadPath(value) {
const parts = Array.isArray(value) ? value : [];
return parts.join('/');
}
function hasBlockedExtension(uploadPath) {
return path.extname(String(uploadPath || '')).toLowerCase() === '.php';
}
export async function PUT(request, { params }) {
await runCleanupIfNeeded();
const token = bearerToken(request);
if (!token) {
return textResponse('Missing bearer token.', 401);
}
const tokenHash = hashUploadToken(token);
const tokenEntry = await get(
`SELECT id, created_by, scope_path, expires_at, disabled_at
FROM upload_tokens
WHERE token_hash = ?`,
[tokenHash]
);
if (!tokenEntry || Number(tokenEntry.disabled_at || 0) > 0) {
return textResponse('Invalid bearer token.', 401);
}
const now = Date.now();
if (Number(tokenEntry.expires_at || 0) > 0 && Number(tokenEntry.expires_at) <= now) {
return textResponse('Expired bearer token.', 401);
}
const resolvedParams = await params;
const uploadPath = requestedUploadPath(resolvedParams.path);
if (hasBlockedExtension(uploadPath)) {
return textResponse('PHP uploads are not allowed.', 415);
}
const scopedTarget = resolveScopedAdminUploadPath(
tokenEntry.scope_path,
uploadPath
);
if (!scopedTarget) {
return textResponse('Invalid upload path.', 400);
}
const contentLength = Number.parseInt(String(request.headers.get('content-length') || ''), 10);
if (maxUploadBytes > 0 && Number.isFinite(contentLength) && contentLength > maxUploadBytes) {
return textResponse(`File exceeds upload limit (${maxUploadBytes} bytes).`, 413);
}
try {
await fs.promises.mkdir(path.dirname(scopedTarget.targetPath), { recursive: true });
try {
await fs.promises.access(scopedTarget.targetPath, fs.constants.F_OK);
return textResponse('Target file already exists.', 409);
} catch {
}
const parsed = await streamBodyToPath(request, {
targetPath: scopedTarget.targetPath,
maxBytes: maxUploadBytes,
});
await run('UPDATE upload_tokens SET last_used_at = ? WHERE id = ?', [Date.now(), tokenEntry.id]);
await logEvent(
'token_upload',
tokenEntry.created_by || null,
{ token_id: tokenEntry.id, path: scopedTarget.relativePath, size: parsed.sizeBytes },
await getRequestMeta()
);
return textResponse(`Uploaded ${scopedTarget.relativePath}`, 201);
} catch (error) {
if (error && error.message === 'file-too-large') {
return textResponse(`File exceeds upload limit (${maxUploadBytes} bytes).`, 413);
}
if (error && error.message === 'missing-file') {
return textResponse('Missing file body.', 400);
}
if (error && error.code === 'EEXIST') {
return textResponse('Target file already exists.', 409);
}
return textResponse('Upload failed.', 500);
}
}