From f90633bce3590d65610046c433b331a618d36bac Mon Sep 17 00:00:00 2001 From: Joe Ma Date: Fri, 25 Jul 2025 17:24:07 +0800 Subject: [PATCH] Update itty-router to 5.x Fix minor bug Update package dependencies Signed-off-by: Joe Ma --- frontend/static/paste.js | 2 +- package.json | 30 ++++++------- src/constant.ts | 19 +++++++-- src/index.ts | 92 ++++++++++++++++++++-------------------- src/types.d.ts | 10 +++++ src/utils.ts | 13 +++--- src/v2/large_upload.ts | 44 +++++++++++-------- wrangler.toml | 5 +++ 8 files changed, 127 insertions(+), 88 deletions(-) diff --git a/frontend/static/paste.js b/frontend/static/paste.js index cb63082..f1413af 100644 --- a/frontend/static/paste.js +++ b/frontend/static/paste.js @@ -381,7 +381,7 @@ $(function () { } try { - const res = await fetch(`https://pb.nekoid.cc/${uuid}/settings?${new URLSearchParams({ json: '1' })}`); + const res = await fetch(`${ENDPOINT}/${uuid}/settings?${new URLSearchParams({ json: '1' })}`); if (res.ok) { const paste_info = await res.json(); build_paste_modal(paste_info, show_qrcode, false, true); diff --git a/package.json b/package.json index e37a985..742432e 100644 --- a/package.json +++ b/package.json @@ -9,27 +9,27 @@ "lint": "eslint . --color --cache -f friendly --max-warnings 10" }, "dependencies": { - "aws4fetch": "^1.0.17", + "aws4fetch": "^1.0.20", + "buffer": "^6.0.3", "dedent-js": "^1.0.1", - "itty-router": "^4.0.23", - "js-sha256": "^0.10.1", - "nanoid": "^5.0.2", - "xml-js": "^1.6.11", + "itty-router": "^5.0.18", + "js-sha256": "^0.11.1", + "nanoid": "^5.1.5", "string_decoder": "^1.3.0", - "buffer": "^6.0.3" + "xml-js": "^1.6.11" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20231025.0", - "@types/bootstrap": "^5.2.8", + "@cloudflare/workers-types": "^4.20250725.0", + "@types/bootstrap": "^5.2.10", "@types/crypto-js": "4.2.2", - "@types/jquery": "^3.5.25", + "@types/jquery": "^3.5.32", "@types/xml2js": "^0.4.14", - "eslint": "^8.52.0", - "eslint-config-prettier": "^9.0.0", + "eslint": "^9.31.0", + "eslint-config-prettier": "^10.1.8", "eslint-formatter-friendly": "^7.0.0", - "eslint-plugin-import": "^2.29.0", - "prettier": "^3.0.3", - "typescript": "^5.2.2", - "wrangler": "^3.15.0" + "eslint-plugin-import": "^2.32.0", + "prettier": "^3.6.2", + "typescript": "^5.8.3", + "wrangler": "^4.26.0" } } \ No newline at end of file diff --git a/src/constant.ts b/src/constant.ts index a7bfcc2..2c0962d 100644 --- a/src/constant.ts +++ b/src/constant.ts @@ -1,4 +1,15 @@ -export const SERVICE_URL = 'pb.nekoid.cc'; -export const PASTE_WEB_URL = 'https://raw.githubusercontent.com/rikkaneko/paste/main/frontend'; -export const UUID_LENGTH = 4; -export const CORS_DOMAIN = 'nekoid.cc'; +import { Config, Env } from './types'; + +// @ts-ignore +let CONSTANTS: Config = { + UUID_LENGTH: 4, +}; +export default CONSTANTS; + +// Fetch variable from Env +export const fetch_constant = (env: Env) => { + CONSTANTS = { + ...env, + ...CONSTANTS, + }; +}; diff --git a/src/index.ts b/src/index.ts index 9a50088..ef16e2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ /* * This file is part of paste. - * Copyright (c) 2022-2024 Joe Ma + * Copyright (c) 2022-2025 Joe Ma * * 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 @@ -18,14 +18,34 @@ import { AwsClient } from 'aws4fetch'; import { sha256 } from 'js-sha256'; -import { Router, error } from 'itty-router'; +import { Router, error, cors } from 'itty-router'; import { ERequest, Env, PasteIndexEntry } 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, CORS_DOMAIN } from './constant'; +import constants, { fetch_constant } from './constant'; import { get_presign_url, router as large_upload } from './v2/large_upload'; -const router = Router(); +// In favour of new cors() in itty-router v5 +const { preflight, corsify } = cors({ + origin: (o) => { + if (constants?.CORS_DOMAIN) { + return o?.endsWith(constants.CORS_DOMAIN) ? o : undefined; + } + }, + credentials: true, + allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], +}); + +const router = Router({ + before: [ + preflight, + (_, env) => { + fetch_constant(env); + }, + ], + catch: error, + finally: [corsify], +}); // Shared common properties to all route router.all('*', (request) => { @@ -39,27 +59,10 @@ router.all('*', (request) => { request.origin = headers.get('referer') ?? undefined; }); -// Handle preflighted CORS request -router.options('*', (request) => { - if (!request.origin) return new Response(null); - const url = new URL(request.origin); - // Allow all subdomain of nekoid.cc - if (url.hostname.endsWith(CORS_DOMAIN)) { - return new Response(null, { - status: 204, - headers: { - 'Access-Control-Allow-Origin': url.origin, - 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', - Vary: 'Origin', - }, - }); - } -}); - /* Static file path */ // Web homepage -router.get('/', (request) => { - return serve_static(PASTE_WEB_URL + '/paste.html', request.headers); +router.get('/', (request, env, ctx) => { + return serve_static(env.PASTE_WEB_URL + '/paste.html', request.headers); }); // Favicon @@ -73,11 +76,11 @@ router.get('/favicon.ico', () => { }); // Web script and style file -router.get('/static/*', (request) => { +router.get('/static/*', (request, env, ctx) => { const { url } = request; const { pathname } = new URL(url); const path = pathname.replace(/\/+$/, '') || '/'; - return serve_static(PASTE_WEB_URL + path, request.headers); + return serve_static(env.PASTE_WEB_URL + path, request.headers); }); // Create new paste (10MB limit) @@ -95,7 +98,7 @@ router.post('/', async (request, env, ctx) => { let need_qrcode: boolean = false; let paste_type: string | undefined; let reply_json: boolean = false; - // Content-Type: multipart/form-data (deprecated) + // Content-Type: multipart/form-data if (content_type.includes('multipart/form-data')) { const formdata = await request.formData(); const data: File | string | any = formdata.get('u'); @@ -114,6 +117,7 @@ router.post('/', async (request, env, ctx) => { buffer = await data.arrayBuffer(); // Text } else { + // @ts-ignore buffer = new TextEncoder().encode(data); mime_type = 'text/plain; charset=UTF-8;'; } @@ -158,6 +162,7 @@ router.post('/', async (request, env, ctx) => { reply_json = true; } } else { + // HTTP API title = headers.get('x-paste-title') || undefined; mime_type = headers.get('x-paste-content-type') || undefined; password = headers.get('x-paste-pass') || undefined; @@ -222,6 +227,9 @@ router.post('/', async (request, env, ctx) => { body: buffer, }); + // Default paste type + paste_type = paste_type ? paste_type : 'paste'; + if (paste_type === 'link') { mime_type = 'text/x-uri'; } @@ -256,14 +264,14 @@ router.post('/', async (request, env, ctx) => { }); // Handle large upload (> 25MB) -router.all('/v2/large_upload/*', large_upload.handle); +router.all('/v2/large_upload/*', large_upload.fetch); // 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) { + if (uuid.length !== constants.UUID_LENGTH) { return new Response('Invalid UUID.\n', { status: 442, }); @@ -343,7 +351,12 @@ router.get('/:uuid/:option?', async (request, env, ctx) => { } if (descriptor.size >= 209715200) { - const signed_url = await get_presign_url(uuid, descriptor, env); + const signed_url = await get_presign_url(uuid, descriptor); + if (signed_url == null) { + return new Response('No available download endpoint.\n', { + status: 404, + }); + } ctx.waitUntil( env.PASTE_INDEX.put(uuid, JSON.stringify(descriptor), { @@ -366,7 +379,7 @@ router.get('/:uuid/:option?', async (request, env, ctx) => { 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}`, { + const req_key = new Request(`https://${env.SERVICE_URL}/${uuid}`, { method: 'GET', headers: match_etag ? { @@ -493,7 +506,7 @@ 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) { + if (uuid.length !== constants.UUID_LENGTH) { return new Response('Invalid UUID.\n', { status: 442, }); @@ -557,7 +570,7 @@ router.delete('/:uuid', async (request, env, ctx) => { if (res.ok) { ctx.waitUntil(env.PASTE_INDEX.delete(uuid)); // Invalidate CF cache - ctx.waitUntil(cache.delete(new Request(`https://${SERVICE_URL}/${uuid}`))); + ctx.waitUntil(cache.delete(new Request(`https://${env.SERVICE_URL}/${uuid}`))); return new Response('OK\n'); } else { return new Response('Unable to process such request.\n', { @@ -576,17 +589,6 @@ router.all('*', () => { export default { fetch: (req: ERequest, env: Env, ctx: ExecutionContext) => router - .handle(req, env, ctx) - .catch(error) - // Apply CORS headers - .then((res: Response) => { - if (!req.origin) return res; - const url = new URL(req.origin); - // Allow all subdomain of nekoid.cc - if (url.hostname.endsWith(CORS_DOMAIN)) { - res.headers.set('Access-Control-Allow-Origin', url.origin); - res.headers.set('Vary', 'Origin'); - } - return res; - }), + // Update with itty-router 5.x + .fetch(req, env, ctx), }; diff --git a/src/types.d.ts b/src/types.d.ts index 0031cae..a7c5b6a 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -25,6 +25,12 @@ export interface PasteIndexEntry { } export interface Env { + // Variable + SERVICE_URL: string; + PASTE_WEB_URL?: string; + UUID_LENGTH: string; + CORS_DOMAIN?: string; + // Secret PASTE_INDEX: KVNamespace; QRCODE: ServiceWorkerGlobalScope; AWS_ACCESS_KEY_ID: string; @@ -34,4 +40,8 @@ export interface Env { ENDPOINT: string; LARGE_ENDPOINT?: string; LARGE_DOWNLOAD_ENDPOINT?: string; +} + +export interface Config extends Env { + UUID_LENGTH: number; } \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts index 09c541c..20ce884 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -18,15 +18,18 @@ import dedent from 'dedent-js'; import { customAlphabet } from 'nanoid'; -import { SERVICE_URL, UUID_LENGTH } from './constant'; +import constants from './constant'; import { PasteIndexEntry, Env } from './types'; -export const gen_id = customAlphabet('1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', UUID_LENGTH); +export const gen_id = customAlphabet( + '1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', + constants.UUID_LENGTH +); -export function get_paste_info_obj(uuid: string, descriptor: PasteIndexEntry) { +export function get_paste_info_obj(uuid: string, descriptor: PasteIndexEntry, env: Env) { const created = new Date(descriptor.last_modified); const expired = new Date(descriptor.expiration ?? descriptor.last_modified + 2419200000); - const link = `https://${SERVICE_URL}/${uuid}`; + const link = `https://${env.SERVICE_URL}/${uuid}`; const paste_info = { uuid, link, @@ -53,7 +56,7 @@ export async function get_paste_info( need_qr: boolean = false, reply_json = false ): Promise { - const paste_info = get_paste_info_obj(uuid, descriptor); + const paste_info = get_paste_info_obj(uuid, descriptor, env); // Reply with JSON if (reply_json) { diff --git a/src/v2/large_upload.ts b/src/v2/large_upload.ts index a66c63c..0b2d1b0 100644 --- a/src/v2/large_upload.ts +++ b/src/v2/large_upload.ts @@ -4,11 +4,11 @@ import { AwsClient } from 'aws4fetch'; import { xml2js } from 'xml-js'; import { ERequest, Env, PasteIndexEntry } from '../types'; import { gen_id, get_paste_info_obj } from '../utils'; -import { UUID_LENGTH } from '../constant'; +import constants from '../constant'; export const router = Router({ base: '/v2/large_upload' }); -export async function get_presign_url(uuid: string, descriptor: PasteIndexEntry, env: Env) { +export async function get_presign_url(uuid: string, descriptor: PasteIndexEntry) { // Use cached presigned url if expiration is more than 10 mins if (descriptor.cached_presigned_url) { const expiration = new Date(descriptor.cached_presigned_url_expiration ?? 0); @@ -18,22 +18,28 @@ export async function get_presign_url(uuid: string, descriptor: PasteIndexEntry, } } - const endpoint_url = new URL(`${env.LARGE_DOWNLOAD_ENDPOINT}/${uuid}`); - endpoint_url.searchParams.set('X-Amz-Expires', '14400'); // Valid for 4 hours - endpoint_url.searchParams.set( + const download_url = constants.LARGE_DOWNLOAD_ENDPOINT ?? constants.LARGE_ENDPOINT; + if (download_url == null) { + // Not method to download + return null; + } + + const download_path = new URL(`${download_url}/${uuid}`); + download_path.searchParams.set('X-Amz-Expires', '14400'); // Valid for 4 hours + download_path.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;'); + download_path.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!, + accessKeyId: constants.LARGE_AWS_ACCESS_KEY_ID!, + secretAccessKey: constants.LARGE_AWS_SECRET_ACCESS_KEY!, service: 's3', // required }); - const signed = await s3.sign(endpoint_url, { + const signed = await s3.sign(download_path, { method: 'GET', headers: {}, aws: { @@ -65,6 +71,8 @@ router.post('/create', async (request, env, ctx) => { let read_limit: number | undefined; let file_size: number | undefined; let file_hash: string | undefined; + + // Content-Type: multipart/form-data if (content_type?.includes('multipart/form-data')) { const formdata = await request.formData(); const title = formdata.get('title'); @@ -128,15 +136,15 @@ router.post('/create', async (request, env, ctx) => { 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', '900'); // Valid for 15 mins + const upload_path = new URL(`${env.LARGE_ENDPOINT}/${uuid}`); + upload_path.searchParams.set('X-Amz-Expires', '900'); // Valid for 15 mins const required_headers = { 'Content-Length': file_size.toString(), 'X-Amz-Content-Sha256': file_hash, }; // Generate Presigned Request - const signed = await s3.sign(endpoint_url, { + const signed = await s3.sign(upload_path, { method: 'PUT', headers: required_headers, aws: { @@ -180,7 +188,7 @@ 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) { + if (uuid.length !== constants.UUID_LENGTH) { return new Response('Invalid UUID.\n', { status: 442, }); @@ -208,7 +216,7 @@ router.post('/complete/:uuid', async (request, env, ctx) => { try { // Get object attributes - const objectmeta = await s3.fetch(`${env.LARGE_DOWNLOAD_ENDPOINT}/${uuid}?attributes`, { + const objectmeta = await s3.fetch(`${env.LARGE_ENDPOINT}/${uuid}?attributes`, { method: 'GET', headers: { 'X-AMZ-Object-Attributes': 'ObjectSize', @@ -224,7 +232,7 @@ router.post('/complete/:uuid', async (request, env, ctx) => { }); const file_size: number = parsed.getobjectattributesresponse.objectsize._text; if (file_size !== descriptor.size) { - return new Response('This paste is not finishing the upload.\n', { + return new Response(`This paste is not finishing upload. (${file_size} != ${descriptor.size})\n`, { status: 400, }); } @@ -249,7 +257,7 @@ router.post('/complete/:uuid', async (request, env, ctx) => { const paste_info = { upload_completed: true, expired: new Date(expriation).toISOString(), - paste_info: get_paste_info_obj(uuid, descriptor), + paste_info: get_paste_info_obj(uuid, descriptor, env), }; return new Response(JSON.stringify(paste_info)); @@ -258,7 +266,7 @@ router.post('/complete/:uuid', async (request, env, ctx) => { router.get('/:uuid', async (request, env, ctx) => { const { uuid } = request.params; // UUID format: [A-z0-9]{UUID_LENGTH} - if (uuid.length !== UUID_LENGTH) { + if (uuid.length !== constants.UUID_LENGTH) { return new Response('Invalid UUID.\n', { status: 442, }); @@ -284,7 +292,7 @@ router.get('/:uuid', async (request, env, ctx) => { }); } - const signed_url = await get_presign_url(uuid, descriptor, env); + const signed_url = await get_presign_url(uuid, descriptor); const result = { uuid, expire: new Date(descriptor.expiration || 0).toISOString(), diff --git a/wrangler.toml b/wrangler.toml index 21c8724..18a456d 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -9,6 +9,11 @@ services = [ { binding = "QRCODE", service = "qrcode-gen", environment = "production" } ] +[vars] +SERVICE_URL = "pb.nekoid.cc" +PASTE_WEB_URL = "https://raw.githubusercontent.com/rikkaneko/paste/main/frontend" +CORS_DOMAIN = "nekoid.cc" + # [secret] # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY