mirror of
https://github.com/rikkaneko/paste.git
synced 2025-08-09 15:35:35 +01:00
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:
parent
8623fb74d9
commit
76e8d074dc
8 changed files with 550 additions and 227 deletions
96
src/index.ts
96
src/index.ts
|
@ -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
9
src/types.d.ts
vendored
|
@ -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;
|
||||
}
|
|
@ -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
275
src/v2/large_upload.ts
Normal 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));
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue