diff --git a/frontend/paste.html b/frontend/paste.html index a785860..a3cb9f1 100644 --- a/frontend/paste.html +++ b/frontend/paste.html @@ -243,7 +243,7 @@ Access Times - - / 99999 + - / Created diff --git a/frontend/static/paste.js b/frontend/static/paste.js index 3090dd4..b3819e8 100644 --- a/frontend/static/paste.js +++ b/frontend/static/paste.js @@ -109,9 +109,17 @@ function build_paste_modal(paste_info, show_qrcode = true, saved = true, one_tim Object.entries(paste_info).forEach(([key, val]) => { if (key.includes('link')) return; - $(`#paste_info_${key}`).text(val ?? '-'); + $(`#paste_info_${key}`).text(val ?? ''); }); + if (paste_info.max_access_n !== undefined) { + $('#paste_info_access_separator').show(); + $('#paste_info_max_access_n').show(); + } else { + $('#paste_info_access_separator').hide(); + $('#paste_info_max_access_n').hide(); + } + let modal = new bootstrap.Modal(paste_modal.modal); if (!build_only) modal.show(); paste_modal.forget_btn.prop('disabled', one_time_only); diff --git a/src/index.ts b/src/index.ts index b05ef72..fcc79a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,7 @@ import { sha256 } from 'js-sha256'; import { Router, error, cors } from 'itty-router'; 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 { check_password_rules, get_paste_info, get_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'; @@ -316,7 +316,7 @@ router.get('/:uuid/:option?', async (request, env, ctx) => { // Check password if needed if (descriptor.password !== undefined) { if (headers.has('Authorization')) { - let cert = get_basic_auth(headers); + let cert = get_auth(headers, 'Basic'); // Error occurred when parsing the header if (cert === null) { return new Response('Invalid Authorization header.', { diff --git a/src/utils.ts b/src/utils.ts index 11d5736..fe75272 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -150,28 +150,31 @@ export function check_password_rules(password: string): boolean { return password.match('^[A-z0-9]{1,}$') !== null; } // Extract username and password from Basic Authorization header -export function get_basic_auth(headers: Headers): [string, string] | null { +export function get_auth(headers: Headers, required_scheme: string): string | [string, string] | null { if (headers.has('Authorization')) { const auth = headers.get('Authorization'); const [scheme, encoded] = auth!.split(' '); // Validate authorization header format - if (!encoded || scheme !== 'Basic') { + if (!encoded || scheme !== required_scheme) { return null; } - // Decode base64 to string (UTF-8) - const buffer = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0)); - const decoded = new TextDecoder().decode(buffer).normalize(); - const index = decoded.indexOf(':'); + if (scheme == 'Basic') { + // Decode base64 to string (UTF-8) + const buffer = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0)); + const decoded = new TextDecoder().decode(buffer).normalize(); + const index = decoded.indexOf(':'); - // Check if user & password are split by the first colon and MUST NOT contain control characters. - if (index === -1 || decoded.match('[\\0-\x1F\x7F]')) { - return null; + // Check if user & password are split by the first colon and MUST NOT contain control characters. + if (index === -1 || decoded.match('[\\0-\x1F\x7F]')) { + return null; + } + + return [decoded.slice(0, index), decoded.slice(index + 1)]; + } else if (scheme == 'Bearer') { + return encoded; } - - return [decoded.slice(0, index), decoded.slice(index + 1)]; - } else { - return null; } + return null; } function to_human_readable_size(bytes: number): string { diff --git a/src/v2/api.ts b/src/v2/api.ts index 6fdf8aa..046611a 100644 --- a/src/v2/api.ts +++ b/src/v2/api.ts @@ -9,8 +9,10 @@ import { PasteIndexEntry, PasteCreateUploadResponse, PasteType, + PasteInfoUpdateParams, + PasteInfoUpdateParamsValidator, } from './schema'; -import { gen_id } from '../utils'; +import { gen_id, get_auth } from '../utils'; import { AwsClient } from 'aws4fetch'; import { sha256 } from 'js-sha256'; import { xml2js } from 'xml-js'; @@ -37,6 +39,71 @@ router.get('/info/:uuid', async (req, env, ctx) => { return PasteAPIRepsonse.info(descriptor); }); +/* POST /info/:uuid + * Header: Authorization: Basic + * + * Response: + * | + */ +router.post('/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); + + let params: PasteInfoUpdateParams | undefined; + try { + const _params: PasteInfoUpdateParams = await req.json(); + if (!PasteInfoUpdateParamsValidator.test(_params)) { + return PasteAPIRepsonse.build(400, 'Invalid request fields.'); + } + params = _params; + } catch (e) { + return PasteAPIRepsonse.build(400, 'Invalid request.'); + } + + // Check password if needed + if (descriptor.password !== undefined) { + const { headers } = req; + let cert = get_auth(headers, 'Bearer'); + // Error occurred when parsing the header + if (cert === null) { + return PasteAPIRepsonse.build( + 403, + 'This paste is password-protected. You must provide the current access credentials to update its metadata.' + ); + } + // Check password and username should be empty + if (cert.length != 0 || descriptor.password !== sha256(cert).slice(0, 16)) { + return PasteAPIRepsonse.build(403, 'Invalid access credentials.'); + } + } + + if (descriptor.upload_track?.pending_upload) { + return PasteAPIRepsonse.build(400, 'This paste is not yet finalized.'); + } + + // Change paste info logic + // Explict assign the fields + const updated_descriptor = { + ...descriptor, + password: params.password ? sha256(params.password).slice(0, 16) : undefined, + max_access_n: params.max_access_n, + title: params.title, + mime_type: params.mime_type, + expired_at: params.expired_at ? params.expired_at : descriptor.expired_at, + }; + + ctx.waitUntil(env.PASTE_INDEX.put(uuid, JSON.stringify(updated_descriptor), { expirationTtl: 2419200 })); + return PasteAPIRepsonse.info(updated_descriptor); +}); + /* POST /create * Body: * diff --git a/src/v2/schema.ts b/src/v2/schema.ts index 68dee38..a469778 100644 --- a/src/v2/schema.ts +++ b/src/v2/schema.ts @@ -70,8 +70,8 @@ export interface PasteCreateParams { expired_at?: number; } -export const PasteCreateParamsValidator = new Validator({ - password: new Rule({ type: 'string', optional: true, notEmpty: true }), +const param_rules = { + password: new Rule({ type: 'password', optional: true, notEmpty: true, maxLength: 40 }), 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 }), @@ -83,7 +83,9 @@ export const PasteCreateParamsValidator = new Validator({ min: Date.now(), max: new Date(Date.now() + 2419200 * 1000).getTime(), // max. 28 days }), -}); +}; + +export const PasteCreateParamsValidator = new Validator(param_rules); export interface PasteCreateUploadResponse { uuid: string; @@ -95,6 +97,19 @@ export interface PasteCreateUploadResponse { }; } +export interface PasteInfoUpdateParams { + password?: string; + max_access_n?: number; + title?: string; + mime_type?: string; + expired_at?: number; +} + +// Omit non-editable fields +const { file_size, file_hash, ...editabe_fiels } = param_rules; + +export const PasteInfoUpdateParamsValidator = new Validator(editabe_fiels); + export class PasteAPIRepsonse { static build( status_code: number = 200, @@ -129,6 +144,7 @@ export class PasteAPIRepsonse { static info(descriptor: PasteIndexEntry) { const paste_info: PasteInfo = { uuid: descriptor.uuid, + title: descriptor.title, paste_type: descriptor.paste_type, file_size: descriptor.file_size, mime_type: descriptor.mime_type,