paste/src/index.ts
Joe Ma 3de4fa14ed
Refactor the whole project
Use itty-router over manual routing

Extract resuable code into modules

Update copyright notice

Remove path to paste v1 homepage

Signed-off-by: Joe Ma <rikkaneko23@gmail.com>
2023-11-19 02:19:27 +08:00

492 lines
14 KiB
TypeScript

/*
* This file is part of paste.
* Copyright (c) 2022-2024 Joe Ma <rikkaneko23@gmail.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { AwsClient } from 'aws4fetch';
import { customAlphabet } from 'nanoid';
import { sha256 } from 'js-sha256';
import { Router, error } from 'itty-router';
import { ERequest } from './types';
import { Env, PasteIndexEntry } from './types';
import { serve_static } from './proxy';
import { check_password_rules, get_paste_info, get_basic_auth } from './utils';
import { UUID_LENGTH, PASTE_WEB_URL, SERVICE_URL } from './constant';
const gen_id = customAlphabet('1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', UUID_LENGTH);
const router = Router<ERequest, [Env, ExecutionContext]>();
// Shared common properties to all route
router.all('*', (request) => {
// Detect if request from browsers
const agent = request.headers.get('user-agent') ?? '';
request.is_browser = ['Chrome', 'Mozilla', 'AppleWebKit', 'Safari', 'Gecko', 'Chromium'].some((v) =>
agent.includes(v)
);
});
/* Static file path */
// Web homepage
router.get('/', (request) => {
return serve_static(PASTE_WEB_URL + '/paste.html', request.headers);
});
// Favicon
router.get('/favicon.ico', () => {
return new Response(null, {
headers: {
'cache-control': 'public, max-age=172800',
},
status: 404,
});
});
// Web script and style file
router.get('/(css|js)/*', (request) => {
const { url } = request;
const { pathname } = new URL(url);
const path = pathname.replace(/\/+$/, '') || '/';
return serve_static(PASTE_WEB_URL + path, request.headers);
});
// Create new paste (10MB limit)
router.post('/', async (request, env, ctx) => {
const { headers } = request;
const uuid = gen_id();
let buffer: ArrayBuffer;
let title: string | undefined;
// Handle content-type
const content_type = headers.get('content-type') || '';
let mime_type: string | undefined;
let password: string | undefined;
let read_limit: number | undefined;
let need_qrcode: boolean = false;
let paste_type: string | undefined;
let reply_json: boolean = false;
// Content-Type: multipart/form-data (deprecated)
if (content_type.includes('multipart/form-data')) {
const formdata = await request.formData();
const data: File | string | any = formdata.get('u');
const type = formdata.get('paste-type');
const file_title = formdata.get('title');
if (data === null) {
return new Response('Invalid request.\n', {
status: 422,
});
}
// File
if (data instanceof File) {
title = data.name || undefined;
mime_type = data.type || undefined;
buffer = await data.arrayBuffer();
// Text
} else {
buffer = new TextEncoder().encode(data);
mime_type = 'text/plain; charset=UTF-8;';
}
if (typeof file_title === 'string') title = file_title;
if (typeof type === 'string') paste_type = type;
// Set password
const pass = formdata.get('pass');
if (typeof pass === 'string') {
password = pass || undefined;
}
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;
}
// Check if qrcode generation needed
const qr = formdata.get('qrcode');
if (typeof qr === 'string' && qr === '1') {
need_qrcode = true;
}
// Check reply format
const json = formdata.get('json');
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;
password = headers.get('x-paste-pass') || undefined;
paste_type = headers.get('x-paste-type') || undefined;
need_qrcode = headers.get('x-paste-qr') === '1';
reply_json = headers.get('x-json') === '1';
const count = headers.get('x-paste-read-limit') || '';
const n = parseInt(count);
if (isNaN(n) || n <= 0) {
return new Response('x-paste-read-limit must be a positive integer.\n', {
status: 422,
});
}
read_limit = n;
buffer = await request.arrayBuffer();
}
// Check if qrcode generation needed
if (request.query?.qr === '1') {
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', {
status: 422,
});
// Check password rules
if (password && !check_password_rules(password)) {
return new Response(
'Invalid password. ' + 'Password must contain alphabets and digits only, and has a length of 4 or more.',
{
status: 422,
}
);
}
// Check request.body size <= 25MB
const size = buffer.byteLength;
if (size > 26214400) {
return new Response('Paste size must be under 25MB.\n', {
status: 422,
});
}
// Check request.body size not empty
if (buffer.byteLength == 0) {
return new Response('Paste cannot be empty.\n', {
status: 422,
});
}
const s3 = new AwsClient({
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
});
const res = await s3.fetch(`${env.ENDPOINT}/${uuid}`, {
method: 'PUT',
body: buffer,
});
if (res.ok) {
// Upload success
const descriptor: PasteIndexEntry = {
title: title || undefined,
last_modified: Date.now(),
password: password ? sha256(password).slice(0, 16) : undefined,
read_count_remain: read_limit ?? undefined,
mime_type: mime_type || undefined,
type: paste_type,
size,
};
// Key will be expired after 28 day if unmodified
ctx.waitUntil(env.PASTE_INDEX.put(uuid, JSON.stringify(descriptor), { expirationTtl: 2419200 }));
return await get_paste_info(uuid, descriptor, env, request.is_browser, need_qrcode, reply_json);
} else {
return new Response('Unable to upload the paste.\n', {
status: 500,
});
}
});
// Fetch paste by uuid [4-digit UUID]
router.get('/:uuid/:option?', async (request, env, ctx) => {
const { headers } = request;
const { uuid, option } = 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);
// Handling /<uuid>/settings
if (option === 'settings') {
const need_qrcode = request.query?.qr === '1' || headers.get('x-qr') === '1';
const reply_json = request.query?.json === '1' || headers.get('x-json') === '1';
return await get_paste_info(uuid, descriptor, env, request.is_browser, need_qrcode, reply_json);
}
// Check password if needed
if (descriptor.password !== undefined) {
if (headers.has('Authorization')) {
let cert = get_basic_auth(headers);
// Error occurred when parsing the header
if (cert === null) {
return new Response('Invalid Authorization header.', {
status: 400,
});
}
// Check password and username should be empty
if (cert[0].length != 0 || descriptor.password !== sha256(cert[1]).slice(0, 16)) {
return new Response('Incorrect password.\n', {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="Requires password"',
},
});
}
// x-pass header
} else if (headers.has('x-pass')) {
if (descriptor.password !== sha256(headers.get('x-pass')!).slice(0, 16)) {
return new Response('Incorrect password.\n');
}
} else {
return new Response('This paste requires password.\n', {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="Requires password"',
},
});
}
}
// Check if access_count_remain entry present
if (descriptor.read_count_remain !== undefined) {
if (descriptor.read_count_remain <= 0) {
return new Response('Paste expired.\n', {
status: 410,
});
}
descriptor.read_count_remain--;
ctx.waitUntil(
env.PASTE_INDEX.put(uuid, JSON.stringify(descriptor), {
expiration: descriptor.last_modified / 1000 + 2419200,
})
);
}
// Enable CF cache for authorized request
// Match in existing cache
const cache = caches.default;
const match_etag = headers.get('If-None-Match') || undefined;
// Define the Request object as cache key
const req_key = new Request(`https://${SERVICE_URL}/${uuid}`, {
method: 'GET',
headers: match_etag
? {
// ETag to cache file
'if-none-match': match_etag,
}
: undefined,
});
let res = await cache.match(req_key);
if (res === undefined) {
const s3 = new AwsClient({
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
});
// Fetch form origin if not hit cache
let origin = await s3.fetch(`${env.ENDPOINT}/${uuid}`, {
method: 'GET',
headers: match_etag
? {
'if-none-match': match_etag,
}
: undefined,
});
// Reserve ETag header
res = new Response(origin.body, { status: origin.status });
const etag = origin.headers.get('etag');
if (etag) res.headers.append('etag', etag);
if (res.status == 404) {
// UUID exists in index but not found in remote object storage service, probably expired
// Remove expired key
ctx.waitUntil(env.PASTE_INDEX.delete(uuid));
// Invalidate CF cache
ctx.waitUntil(cache.delete(req_key));
return new Response('Paste expired.\n', {
status: 410,
});
} else if (!res.ok && res.status !== 304) {
// Other error
return new Response('Internal server error.\n', {
status: 500,
});
}
res.headers.set('cache-control', 'public, max-age=18000');
res.headers.set('content-disposition', `inline; filename="${encodeURIComponent(descriptor.title ?? uuid)}"`);
if (descriptor.mime_type) res.headers.set('content-type', descriptor.mime_type);
// Let the browser guess the content
else res.headers.delete('content-type');
// 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)}"`);
// Link redirection
else if (descriptor.type === 'link' || option === 'link') {
const content = await res.clone().arrayBuffer();
try {
const href = new TextDecoder().decode(content);
new URL(href);
res.headers.set('location', href);
res = new Response(res.body, {
status: 301,
headers: {
location: href,
...Object.entries(res.headers),
},
});
} catch (err) {
if (err instanceof TypeError) {
res = new Response('Invalid URL.', {
status: 422,
});
}
}
}
// res.body cannot be read twice
// Do not block when writing to cache
if (res.ok) ctx.waitUntil(cache.put(req_key, res.clone()));
return res;
}
// Cache hit
// Matched Etag, no body
if (res.status == 304) return res;
let { readable, writable } = new TransformStream();
res.body?.pipeTo(writable);
return new Response(readable, res);
});
// Update paste metadata
router.post('/:uuid/:options', () => {
// TODO Implement paste setting update
return new Response('Service is under maintainance.\n', {
status: 422,
});
});
// Delete paste by uuid
router.delete('/: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.editable !== undefined && !descriptor.editable) {
return new Response('This paste is immutable.\n', {
status: 405,
});
}
// Check password if needed
if (descriptor.password !== undefined) {
if (headers.has('x-pass')) {
const pass = headers.get('x-pass');
if (descriptor.password !== sha256(pass!).slice(0, 16)) {
return new Response('Incorrect password.\n', {
status: 403,
});
}
} else {
return new Response('This operation requires password.\n', {
status: 401,
});
}
}
const cache = caches.default;
const s3 = new AwsClient({
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
});
let res = await s3.fetch(`${env.ENDPOINT}/${uuid}`, {
method: 'DELETE',
});
if (res.ok) {
ctx.waitUntil(env.PASTE_INDEX.delete(uuid));
// Invalidate CF cache
ctx.waitUntil(cache.delete(new Request(`https://${SERVICE_URL}/${uuid}`)));
return new Response('OK\n');
} else {
return new Response('Unable to process such request.\n', {
status: 500,
});
}
});
// Fallback route
router.all('*', () => {
return new Response('Invalid path.\n', {
status: 403,
});
});
export default {
fetch: (req: Request, env: Env, ctx: ExecutionContext) => router.handle(req, env, ctx).catch(error),
};