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); } }