feat: add scoped upload tokens
This commit is contained in:
123
nextjs/app/%5Ftoken-upload/[...path]/route.js
Normal file
123
nextjs/app/%5Ftoken-upload/[...path]/route.js
Normal file
@@ -0,0 +1,123 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user