Add large paste (API V2)

Generate S3 pre-signed url

Fix Content-Disposition charsets

Signed-off-by: Joe Ma <rikkaneko23@gmail.com>
This commit is contained in:
Joe Ma 2024-02-02 03:15:34 +08:00
parent 8623fb74d9
commit 76e8d074dc
No known key found for this signature in database
GPG key ID: 7A0ECF5F5EDC587F
8 changed files with 550 additions and 227 deletions

View file

@ -19,12 +19,11 @@
import { AwsClient } from 'aws4fetch';
import { sha256 } from 'js-sha256';
import { Router, error } from 'itty-router';
import { ERequest, Env, PasteIndexEntry } from './types';
import { ERequest, Env, PasteIndexEntry, PASTE_TYPES } from './types';
import { serve_static } from './proxy';
import { check_password_rules, get_paste_info, get_basic_auth, gen_id } from './utils';
import { UUID_LENGTH, PASTE_WEB_URL, SERVICE_URL } from './constant';
const gen_id = customAlphabet('1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', UUID_LENGTH);
import { get_presign_url, router as large_upload } from './v2/large_upload';
const router = Router<ERequest, [Env, ExecutionContext]>();
@ -82,6 +81,7 @@ router.post('/', async (request, env, ctx) => {
const data: File | string | any = formdata.get('u');
const type = formdata.get('paste-type');
const file_title = formdata.get('title');
const file_meta = formdata.get('mime-type');
if (data === null) {
return new Response('Invalid request.\n', {
status: 422,
@ -99,7 +99,15 @@ router.post('/', async (request, env, ctx) => {
}
if (typeof file_title === 'string') title = file_title;
if (typeof type === 'string') paste_type = type;
if (typeof file_meta === 'string') mime_type = file_meta;
if (typeof type === 'string') {
if (type === 'paste' || type === 'link') paste_type = type;
else {
return new Response('paste-type can only be "paste" or "link".\n', {
status: 422,
});
}
}
// Set password
const pass = formdata.get('pass');
@ -129,8 +137,6 @@ router.post('/', async (request, env, ctx) => {
if (typeof json === 'string' && json === '1') {
reply_json = true;
}
// Paste API v2
} else {
title = headers.get('x-paste-title') || undefined;
mime_type = headers.get('x-paste-content-type') || undefined;
@ -157,24 +163,6 @@ router.post('/', async (request, env, ctx) => {
need_qrcode = true;
}
// Validate paste type parameter
switch (paste_type) {
case 'link':
mime_type = 'text/x-uri';
paste_type = 'link';
break;
case 'paste':
case undefined:
paste_type = undefined;
break;
default:
return new Response('Unknown paste type.\n', {
status: 422,
});
}
// Check file title rules
if (title && /^.*[\\\/]/.test(title))
return new Response('Invalid title', {
@ -216,6 +204,17 @@ router.post('/', async (request, env, ctx) => {
body: buffer,
});
if (paste_type === 'link') {
mime_type = 'text/x-uri';
}
// Validate paste type parameter
if (paste_type !== 'paste' && paste_type !== 'link') {
return new Response('Unknown paste type.\n', {
status: 422,
});
}
if (res.ok) {
// Upload success
const descriptor: PasteIndexEntry = {
@ -238,6 +237,9 @@ router.post('/', async (request, env, ctx) => {
}
});
// Handle large upload (> 25MB)
router.all('/v2/large_upload/*', large_upload.handle);
// Fetch paste by uuid [4-digit UUID]
router.get('/:uuid/:option?', async (request, env, ctx) => {
const { headers } = request;
@ -312,6 +314,24 @@ router.get('/:uuid/:option?', async (request, env, ctx) => {
);
}
// New added in 2.0
// Handle large_paste
if (descriptor.type === 'large_paste') {
if (!descriptor.upload_completed) {
return new Response('This paste is not yet finalized.\n', {
status: 400,
});
}
const signed_url = await get_presign_url(uuid, descriptor, env);
return new Response(null, {
status: 301,
headers: {
location: signed_url,
},
});
}
// Enable CF cache for authorized request
// Match in existing cache
const cache = caches.default;
@ -365,7 +385,7 @@ router.get('/:uuid/:option?', async (request, env, ctx) => {
}
res.headers.set('cache-control', 'public, max-age=18000');
res.headers.set('content-disposition', `inline; filename="${encodeURIComponent(descriptor.title ?? uuid)}"`);
res.headers.set('content-disposition', `inline; filename*=UTF-8''${encodeURIComponent(descriptor.title ?? uuid)}`);
if (descriptor.mime_type) res.headers.set('content-type', descriptor.mime_type);
// Let the browser guess the content
@ -400,7 +420,10 @@ router.get('/:uuid/:option?', async (request, env, ctx) => {
// Handle option
if (option === 'raw') res.headers.delete('content-type');
else if (option === 'download')
res.headers.set('content-disposition', `attachment; filename="${encodeURIComponent(descriptor.title ?? uuid)}"`);
res.headers.set(
'content-disposition',
`attachment; filename*=UTF-8''${encodeURIComponent(descriptor.title ?? uuid)}`
);
return res;
}
@ -413,7 +436,10 @@ router.get('/:uuid/:option?', async (request, env, ctx) => {
// Handle option
if (option === 'raw') nres.headers.delete('content-type');
else if (option === 'download')
nres.headers.set('content-disposition', `attachment; filename="${encodeURIComponent(descriptor.title ?? uuid)}"`);
nres.headers.set(
'content-disposition',
`attachment; filename*=UTF-8''${encodeURIComponent(descriptor.title ?? uuid)}`
);
return nres;
});
@ -466,11 +492,21 @@ router.delete('/:uuid', async (request, env, ctx) => {
}
const cache = caches.default;
// Distinguish the endpoint for large_paste and normal paste
if (descriptor.type === 'large_paste') {
if (!env.LARGE_AWS_ACCESS_KEY_ID || !env.LARGE_AWS_SECRET_ACCESS_KEY || !env.LARGE_ENDPOINT) {
return new Response('Unsupported paste type.\n', {
status: 501,
});
}
}
const endpoint = descriptor.type === 'large_paste' ? env.LARGE_DOWNLOAD_ENDPOINT : env.ENDPOINT;
const s3 = new AwsClient({
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
accessKeyId: descriptor.type === 'large_paste' ? env.LARGE_AWS_ACCESS_KEY_ID! : env.AWS_ACCESS_KEY_ID,
secretAccessKey: descriptor.type === 'large_paste' ? env.LARGE_AWS_SECRET_ACCESS_KEY! : env.AWS_SECRET_ACCESS_KEY,
service: 's3', // required
});
let res = await s3.fetch(`${env.ENDPOINT}/${uuid}`, {
let res = await s3.fetch(`${endpoint}/${uuid}`, {
method: 'DELETE',
});

9
src/types.d.ts vendored
View file

@ -5,15 +5,19 @@ export type ERequest = {
// match_etag?: string;
} & IRequest;
export type PASTE_TYPES = 'paste' | 'link' | 'large_paste';
export interface PasteIndexEntry {
title?: string;
mime_type?: string;
last_modified: number;
expiration?: number; // New added in 2.0
size: number;
password?: string;
editable?: boolean; // Default: False (unsupported)
read_count_remain?: number;
type?: string;
type: PASTE_TYPES;
// Only apply when large_paste
upload_completed?: boolean;
sha256_hash?: string;
}
@ -26,5 +30,6 @@ export interface Env {
LARGE_AWS_ACCESS_KEY_ID?: string;
LARGE_AWS_SECRET_ACCESS_KEY?: string;
ENDPOINT: string;
LARGE_ENDPOINT: string;
LARGE_ENDPOINT?: string;
LARGE_DOWNLOAD_ENDPOINT?: string;
}

View file

@ -32,7 +32,7 @@ export async function get_paste_info(
reply_json = false
): Promise<Response> {
const created = new Date(descriptor.last_modified);
const expired = new Date(descriptor.last_modified + 2419200000);
const expired = new Date(descriptor.expiration ?? descriptor.last_modified + 2419200000);
const link = `https://${SERVICE_URL}/${uuid}`;
const paste_info = {
uuid,
@ -47,6 +47,7 @@ export async function get_paste_info(
read_count_remain: descriptor.read_count_remain,
created: created.toISOString(),
expired: expired.toISOString(),
update_completed: descriptor.upload_completed ?? undefined, // only for large_paste
};
// Reply with JSON

275
src/v2/large_upload.ts Normal file
View file

@ -0,0 +1,275 @@
import { Router } from 'itty-router';
import { sha256 } from 'js-sha256';
import { AwsClient } from 'aws4fetch';
import { ERequest, Env, PasteIndexEntry } from '../types';
import { gen_id } from '../utils';
import { UUID_LENGTH } from '../constant';
export const router = Router<ERequest, [Env, ExecutionContext]>({ base: '/v2/large_upload' });
export async function get_presign_url(uuid: string, descriptor: PasteIndexEntry, env: Env) {
const endpoint_url = new URL(`${env.LARGE_DOWNLOAD_ENDPOINT}/${uuid}`);
endpoint_url.searchParams.set('X-Amz-Expires', '3600');
endpoint_url.searchParams.set(
'response-content-disposition',
`inline; filename*=UTF-8''${encodeURIComponent(descriptor.title ?? uuid)}`
);
endpoint_url.searchParams.set('response-content-type', descriptor.mime_type ?? 'text/plain; charset=UTF-8;');
// Generate Presigned Request
const s3 = new AwsClient({
accessKeyId: env.LARGE_AWS_ACCESS_KEY_ID!,
secretAccessKey: env.LARGE_AWS_SECRET_ACCESS_KEY!,
service: 's3',
});
const signed = await s3.sign(endpoint_url, {
method: 'GET',
headers: {},
aws: {
signQuery: true,
},
});
return signed.url;
}
router.all('*', (request, env, ctx) => {
if (!env.LARGE_AWS_ACCESS_KEY_ID || !env.LARGE_AWS_SECRET_ACCESS_KEY || !env.LARGE_ENDPOINT) {
return new Response('This function is currently disabled.\n', {
status: 501,
});
}
});
router.post('/create', async (request, env, ctx) => {
const { headers } = request;
const content_type = headers.get('content-type');
let file_title: string | undefined;
let file_mime: string | undefined;
let password: string | undefined;
let read_limit: number | undefined;
let file_size: number | undefined;
let file_hash: string | undefined;
if (content_type?.includes('multipart/form-data')) {
const formdata = await request.formData();
const title = formdata.get('title');
const mime = formdata.get('mime-type');
if (typeof title === 'string') file_title = title;
if (typeof mime === 'string') file_mime = mime;
const pass = formdata.get('pass') ?? undefined;
if (typeof pass === 'string') password = pass;
const count = formdata.get('read-limit');
if (typeof count === 'string') {
const n = parseInt(count);
if (isNaN(n) || n <= 0) {
return new Response('Invalid read-limit field, must be a positive integer.\n', {
status: 422,
});
}
read_limit = n;
}
const size = formdata.get('file-size');
if (typeof size === 'string') {
const n = parseInt(size);
if (isNaN(n) || n <= 0) {
return new Response('Invalid file-size, expecting a positive integer.\n', {
status: 422,
});
}
file_size = n;
} else {
return new Response('Invalid file-size, expecting a positive integer.\n', {
status: 422,
});
}
file_hash = formdata.get('file-sha256-hash') ?? undefined;
if (!file_hash || file_hash.length !== 64) {
return new Response('Invalid file-sha256-hash, expecting a SHA256 hex.\n', {
status: 422,
});
}
} else {
return new Response('Currently only support multipart/form-data.\n', {
status: 501,
});
}
if (file_size > 262144000) {
return new Response('Paste size must be under 250MB.\n', {
status: 422,
});
}
const uuid = gen_id();
const s3 = new AwsClient({
accessKeyId: env.LARGE_AWS_ACCESS_KEY_ID!,
secretAccessKey: env.LARGE_AWS_SECRET_ACCESS_KEY!,
service: 's3',
});
const current = Date.now();
const expiration = new Date(current + 14400 * 1000).getTime();
const endpoint_url = new URL(`${env.LARGE_ENDPOINT}/${uuid}`);
endpoint_url.searchParams.set('X-Amz-Expires', '14400');
const required_headers = {
'Content-Length': file_size.toString(),
'X-Amz-Content-Sha256': file_hash,
};
// Generate Presigned Request
const signed = await s3.sign(endpoint_url, {
method: 'PUT',
headers: required_headers,
aws: {
signQuery: true,
service: 's3',
allHeaders: true,
},
});
const result = {
uuid,
expiration,
file_size,
file_hash,
signed_url: signed.url,
required_headers,
};
const descriptor: PasteIndexEntry = {
title: file_title || undefined,
mime_type: file_mime || undefined,
last_modified: current,
expiration: new Date(Date.now() + 3600 * 1000).getTime(),
password: password ? sha256(password).slice(0, 16) : undefined,
read_count_remain: read_limit ?? undefined,
type: 'large_paste',
size: file_size,
upload_completed: false,
sha256_hash: file_hash,
};
ctx.waitUntil(
env.PASTE_INDEX.put(uuid, JSON.stringify(descriptor), {
expirationTtl: 14400,
})
);
return new Response(JSON.stringify(result));
});
router.post('/complete/:uuid', async (request, env, ctx) => {
const { headers } = request;
const { uuid } = request.params;
// UUID format: [A-z0-9]{UUID_LENGTH}
if (uuid.length !== UUID_LENGTH) {
return new Response('Invalid UUID.\n', {
status: 442,
});
}
const val = await env.PASTE_INDEX.get(uuid);
if (val === null) {
return new Response('Paste not found.\n', {
status: 404,
});
}
const descriptor: PasteIndexEntry = JSON.parse(val);
if (descriptor.type !== 'large_paste' || descriptor.upload_completed) {
return new Response('Invalid operation.\n', {
status: 442,
});
}
const s3 = new AwsClient({
accessKeyId: env.LARGE_AWS_ACCESS_KEY_ID!,
secretAccessKey: env.LARGE_AWS_SECRET_ACCESS_KEY!,
service: 's3',
});
try {
const objectmeta = await s3.fetch(`${env.LARGE_ENDPOINT}/${uuid}`, {
method: 'HEAD',
});
if (objectmeta.ok) {
const { headers } = objectmeta;
const file_size = headers.get('Content-Length') || '0';
if (parseInt(file_size) !== descriptor.size) {
return new Response('This paste is not finishing the upload.\n', {
status: 400,
});
}
} else {
return new Response('This paste is not finishing the upload.\n', {
status: 400,
});
}
} catch (err) {
return new Response('Unable to connect to remote.\n', {
status: 500,
});
}
const current = Date.now();
const expriation = new Date(Date.now() + 2419200 * 1000).getTime(); // default 28 days
descriptor.upload_completed = true;
descriptor.last_modified = current;
descriptor.expiration = expriation;
ctx.waitUntil(env.PASTE_INDEX.put(uuid, JSON.stringify(descriptor), { expirationTtl: 2419200 }));
const paste_info = {
uuid,
upload_completed: true,
expired: new Date(expriation).toISOString(),
};
return new Response(JSON.stringify(paste_info), {
status: 400,
});
});
router.get('/:uuid', async (request, env, ctx) => {
const { uuid } = request.params;
// UUID format: [A-z0-9]{UUID_LENGTH}
if (uuid.length !== UUID_LENGTH) {
return new Response('Invalid UUID.\n', {
status: 442,
});
}
const val = await env.PASTE_INDEX.get(uuid);
if (val === null) {
return new Response('Paste not found.\n', {
status: 404,
});
}
const descriptor: PasteIndexEntry = JSON.parse(val);
if (descriptor.type !== 'large_paste') {
return new Response('Invalid operation.\n', {
status: 400,
});
}
if (!descriptor.upload_completed) {
return new Response('This paste is not yet finalized.\n', {
status: 400,
});
}
const signed_url = await get_presign_url(uuid, descriptor, env);
const result = {
uuid,
expire: new Date(descriptor.expiration || 0).toISOString(),
signed_url,
};
return new Response(JSON.stringify(result));
});