Add edit-info API (v2)

Update frontend ui

Signed-off-by: Joe Ma <rikkaneko23@gmail.com>
This commit is contained in:
Joe Ma 2025-08-04 13:03:57 +08:00
parent 0301a9d9f8
commit a2347f9f94
No known key found for this signature in database
GPG key ID: 7A0ECF5F5EDC587F
6 changed files with 115 additions and 21 deletions

View file

@ -243,7 +243,7 @@
</tr>
<tr>
<td class="text-center col-3 text-nowrap">Access Times</td>
<td class="text-center col-6"><span id="paste_info_access_n">-</span> / <span id="paste_info_access_max_access_n">99999</span></td>
<td class="text-center col-6"><span id="paste_info_access_n">-</span><span id="paste_info_access_separator"> / </span><span id="paste_info_max_access_n"></span></td>
</tr>
<tr>
<td class="text-center col-3 text-nowrap">Created</td>

View file

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

View file

@ -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.', {

View file

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

View file

@ -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 <password>
*
* Response:
* <empty> | <Error>
*/
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: <PasteCreateLargeParams>
*

View file

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