mirror of
https://github.com/rikkaneko/paste.git
synced 2025-08-06 22:15:34 +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>
|
<td class="text-center col-6" id="paste_info_password">-</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-center col-3 text-nowrap">Read limit</td>
|
<td class="text-center col-3 text-nowrap">Account Times</td>
|
||||||
<td class="text-center col-6" id="paste_info_read_count_remain">-</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>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-center col-3 text-nowrap">Created</td>
|
<td class="text-center col-3 text-nowrap">Created</td>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "paste",
|
"name": "paste",
|
||||||
"version": "2.0",
|
"version": "2.1",
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wrangler dev",
|
"dev": "wrangler dev",
|
||||||
|
@ -9,6 +9,7 @@
|
||||||
"lint": "eslint . --color --cache -f friendly --max-warnings 10"
|
"lint": "eslint . --color --cache -f friendly --max-warnings 10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@cesium133/forgjs": "^2.2.0",
|
||||||
"aws4fetch": "^1.0.20",
|
"aws4fetch": "^1.0.20",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"dedent-js": "^1.0.1",
|
"dedent-js": "^1.0.1",
|
||||||
|
|
|
@ -2,9 +2,10 @@ import { Router } from 'itty-router';
|
||||||
import { sha256 } from 'js-sha256';
|
import { sha256 } from 'js-sha256';
|
||||||
import { AwsClient } from 'aws4fetch';
|
import { AwsClient } from 'aws4fetch';
|
||||||
import { xml2js } from 'xml-js';
|
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 { gen_id, get_paste_info_obj } from '../utils';
|
||||||
import constants from '../constant';
|
import constants from '../constant';
|
||||||
|
import { PasteIndexEntry, PasteType } from '../v2/schema';
|
||||||
|
|
||||||
export const router = Router<ERequest, [Env, ExecutionContext]>({ base: '/api/large_upload' });
|
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 = {
|
const descriptor: PasteIndexEntry = {
|
||||||
|
uuid,
|
||||||
title: file_title || undefined,
|
title: file_title || undefined,
|
||||||
mime_type: file_mime || undefined,
|
mime_type: file_mime || undefined,
|
||||||
last_modified: current,
|
created_at: current,
|
||||||
expiration: new Date(Date.now() + 900 * 1000).getTime(),
|
expired_at: new Date(Date.now() + 900 * 1000).getTime(),
|
||||||
password: password ? sha256(password).slice(0, 16) : undefined,
|
password: password ? sha256(password).slice(0, 16) : undefined,
|
||||||
read_count_remain: read_limit ?? undefined,
|
access_n: 0,
|
||||||
type: 'large_paste',
|
max_access_n: read_limit ?? undefined,
|
||||||
size: file_size,
|
paste_type: PasteType.large_paste,
|
||||||
upload_completed: false,
|
file_size: file_size,
|
||||||
|
upload_track: {
|
||||||
|
pending_upload: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
ctx.waitUntil(
|
ctx.waitUntil(
|
||||||
|
@ -202,7 +207,7 @@ router.post('/complete/:uuid', async (request, env, ctx) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const descriptor: PasteIndexEntry = JSON.parse(val);
|
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', {
|
return new Response('Invalid operation.\n', {
|
||||||
status: 442,
|
status: 442,
|
||||||
});
|
});
|
||||||
|
@ -231,8 +236,8 @@ router.post('/complete/:uuid', async (request, env, ctx) => {
|
||||||
elementNameFn: (val) => val.toLowerCase(),
|
elementNameFn: (val) => val.toLowerCase(),
|
||||||
});
|
});
|
||||||
const file_size: number = parsed.getobjectattributesresponse.objectsize._text;
|
const file_size: number = parsed.getobjectattributesresponse.objectsize._text;
|
||||||
if (file_size !== descriptor.size) {
|
if (file_size !== descriptor.file_size) {
|
||||||
return new Response(`This paste is not finishing upload. (${file_size} != ${descriptor.size})\n`, {
|
return new Response(`This paste is not finishing upload. (${file_size} != ${descriptor.file_size})\n`, {
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -249,9 +254,10 @@ router.post('/complete/:uuid', async (request, env, ctx) => {
|
||||||
|
|
||||||
const current = Date.now();
|
const current = Date.now();
|
||||||
const expriation = new Date(Date.now() + 2419200 * 1000).getTime(); // default 28 days
|
const expriation = new Date(Date.now() + 2419200 * 1000).getTime(); // default 28 days
|
||||||
descriptor.upload_completed = true;
|
// Remove unneeded propty
|
||||||
descriptor.last_modified = current;
|
delete descriptor.upload_track;
|
||||||
descriptor.expiration = expriation;
|
descriptor.created_at = current;
|
||||||
|
descriptor.expired_at = expriation;
|
||||||
ctx.waitUntil(env.PASTE_INDEX.put(uuid, JSON.stringify(descriptor), { expirationTtl: 2419200 }));
|
ctx.waitUntil(env.PASTE_INDEX.put(uuid, JSON.stringify(descriptor), { expirationTtl: 2419200 }));
|
||||||
|
|
||||||
const paste_info = {
|
const paste_info = {
|
||||||
|
@ -280,13 +286,13 @@ router.get('/:uuid', async (request, env, ctx) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const descriptor: PasteIndexEntry = JSON.parse(val);
|
const descriptor: PasteIndexEntry = JSON.parse(val);
|
||||||
if (descriptor.type !== 'large_paste') {
|
if (descriptor.paste_type == PasteType.large_paste) {
|
||||||
return new Response('Invalid operation.\n', {
|
return new Response('Invalid operation.\n', {
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!descriptor.upload_completed) {
|
if (!descriptor.upload_track?.pending_upload) {
|
||||||
return new Response('This paste is not yet finalized.\n', {
|
return new Response('This paste is not yet finalized.\n', {
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
|
@ -295,7 +301,7 @@ router.get('/:uuid', async (request, env, ctx) => {
|
||||||
const signed_url = await get_presign_url(uuid, descriptor);
|
const signed_url = await get_presign_url(uuid, descriptor);
|
||||||
const result = {
|
const result = {
|
||||||
uuid,
|
uuid,
|
||||||
expire: new Date(descriptor.expiration || 0).toISOString(),
|
expire: new Date(descriptor.expired_at).toISOString(),
|
||||||
signed_url,
|
signed_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -3,11 +3,15 @@ import { Config, Env } from './types';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
let CONSTANTS: Config = {
|
let CONSTANTS: Config = {
|
||||||
UUID_LENGTH: 4,
|
UUID_LENGTH: 4,
|
||||||
|
enable_large_upload: false,
|
||||||
};
|
};
|
||||||
export default CONSTANTS;
|
export default CONSTANTS;
|
||||||
|
|
||||||
// Fetch variable from Env
|
// Fetch variable from Env
|
||||||
export const fetch_constant = (env: 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 = {
|
CONSTANTS = {
|
||||||
...env,
|
...env,
|
||||||
...CONSTANTS,
|
...CONSTANTS,
|
||||||
|
|
96
src/index.ts
96
src/index.ts
|
@ -19,11 +19,13 @@
|
||||||
import { AwsClient } from 'aws4fetch';
|
import { AwsClient } from 'aws4fetch';
|
||||||
import { sha256 } from 'js-sha256';
|
import { sha256 } from 'js-sha256';
|
||||||
import { Router, error, cors } from 'itty-router';
|
import { Router, error, cors } from 'itty-router';
|
||||||
import { ERequest, Env, PasteIndexEntry } from './types';
|
import { ERequest, Env } from './types';
|
||||||
import { serve_static } from './proxy';
|
import { serve_static } from './proxy';
|
||||||
import { check_password_rules, get_paste_info, get_basic_auth, gen_id } from './utils';
|
import { check_password_rules, get_paste_info, get_basic_auth, gen_id } from './utils';
|
||||||
import constants, { fetch_constant } from './constant';
|
import constants, { fetch_constant } from './constant';
|
||||||
import { get_presign_url, router as large_upload } from './api/large_upload';
|
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
|
// In favour of new cors() in itty-router v5
|
||||||
const { preflight, corsify } = cors({
|
const { preflight, corsify } = cors({
|
||||||
|
@ -44,7 +46,18 @@ const router = Router<ERequest, [Env, ExecutionContext]>({
|
||||||
preflight,
|
preflight,
|
||||||
],
|
],
|
||||||
catch: error,
|
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
|
// Shared common properties to all route
|
||||||
|
@ -165,7 +178,7 @@ router.post('/', async (request, env, ctx) => {
|
||||||
// HTTP API
|
// HTTP API
|
||||||
title = headers.get('x-paste-title') || undefined;
|
title = headers.get('x-paste-title') || undefined;
|
||||||
mime_type = headers.get('x-paste-content-type') || 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;
|
paste_type = headers.get('x-paste-type') || undefined;
|
||||||
need_qrcode = headers.get('x-paste-qr') === '1';
|
need_qrcode = headers.get('x-paste-qr') === '1';
|
||||||
reply_json = headers.get('x-json') === '1';
|
reply_json = headers.get('x-json') === '1';
|
||||||
|
@ -243,14 +256,20 @@ router.post('/', async (request, env, ctx) => {
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
// Upload success
|
// 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 = {
|
const descriptor: PasteIndexEntry = {
|
||||||
|
uuid,
|
||||||
title: title || undefined,
|
title: title || undefined,
|
||||||
last_modified: Date.now(),
|
|
||||||
password: password ? sha256(password).slice(0, 16) : undefined,
|
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,
|
mime_type: mime_type || undefined,
|
||||||
type: paste_type,
|
paste_type: PasteTypeFrom(paste_type),
|
||||||
size,
|
file_size: size,
|
||||||
|
created_at: current_time,
|
||||||
|
expired_at: expiration,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Key will be expired after 28 day if unmodified
|
// Key will be expired after 28 day if unmodified
|
||||||
|
@ -266,6 +285,9 @@ router.post('/', async (request, env, ctx) => {
|
||||||
// Handle large upload (> 25MB)
|
// Handle large upload (> 25MB)
|
||||||
router.all('/api/large_upload/*', large_upload.fetch);
|
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]
|
// Fetch paste by uuid [4-digit UUID]
|
||||||
router.get('/:uuid/:option?', async (request, env, ctx) => {
|
router.get('/:uuid/:option?', async (request, env, ctx) => {
|
||||||
const { headers } = request;
|
const { headers } = request;
|
||||||
|
@ -326,31 +348,32 @@ router.get('/:uuid/:option?', async (request, env, ctx) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if access_count_remain entry present
|
// Check if access_count_remain entry present
|
||||||
if (descriptor.read_count_remain !== undefined) {
|
if (descriptor.max_access_n !== undefined) {
|
||||||
if (descriptor.read_count_remain <= 0) {
|
if (descriptor.access_n > descriptor.max_access_n) {
|
||||||
return new Response('Paste expired.\n', {
|
return new Response('Paste expired.\n', {
|
||||||
status: 410,
|
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
|
// New added in 2.0
|
||||||
// Handle large_paste
|
// Handle large_paste
|
||||||
// Use presigned url generation only if the file size larger than 200MB, use request forwarding instead
|
// Use presigned url generation only if the file size larger than 200MB, use request forwarding instead
|
||||||
if (descriptor.type === 'large_paste') {
|
if (descriptor.paste_type === PasteType.large_paste) {
|
||||||
if (!descriptor.upload_completed) {
|
if (descriptor.upload_track?.pending_upload) {
|
||||||
return new Response('This paste is not yet finalized.\n', {
|
return new Response('This paste is not yet finalized.\n', {
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (descriptor.size >= 209715200) {
|
if (descriptor.file_size >= 209715200) {
|
||||||
const signed_url = await get_presign_url(uuid, descriptor);
|
const signed_url = await get_presign_url(uuid, descriptor);
|
||||||
if (signed_url == null) {
|
if (signed_url == null) {
|
||||||
return new Response('No available download endpoint.\n', {
|
return new Response('No available download endpoint.\n', {
|
||||||
|
@ -360,7 +383,7 @@ router.get('/:uuid/:option?', async (request, env, ctx) => {
|
||||||
|
|
||||||
ctx.waitUntil(
|
ctx.waitUntil(
|
||||||
env.PASTE_INDEX.put(uuid, JSON.stringify(descriptor), {
|
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);
|
let res = await cache.match(req_key);
|
||||||
if (res === undefined) {
|
if (res === undefined) {
|
||||||
// Use althernative endpoint and credentials for large_type
|
// Use althernative endpoint and credentials for large_type
|
||||||
const endpoint = descriptor.type === 'large_paste' ? env.LARGE_DOWNLOAD_ENDPOINT : env.ENDPOINT;
|
const endpoint = descriptor.paste_type == PasteType.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 access_key_id =
|
||||||
|
descriptor.paste_type == PasteType.large_paste ? env.LARGE_AWS_ACCESS_KEY_ID! : env.AWS_ACCESS_KEY_ID;
|
||||||
const secret_access_key =
|
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({
|
const s3 = new AwsClient({
|
||||||
accessKeyId: access_key_id,
|
accessKeyId: access_key_id,
|
||||||
|
@ -414,9 +438,16 @@ router.get('/:uuid/:option?', async (request, env, ctx) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reserve ETag header
|
// Reserve ETag header
|
||||||
res = new Response(origin.body, { status: origin.status });
|
|
||||||
const etag = origin.headers.get('etag');
|
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) {
|
if (res.status == 404) {
|
||||||
// UUID exists in index but not found in remote object storage service, probably expired
|
// 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');
|
else res.headers.delete('content-type');
|
||||||
|
|
||||||
// Link redirection
|
// Link redirection
|
||||||
if (descriptor.type === 'link') {
|
if (descriptor.paste_type == PasteType.link) {
|
||||||
const content = await res.clone().arrayBuffer();
|
const content = await res.clone().arrayBuffer();
|
||||||
try {
|
try {
|
||||||
const href = new TextDecoder().decode(content);
|
const href = new TextDecoder().decode(content);
|
||||||
|
@ -519,12 +550,6 @@ router.delete('/:uuid', async (request, env, ctx) => {
|
||||||
}
|
}
|
||||||
const descriptor: PasteIndexEntry = JSON.parse(val);
|
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
|
// Check password if needed
|
||||||
if (descriptor.password !== undefined) {
|
if (descriptor.password !== undefined) {
|
||||||
if (headers.has('x-pass')) {
|
if (headers.has('x-pass')) {
|
||||||
|
@ -543,7 +568,7 @@ router.delete('/:uuid', async (request, env, ctx) => {
|
||||||
|
|
||||||
const cache = caches.default;
|
const cache = caches.default;
|
||||||
// Distinguish the endpoint for large_paste and normal paste
|
// 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) {
|
if (!env.LARGE_AWS_ACCESS_KEY_ID || !env.LARGE_AWS_SECRET_ACCESS_KEY || !env.LARGE_ENDPOINT) {
|
||||||
return new Response('Unsupported paste type.\n', {
|
return new Response('Unsupported paste type.\n', {
|
||||||
status: 501,
|
status: 501,
|
||||||
|
@ -552,10 +577,11 @@ router.delete('/:uuid', async (request, env, ctx) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use althernative endpoint and credentials for large_type
|
// Use althernative endpoint and credentials for large_type
|
||||||
const endpoint = descriptor.type === 'large_paste' ? env.LARGE_DOWNLOAD_ENDPOINT : env.ENDPOINT;
|
const endpoint = descriptor.paste_type == PasteType.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 access_key_id =
|
||||||
|
descriptor.paste_type == PasteType.large_paste ? env.LARGE_AWS_ACCESS_KEY_ID! : env.AWS_ACCESS_KEY_ID;
|
||||||
const secret_access_key =
|
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({
|
const s3 = new AwsClient({
|
||||||
accessKeyId: access_key_id,
|
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';
|
export type PASTE_TYPES = 'paste' | 'text' | 'link' | 'large_paste';
|
||||||
|
|
||||||
|
// Deprecated
|
||||||
export interface PasteIndexEntry {
|
export interface PasteIndexEntry {
|
||||||
title?: string;
|
title?: string;
|
||||||
mime_type?: string;
|
mime_type?: string;
|
||||||
last_modified: number;
|
last_modified: number;
|
||||||
expiration?: number; // New added in 2.0
|
expiration?: number; // New added in 2.0
|
||||||
size: number;
|
file_size: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
editable?: boolean; // Default: False (unsupported)
|
editable?: boolean; // Default: False (unsupported)
|
||||||
read_count_remain?: number;
|
access_n: number;
|
||||||
|
max_access_n?: number;
|
||||||
type: PASTE_TYPES;
|
type: PASTE_TYPES;
|
||||||
// Only apply when large_paste
|
// Only apply when large_paste
|
||||||
upload_completed?: boolean;
|
upload_completed?: boolean;
|
||||||
|
@ -44,4 +46,5 @@ export interface Env {
|
||||||
|
|
||||||
export interface Config extends Env {
|
export interface Config extends Env {
|
||||||
UUID_LENGTH: number;
|
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 dedent from 'dedent-js';
|
||||||
import { customAlphabet } from 'nanoid';
|
import { customAlphabet } from 'nanoid';
|
||||||
import constants from './constant';
|
import constants from './constant';
|
||||||
import { PasteIndexEntry, Env } from './types';
|
import { Env } from './types';
|
||||||
|
import { PasteIndexEntry, PasteTypeStr } from './v2/schema';
|
||||||
|
|
||||||
export const gen_id = customAlphabet(
|
export const gen_id = customAlphabet(
|
||||||
'1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
|
'1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
|
||||||
constants.UUID_LENGTH
|
constants.UUID_LENGTH
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Paste API Response (v1)
|
||||||
export function get_paste_info_obj(uuid: string, descriptor: PasteIndexEntry, env: Env) {
|
export function get_paste_info_obj(uuid: string, descriptor: PasteIndexEntry, env: Env) {
|
||||||
const created = new Date(descriptor.last_modified);
|
const created = new Date(descriptor.created_at);
|
||||||
const expired = new Date(descriptor.expiration ?? descriptor.last_modified + 2419200000);
|
const expired = new Date(descriptor.expired_at);
|
||||||
const link = `${env.SERVICE_URL}/${uuid}`;
|
const link = `${env.SERVICE_URL}/${uuid}`;
|
||||||
const paste_info = {
|
const paste_info = {
|
||||||
uuid,
|
uuid,
|
||||||
link,
|
link,
|
||||||
link_qr: 'https://qrcode.nekoid.cc/?' + new URLSearchParams({ q: link, type: 'svg' }),
|
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(),
|
title: descriptor.title?.trim(),
|
||||||
mime_type: descriptor.mime_type,
|
mime_type: descriptor.mime_type,
|
||||||
human_readable_size: `${to_human_readable_size(descriptor.size)}`,
|
human_readable_size: `${to_human_readable_size(descriptor.file_size)}`,
|
||||||
size: descriptor.size,
|
size: descriptor.file_size,
|
||||||
password: !!descriptor.password,
|
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(),
|
created: created.toISOString(),
|
||||||
expired: expired.toISOString(),
|
expired: expired.toISOString(),
|
||||||
update_completed: descriptor.upload_completed ?? undefined, // only for large_paste
|
|
||||||
};
|
};
|
||||||
return paste_info;
|
return paste_info;
|
||||||
}
|
}
|
||||||
|
@ -77,13 +79,14 @@ export async function get_paste_info(
|
||||||
mime-type: ${paste_info.mime_type ?? '-'}
|
mime-type: ${paste_info.mime_type ?? '-'}
|
||||||
size: ${paste_info.size} bytes (${paste_info.human_readable_size})
|
size: ${paste_info.size} bytes (${paste_info.human_readable_size})
|
||||||
password: ${paste_info.password}
|
password: ${paste_info.password}
|
||||||
remaining read count: ${
|
access times: ${
|
||||||
paste_info.read_count_remain !== undefined
|
paste_info.max_access_n !== undefined
|
||||||
? paste_info.read_count_remain
|
? paste_info.max_access_n - paste_info.access_n > 0
|
||||||
? paste_info.read_count_remain
|
? `${paste_info.access_n} / ${paste_info.max_access_n}`
|
||||||
: `0 (expired)`
|
: `${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}
|
created at ${paste_info.created}
|
||||||
expired at ${paste_info.expired}
|
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