diff --git a/frontend/paste.html b/frontend/paste.html index 196e579..6e03265 100644 --- a/frontend/paste.html +++ b/frontend/paste.html @@ -242,8 +242,8 @@ - - Read limit - - + Account Times + - / - Created diff --git a/package.json b/package.json index 742432e..666f0a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paste", - "version": "2.0", + "version": "2.1", "license": "LGPL-3.0-or-later", "scripts": { "dev": "wrangler dev", @@ -9,6 +9,7 @@ "lint": "eslint . --color --cache -f friendly --max-warnings 10" }, "dependencies": { + "@cesium133/forgjs": "^2.2.0", "aws4fetch": "^1.0.20", "buffer": "^6.0.3", "dedent-js": "^1.0.1", diff --git a/src/api/large_upload.ts b/src/api/large_upload.ts index c6dadef..45d7b07 100644 --- a/src/api/large_upload.ts +++ b/src/api/large_upload.ts @@ -2,9 +2,10 @@ import { Router } from 'itty-router'; import { sha256 } from 'js-sha256'; import { AwsClient } from 'aws4fetch'; import { xml2js } from 'xml-js'; -import { ERequest, Env, PasteIndexEntry } from '../types'; +import { ERequest, Env } from '../types'; import { gen_id, get_paste_info_obj } from '../utils'; import constants from '../constant'; +import { PasteIndexEntry, PasteType } from '../v2/schema'; export const router = Router({ base: '/api/large_upload' }); @@ -164,15 +165,19 @@ router.post('/create', async (request, env, ctx) => { }; const descriptor: PasteIndexEntry = { + uuid, title: file_title || undefined, mime_type: file_mime || undefined, - last_modified: current, - expiration: new Date(Date.now() + 900 * 1000).getTime(), + created_at: current, + expired_at: new Date(Date.now() + 900 * 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, + access_n: 0, + max_access_n: read_limit ?? undefined, + paste_type: PasteType.large_paste, + file_size: file_size, + upload_track: { + pending_upload: true, + }, }; ctx.waitUntil( @@ -202,7 +207,7 @@ router.post('/complete/:uuid', async (request, env, ctx) => { } const descriptor: PasteIndexEntry = JSON.parse(val); - if (descriptor.type !== 'large_paste' || descriptor.upload_completed) { + if (descriptor.paste_type == PasteType.large_paste || !descriptor.upload_track?.pending_upload) { return new Response('Invalid operation.\n', { status: 442, }); @@ -231,8 +236,8 @@ router.post('/complete/:uuid', async (request, env, ctx) => { elementNameFn: (val) => val.toLowerCase(), }); const file_size: number = parsed.getobjectattributesresponse.objectsize._text; - if (file_size !== descriptor.size) { - return new Response(`This paste is not finishing upload. (${file_size} != ${descriptor.size})\n`, { + if (file_size !== descriptor.file_size) { + return new Response(`This paste is not finishing upload. (${file_size} != ${descriptor.file_size})\n`, { status: 400, }); } @@ -249,9 +254,10 @@ router.post('/complete/:uuid', async (request, env, ctx) => { 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; + // Remove unneeded propty + delete descriptor.upload_track; + descriptor.created_at = current; + descriptor.expired_at = expriation; ctx.waitUntil(env.PASTE_INDEX.put(uuid, JSON.stringify(descriptor), { expirationTtl: 2419200 })); const paste_info = { @@ -280,13 +286,13 @@ router.get('/:uuid', async (request, env, ctx) => { } const descriptor: PasteIndexEntry = JSON.parse(val); - if (descriptor.type !== 'large_paste') { + if (descriptor.paste_type == PasteType.large_paste) { return new Response('Invalid operation.\n', { status: 400, }); } - if (!descriptor.upload_completed) { + if (!descriptor.upload_track?.pending_upload) { return new Response('This paste is not yet finalized.\n', { status: 400, }); @@ -295,7 +301,7 @@ router.get('/:uuid', async (request, env, ctx) => { const signed_url = await get_presign_url(uuid, descriptor); const result = { uuid, - expire: new Date(descriptor.expiration || 0).toISOString(), + expire: new Date(descriptor.expired_at).toISOString(), signed_url, }; diff --git a/src/constant.ts b/src/constant.ts index 2c0962d..6e8f3f0 100644 --- a/src/constant.ts +++ b/src/constant.ts @@ -3,11 +3,15 @@ import { Config, Env } from './types'; // @ts-ignore let CONSTANTS: Config = { UUID_LENGTH: 4, + enable_large_upload: false, }; export default CONSTANTS; // Fetch variable from Env export const fetch_constant = (env: Env) => { + if (env.LARGE_AWS_ACCESS_KEY_ID && env.LARGE_AWS_SECRET_ACCESS_KEY && env.LARGE_DOWNLOAD_ENDPOINT) { + CONSTANTS.enable_large_upload = true; + } CONSTANTS = { ...env, ...CONSTANTS, diff --git a/src/index.ts b/src/index.ts index 44315de..4fb9acc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,11 +19,13 @@ import { AwsClient } from 'aws4fetch'; import { sha256 } from 'js-sha256'; import { Router, error, cors } from 'itty-router'; -import { ERequest, Env, PasteIndexEntry } from './types'; +import { ERequest, Env } from './types'; import { serve_static } from './proxy'; import { check_password_rules, get_paste_info, get_basic_auth, gen_id } from './utils'; import constants, { fetch_constant } from './constant'; import { get_presign_url, router as large_upload } from './api/large_upload'; +import v2api from './v2/api'; +import { PasteIndexEntry, PasteTypeStr, PasteTypeFrom, PasteType } from './v2/schema'; // In favour of new cors() in itty-router v5 const { preflight, corsify } = cors({ @@ -44,7 +46,18 @@ const router = Router({ preflight, ], catch: error, - finally: [corsify], + finally: [ + (res: Response) => { + if (res.headers.has('server')) return res; + return corsify( + new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: new Headers(res.headers), + }) + ); + }, + ], }); // Shared common properties to all route @@ -165,7 +178,7 @@ router.post('/', async (request, env, ctx) => { // 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; + password = headers.get('x-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'; @@ -243,14 +256,20 @@ router.post('/', async (request, env, ctx) => { if (res.ok) { // Upload success + const current_time = Date.now(); + // Temporary expiration time + const expiration = new Date(Date.now() + 2419200 * 1000).getTime(); // default 28 days const descriptor: PasteIndexEntry = { + uuid, title: title || undefined, - last_modified: Date.now(), password: password ? sha256(password).slice(0, 16) : undefined, - read_count_remain: read_limit ?? undefined, + access_n: 0, + max_access_n: read_limit ?? undefined, mime_type: mime_type || undefined, - type: paste_type, - size, + paste_type: PasteTypeFrom(paste_type), + file_size: size, + created_at: current_time, + expired_at: expiration, }; // Key will be expired after 28 day if unmodified @@ -266,6 +285,9 @@ router.post('/', async (request, env, ctx) => { // Handle large upload (> 25MB) router.all('/api/large_upload/*', large_upload.fetch); +/* New Paste v2 RESTful API */ +router.all('/v2/*', v2api.fetch); + // Fetch paste by uuid [4-digit UUID] router.get('/:uuid/:option?', async (request, env, ctx) => { const { headers } = request; @@ -326,31 +348,32 @@ router.get('/:uuid/:option?', async (request, env, ctx) => { } // Check if access_count_remain entry present - if (descriptor.read_count_remain !== undefined) { - if (descriptor.read_count_remain <= 0) { + if (descriptor.max_access_n !== undefined) { + if (descriptor.access_n > descriptor.max_access_n) { 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, - }) - ); } + descriptor.access_n++; + ctx.waitUntil( + env.PASTE_INDEX.put(uuid, JSON.stringify(descriptor), { + expiration: descriptor.expired_at / 1000, + }) + ); + // New added in 2.0 // Handle large_paste // Use presigned url generation only if the file size larger than 200MB, use request forwarding instead - if (descriptor.type === 'large_paste') { - if (!descriptor.upload_completed) { + if (descriptor.paste_type === PasteType.large_paste) { + if (descriptor.upload_track?.pending_upload) { return new Response('This paste is not yet finalized.\n', { status: 400, }); } - if (descriptor.size >= 209715200) { + if (descriptor.file_size >= 209715200) { const signed_url = await get_presign_url(uuid, descriptor); if (signed_url == null) { return new Response('No available download endpoint.\n', { @@ -360,7 +383,7 @@ router.get('/:uuid/:option?', async (request, env, ctx) => { ctx.waitUntil( env.PASTE_INDEX.put(uuid, JSON.stringify(descriptor), { - expiration: descriptor.expiration! / 1000, + expiration: descriptor.expired_at / 1000, }) ); @@ -392,10 +415,11 @@ router.get('/:uuid/:option?', async (request, env, ctx) => { let res = await cache.match(req_key); if (res === undefined) { // Use althernative endpoint and credentials for large_type - const endpoint = descriptor.type === 'large_paste' ? env.LARGE_DOWNLOAD_ENDPOINT : env.ENDPOINT; - const access_key_id = descriptor.type === 'large_paste' ? env.LARGE_AWS_ACCESS_KEY_ID! : env.AWS_ACCESS_KEY_ID; + const endpoint = descriptor.paste_type == PasteType.large_paste ? env.LARGE_DOWNLOAD_ENDPOINT : env.ENDPOINT; + const access_key_id = + descriptor.paste_type == PasteType.large_paste ? env.LARGE_AWS_ACCESS_KEY_ID! : env.AWS_ACCESS_KEY_ID; const secret_access_key = - descriptor.type === 'large_paste' ? env.LARGE_AWS_SECRET_ACCESS_KEY! : env.AWS_SECRET_ACCESS_KEY; + descriptor.paste_type == PasteType.large_paste ? env.LARGE_AWS_SECRET_ACCESS_KEY! : env.AWS_SECRET_ACCESS_KEY; const s3 = new AwsClient({ accessKeyId: access_key_id, @@ -414,9 +438,16 @@ router.get('/:uuid/:option?', async (request, env, ctx) => { }); // Reserve ETag header - res = new Response(origin.body, { status: origin.status }); const etag = origin.headers.get('etag'); - if (etag) res.headers.append('etag', etag); + res = new Response(origin.body, { + status: origin.status, + headers: + etag !== null + ? { + etag, + } + : undefined, + }); if (res.status == 404) { // UUID exists in index but not found in remote object storage service, probably expired @@ -442,7 +473,7 @@ router.get('/:uuid/:option?', async (request, env, ctx) => { else res.headers.delete('content-type'); // Link redirection - if (descriptor.type === 'link') { + if (descriptor.paste_type == PasteType.link) { const content = await res.clone().arrayBuffer(); try { const href = new TextDecoder().decode(content); @@ -519,12 +550,6 @@ router.delete('/:uuid', async (request, env, ctx) => { } 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')) { @@ -543,7 +568,7 @@ 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 (descriptor.paste_type == PasteType.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, @@ -552,10 +577,11 @@ router.delete('/:uuid', async (request, env, ctx) => { } // Use althernative endpoint and credentials for large_type - const endpoint = descriptor.type === 'large_paste' ? env.LARGE_DOWNLOAD_ENDPOINT : env.ENDPOINT; - const access_key_id = descriptor.type === 'large_paste' ? env.LARGE_AWS_ACCESS_KEY_ID! : env.AWS_ACCESS_KEY_ID; + const endpoint = descriptor.paste_type == PasteType.large_paste ? env.LARGE_DOWNLOAD_ENDPOINT : env.ENDPOINT; + const access_key_id = + descriptor.paste_type == PasteType.large_paste ? env.LARGE_AWS_ACCESS_KEY_ID! : env.AWS_ACCESS_KEY_ID; const secret_access_key = - descriptor.type === 'large_paste' ? env.LARGE_AWS_SECRET_ACCESS_KEY! : env.AWS_SECRET_ACCESS_KEY; + descriptor.paste_type == PasteType.large_paste ? env.LARGE_AWS_SECRET_ACCESS_KEY! : env.AWS_SECRET_ACCESS_KEY; const s3 = new AwsClient({ accessKeyId: access_key_id, diff --git a/src/types.d.ts b/src/types.d.ts index a7c5b6a..469bbc8 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -8,15 +8,17 @@ export type ERequest = { export type PASTE_TYPES = 'paste' | 'text' | 'link' | 'large_paste'; +// Deprecated export interface PasteIndexEntry { title?: string; mime_type?: string; last_modified: number; expiration?: number; // New added in 2.0 - size: number; + file_size: number; password?: string; editable?: boolean; // Default: False (unsupported) - read_count_remain?: number; + access_n: number; + max_access_n?: number; type: PASTE_TYPES; // Only apply when large_paste upload_completed?: boolean; @@ -44,4 +46,5 @@ export interface Env { export interface Config extends Env { UUID_LENGTH: number; + enable_large_upload: boolean; } \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts index 432b5e8..11d5736 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -19,31 +19,33 @@ import dedent from 'dedent-js'; import { customAlphabet } from 'nanoid'; import constants from './constant'; -import { PasteIndexEntry, Env } from './types'; +import { Env } from './types'; +import { PasteIndexEntry, PasteTypeStr } from './v2/schema'; export const gen_id = customAlphabet( '1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', constants.UUID_LENGTH ); +// Paste API Response (v1) 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 created = new Date(descriptor.created_at); + const expired = new Date(descriptor.expired_at); const link = `${env.SERVICE_URL}/${uuid}`; const paste_info = { uuid, link, link_qr: 'https://qrcode.nekoid.cc/?' + new URLSearchParams({ q: link, type: 'svg' }), - type: descriptor.type ?? 'paste', + type: PasteTypeStr(descriptor.paste_type), title: descriptor.title?.trim(), mime_type: descriptor.mime_type, - human_readable_size: `${to_human_readable_size(descriptor.size)}`, - size: descriptor.size, + human_readable_size: `${to_human_readable_size(descriptor.file_size)}`, + size: descriptor.file_size, password: !!descriptor.password, - read_count_remain: descriptor.read_count_remain, + access_n: descriptor.access_n, + max_access_n: descriptor.max_access_n, created: created.toISOString(), expired: expired.toISOString(), - update_completed: descriptor.upload_completed ?? undefined, // only for large_paste }; return paste_info; } @@ -77,13 +79,14 @@ export async function get_paste_info( mime-type: ${paste_info.mime_type ?? '-'} size: ${paste_info.size} bytes (${paste_info.human_readable_size}) password: ${paste_info.password} - remaining read count: ${ - paste_info.read_count_remain !== undefined - ? paste_info.read_count_remain - ? paste_info.read_count_remain - : `0 (expired)` - : '-' + access times: ${ + paste_info.max_access_n !== undefined + ? paste_info.max_access_n - paste_info.access_n > 0 + ? `${paste_info.access_n} / ${paste_info.max_access_n}` + : `${paste_info.access_n} / ${paste_info.max_access_n} (expired)` + : paste_info.access_n } + max_access_n: ${paste_info.max_access_n ?? '-'} created at ${paste_info.created} expired at ${paste_info.expired} `; diff --git a/src/v2/api.ts b/src/v2/api.ts new file mode 100644 index 0000000..6fdf8aa --- /dev/null +++ b/src/v2/api.ts @@ -0,0 +1,227 @@ +import { Router } from 'itty-router/Router'; +import { Env, ERequest } from '../types'; +import constants from '../constant'; +import { + PasteAPIRepsonse, + PasteCreateParams, + PasteCreateParamsValidator, + PasteInfo, + PasteIndexEntry, + PasteCreateUploadResponse, + PasteType, +} from './schema'; +import { gen_id } from '../utils'; +import { AwsClient } from 'aws4fetch'; +import { sha256 } from 'js-sha256'; +import { xml2js } from 'xml-js'; + +/* RESTful API (v2) */ +export const router = Router({ base: '/v2' }); + +/* GET /info/:uuid + * + * Response: + * | + */ +router.get('/info/:uuid', async (req, env, ctx) => { + const { uuid } = req.params; + if (uuid.length !== constants.UUID_LENGTH) { + new PasteAPIRepsonse(); + return PasteAPIRepsonse.build(442, 'Invalid UUID.'); + } + const val = await env.PASTE_INDEX.get(uuid); + if (val === null) { + return PasteAPIRepsonse.build(404, 'Paste not found.'); + } + const descriptor: PasteIndexEntry = JSON.parse(val); + return PasteAPIRepsonse.info(descriptor); +}); + +/* POST /create + * Body: + * + * Response: + * | + */ +router.post('/create', async (req, env, ctx) => { + let params: PasteCreateParams | undefined; + try { + const _params: PasteCreateParams = await req.json(); + if (!PasteCreateParamsValidator.test(_params)) { + return PasteAPIRepsonse.build(400, 'Invalid request fields.'); + } + params = _params; + } catch (e) { + return PasteAPIRepsonse.build(400, 'Invalid request.'); + } + + // Create paste logic + if (params.file_size > 262144000) { + return PasteAPIRepsonse.build(422, 'Paste size must be under 250MB.\n'); + } + + const uuid = gen_id(); + + const s3 = new AwsClient({ + accessKeyId: env.LARGE_AWS_ACCESS_KEY_ID!, + secretAccessKey: env.LARGE_AWS_SECRET_ACCESS_KEY!, + service: 's3', // required + }); + + const current_time = Date.now(); + // Temporary expiration time + const expiration = new Date(current_time + 900 * 1000).getTime(); + const upload_path = new URL(`${env.LARGE_ENDPOINT}/${uuid}`); + upload_path.searchParams.set('X-Amz-Expires', '900'); // Valid for 15 mins + const request_headers = { + 'Content-Length': params.file_size.toString(), + 'X-Amz-Content-Sha256': params.file_hash, + }; + + // Generate Presigned Request + const signed = await s3.sign(upload_path, { + method: 'PUT', + headers: request_headers, + aws: { + signQuery: true, + service: 's3', + allHeaders: true, + }, + }); + + const result: PasteCreateUploadResponse = { + uuid, + expiration, + upload_url: signed.url, + request_headers, + }; + + const descriptor: PasteIndexEntry = { + uuid, + title: params.title || undefined, + mime_type: params.mime_type || undefined, + password: params.password ? sha256(params.password).slice(0, 16) : undefined, + access_n: 0, + max_access_n: params.max_access_n, + paste_type: PasteType.large_paste, + file_size: params.file_size, + created_at: current_time, + expired_at: expiration, + upload_track: { + pending_upload: true, + saved_expired_at: params.expired_at, + }, + }; + + ctx.waitUntil( + env.PASTE_INDEX.put(uuid, JSON.stringify(descriptor), { + expirationTtl: 14400, + }) + ); + + return new Response(JSON.stringify(result)); +}); + +/* POST /complete/:uuid + * + * Response: + * | + */ +router.post('/complete/:uuid', async (req, env, ctx) => { + const { uuid } = req.params; + if (uuid.length !== constants.UUID_LENGTH) { + new PasteAPIRepsonse(); + return PasteAPIRepsonse.build(442, 'Invalid UUID.'); + } + + // Complete uploaded paste logic + const val = await env.PASTE_INDEX.get(uuid); + if (val === null) { + return PasteAPIRepsonse.build(404, 'Paste not found.'); + } + + const descriptor: PasteIndexEntry = JSON.parse(val); + if (descriptor.paste_type !== PasteType.large_paste || !descriptor.upload_track?.pending_upload) { + return PasteAPIRepsonse.build(442, 'Invalid operation.'); + } + + const s3 = new AwsClient({ + accessKeyId: env.LARGE_AWS_ACCESS_KEY_ID!, + secretAccessKey: env.LARGE_AWS_SECRET_ACCESS_KEY!, + service: 's3', // required + }); + + try { + // Get object attributes + const objectmeta = await s3.fetch(`${env.LARGE_ENDPOINT}/${uuid}?attributes`, { + method: 'GET', + headers: { + 'X-AMZ-Object-Attributes': 'ObjectSize', + }, + }); + if (objectmeta.ok) { + const xml = await objectmeta.text(); + const parsed: any = xml2js(xml, { + compact: true, + nativeType: true, + alwaysArray: false, + elementNameFn: (val) => val.toLowerCase(), + }); + const file_size: number = parsed.getobjectattributesresponse.objectsize._text; + if (file_size !== descriptor.file_size) { + return PasteAPIRepsonse.build( + 400, + `This paste is not finishing upload. (${file_size} != ${descriptor.file_size})\n` + ); + } + } else { + return PasteAPIRepsonse.build(400, 'Unable to query paste status from remote server.'); + } + } catch (err) { + return PasteAPIRepsonse.build(500, 'Internal server error.'); + } + + descriptor.expired_at = descriptor.expired_at = + descriptor.upload_track?.saved_expired_at ?? new Date(descriptor.created_at + 2419200 * 1000).getTime(); // default 28 days; + // Remove unneeded propty + delete descriptor.upload_track; + ctx.waitUntil(env.PASTE_INDEX.put(uuid, JSON.stringify(descriptor), { expirationTtl: 2419200 })); + + return PasteAPIRepsonse.info(descriptor); +}); + +/* DELETE /:uuid + * Header: Authorization: Basic + * + * Response: + * | + */ +router.delete('/:uuid', async (req, env, ctx) => { + const { uuid } = req.params; + if (uuid.length !== constants.UUID_LENGTH) { + new PasteAPIRepsonse(); + return PasteAPIRepsonse.build(442, 'Invalid UUID.'); + } + + // Delete paste logic + const val = await env.PASTE_INDEX.get(uuid); + if (val === null) { + return PasteAPIRepsonse.build(404, 'Paste not found.'); + } + + // TODO Delete paste logic + return PasteAPIRepsonse.build(200, 'This endpoint is not ready.'); +}); + +/* POST /upload/:uuid?code= + * Body: + * + * Response: + * | + */ +router.post('/upload', async (req, env, ctx) => { + // TODO Upload paste logic + return PasteAPIRepsonse.build(200, 'This endpoint is not ready.'); +}); + +export default router; diff --git a/src/v2/schema.ts b/src/v2/schema.ts new file mode 100644 index 0000000..844c8ea --- /dev/null +++ b/src/v2/schema.ts @@ -0,0 +1,142 @@ +import { Validator, Rule } from '@cesium133/forgjs'; + +export enum PasteType { + paste = 1, + link = 2, + large_paste = 3, + unknown = 4, +} + +export const PasteTypeStr = (p: PasteType): string | undefined => { + return ['paste', 'link', 'large_paste', 'unknown'].at(p + 1); +}; + +export const PasteTypeFrom = (s: string): PasteType => { + switch (s) { + case 'paste': + return PasteType.paste; + case 'link': + return PasteType.link; + case 'large_paste': + return PasteType.large_paste; + default: + return PasteType.unknown; + } +}; + +export interface PasteInfo { + uuid: string; + paste_type: PasteType; + title?: string; + file_size: number; + mime_type?: string; + has_password: boolean; + access_n: number; + max_access_n?: number; + created_at: number; + expired_at: number; +} + +// PasteIndexEntry v2 +export interface PasteIndexEntry { + uuid: string; + paste_type: PasteType; + title?: string; + file_size: number; + mime_type?: string; + password?: string; + created_at: number; + expired_at: number; + access_n: number; + max_access_n?: number; + // Track upload status + upload_track?: { + pending_upload?: boolean; + saved_expired_at?: number; + }; + // Only available when large_paste or using /v2/create + cached_presigned_url?: string; + cached_presigned_url_expiration?: number; +} + +export interface PasteCreateParams { + password?: string; + max_access_n?: number; + title?: string; + mime_type?: string; + file_size: number; + file_hash: string; + expired_at?: number; +} + +export const PasteCreateParamsValidator = new Validator({ + password: new Rule({ type: 'string', optional: true, notEmpty: true }), + max_access_n: new Rule({ type: 'int', optional: true, min: 1 }), + title: new Rule({ type: 'string', optional: true, notEmpty: true }), + mime_type: new Rule({ type: 'string', optional: true, notEmpty: true }), + file_size: new Rule({ type: 'int', min: 0 }), + file_hash: new Rule({ type: 'string', minLength: 64, maxLength: 64 }), + expired_at: new Rule({ + type: 'int', + optional: true, + min: Date.now(), + max: new Date(Date.now() + 2419200 * 1000).getTime(), // max. 28 days + }), +}); + +export interface PasteCreateUploadResponse { + uuid: string; + expiration: number; + upload_url: string; + request_headers: { + 'Content-Length': string; + 'X-Amz-Content-Sha256': string; + }; +} + +export class PasteAPIRepsonse { + static build( + status_code: number = 200, + content?: string | PasteInfo | PasteCreateUploadResponse, + headers?: HeadersInit, + content_name?: string + ): Response { + // Default content name if not set + if (content_name == undefined) { + if (typeof content == 'string') { + content_name = 'message'; + } else if (typeof content == 'object' && content.constructor?.name !== 'Object') { + content_name = content.constructor.name; + } else content_name = 'content'; + } + + return new Response( + JSON.stringify({ + status_code, + [content_name]: content, + }) + '\n', + { + status: status_code, + headers: { + 'content-type': 'application/json; charset=utf-8', + ...headers, + }, + } + ); + } + + static info(descriptor: PasteIndexEntry) { + const paste_info: PasteInfo = { + uuid: descriptor.uuid, + paste_type: descriptor.paste_type, + file_size: descriptor.file_size, + mime_type: descriptor.mime_type, + has_password: descriptor.password !== undefined, + access_n: descriptor.access_n, + max_access_n: descriptor.max_access_n, + created_at: descriptor.created_at, + expired_at: descriptor.expired_at, + }; + return this.build(200, paste_info, undefined, 'PasteInfo'); + } +}