124 lines
3.8 KiB
JavaScript
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);
|
|
}
|
|
}
|