mirror of
https://github.com/rikkaneko/paste.git
synced 2025-08-06 14:10:13 +01:00
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:
parent
c18f08a4cb
commit
4542a4f519
9 changed files with 482 additions and 70 deletions
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
96
src/index.ts
96
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<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--;
|
||||
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,
|
||||
|
|
7
src/types.d.ts
vendored
7
src/types.d.ts
vendored
|
@ -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;
|
||||
}
|
31
src/utils.ts
31
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}
|
||||
`;
|
||||
|
|
227
src/v2/api.ts
Normal file
227
src/v2/api.ts
Normal 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
142
src/v2/schema.ts
Normal 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');
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue