Update itty-router to 5.x

Fix minor bug

Update package dependencies

Signed-off-by: Joe Ma <rikkaneko23@gmail.com>
This commit is contained in:
Joe Ma 2025-07-25 17:24:07 +08:00
parent 1fb3076115
commit f90633bce3
No known key found for this signature in database
GPG key ID: 7A0ECF5F5EDC587F
8 changed files with 127 additions and 88 deletions

View file

@ -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);

View file

@ -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"
}
}

View file

@ -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,
};
};

View file

@ -1,6 +1,6 @@
/*
* This file is part of paste.
* Copyright (c) 2022-2024 Joe Ma <rikkaneko23@gmail.com>
* Copyright (c) 2022-2025 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
@ -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<ERequest, [Env, ExecutionContext]>();
// 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<ERequest, [Env, ExecutionContext]>({
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),
};

10
src/types.d.ts vendored
View file

@ -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;
}

View file

@ -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<Response> {
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) {

View file

@ -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<ERequest, [Env, ExecutionContext]>({ 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(),

View file

@ -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