Add Paste API v2

Add object schema validation for v2 API
Fix corsify modifies immutable header issue

Signed-off-by: Joe Ma <rikkaneko23@gmail.com>
This commit is contained in:
Joe Ma 2025-08-01 14:45:33 +08:00
parent c18f08a4cb
commit 4542a4f519
No known key found for this signature in database
GPG key ID: 7A0ECF5F5EDC587F
9 changed files with 482 additions and 70 deletions

View file

@ -242,8 +242,8 @@
<td class="text-center col-6" id="paste_info_password">-</td>
</tr>
<tr>
<td class="text-center col-3 text-nowrap">Read limit</td>
<td class="text-center col-6" id="paste_info_read_count_remain">-</td>
<td class="text-center col-3 text-nowrap">Account Times</td>
<td class="text-center col-6"><span id="paste_info_access_n">-</span> / <span id="paste_info_access_max_access_n">-</span></td>
</tr>
<tr>
<td class="text-center col-3 text-nowrap">Created</td>

View file

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

View file

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

View file

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

View file

@ -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<ERequest, [Env, ExecutionContext]>({
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--;
}
descriptor.access_n++;
ctx.waitUntil(
env.PASTE_INDEX.put(uuid, JSON.stringify(descriptor), {
expiration: descriptor.last_modified / 1000 + 2419200,
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,

7
src/types.d.ts vendored
View file

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

View file

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

227
src/v2/api.ts Normal file
View file

@ -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<ERequest, [Env, ExecutionContext]>({ base: '/v2' });
/* GET /info/:uuid
*
* Response:
* <PasteInfo> | <Error>
*/
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: <PasteCreateLargeParams>
*
* Response:
* <PasteCreateUploadResponse> | <Error>
*/
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:
* <empty> | <PasteAPIError>
*/
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 <password>
*
* Response:
* <empty> | <Error>
*/
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=<authorization-code>
* Body: <file-content>
*
* Response:
* <PasteCreateUploadResponse> | <Error>
*/
router.post('/upload', async (req, env, ctx) => {
// TODO Upload paste logic
return PasteAPIRepsonse.build(200, 'This endpoint is not ready.');
});
export default router;

142
src/v2/schema.ts Normal file
View file

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