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