Add support to create paste from HTTP form

Add paste.html

Add support to direct download paste

Change the default UUID length to 4

Add copyright notice
This commit is contained in:
Joe Ma 2022-06-02 01:31:56 +08:00
parent 44036f4092
commit 80e8aec857
No known key found for this signature in database
GPG key ID: 7A0ECF5F5EDC587F
3 changed files with 190 additions and 78 deletions

69
paste.html Normal file
View file

@ -0,0 +1,69 @@
<!--
~ This file is part of paste.
~ Copyright (c) 2022 Joe Ma <rikkaneko23@gmail.com>
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU Lesser General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU Lesser General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Paste</title>
</head>
<body>
<h3>Paste service - <a href="https://paste.nekoul.com">paste.nekoul.com</a></h3>
<a href="https://nekoul.com">[Homepage]</a><a href="https://paste.nekoul.com/api">[API]</a>
<h4>Upload file</h4>
<form action="https://paste.nekoul.com" method="POST" enctype=multipart/form-data>
<div>
<input id="upload_file" type="file" name="upload-content">
<div><input type="submit" value="Send"> (<span id="file_size">0 byte</span>)</div>
</div>
</form>
<script>
function update_file_size() {
let bytes = this.files[0]?.size ?? 0;
let size = bytes + " bytes";
const units = ["KiB", "MiB", "GiB", "TiB"];
for (let i = 0, approx = bytes / 1024; approx > 1; approx /= 1024, i++) {
size = approx.toFixed(3) + " " + units[i];
}
document.getElementById("file_size").innerHTML = size;
document.getElementById("file_title").innerText = `"${this.files[0]?.name ?? ""}"`
}
document.getElementById("upload_file").addEventListener("change", update_file_size, false);
</script>
<h4>Upload text</h4>
<form action="https://paste.nekoul.com" method="POST" enctype=multipart/form-data>
<div>
<textarea id="text_input" style="width: 30%; max-width: 100%; " rows ="5" cols="50"
name="upload-content" placeholder="Paste your text here..."></textarea>
<div><input type="submit" value="Send"></div>
</div>
</form>
<script>
function update_textarea() {
this.style.height = "auto"
this.style.height = this.scrollHeight + "px";
}
document.getElementById("text_input").addEventListener("input", update_textarea, false);
</script>
<br><p>&copy; 2022 rikkaneko</p>
</body>
</html>

8
paste.iml Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View file

@ -1,8 +1,28 @@
/*
* This file is part of paste.
* Copyright (c) 2022 Joe Ma <rikkaneko23@gmail.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {AwsClient} from "aws4fetch"; import {AwsClient} from "aws4fetch";
import { customAlphabet } from 'nanoid' import { customAlphabet } from 'nanoid'
// Constants // Constants
const SERVICE_URL = "https://paste.nekoul.com" const SERVICE_URL = "paste.nekoul.com"
const PASTE_INDEX_HTML_URL = "https://raw.githubusercontent.com/rikkaneko/paste/main/paste.html"
const UUID_LENGTH = 4
export interface Env { export interface Env {
PASTE_INDEX: KVNamespace; PASTE_INDEX: KVNamespace;
@ -11,30 +31,37 @@ export interface Env {
ENDPOINT: string ENDPOINT: string
} }
const API_DOCS = const API_SPEC_TEXT =
`Paste service https://paste.nekoul.com `Paste service https://${SERVICE_URL}
[API Draft] [API Specification]
GET / Fetch the HTML for uploading text/file [ ] GET / Fetch the Web frontpage for uploading text/file [x]
GET /<uuid> Fetch the paste by uuid [x] GET /api Fetch API specification
GET /<uuid>/<lang> Fetch the paste (code) in rendered HTML with syntax highlighting [ ] GET /<uuid> Fetch the paste by uuid [x]
GET /<uuid>/settings Fetch the paste information [x] GET /<uuid>/<lang> Fetch the paste (code) in rendered HTML with syntax highlighting [ ]
GET /status Fetch service information [x] GET /<uuid>/settings Fetch the paste information [x]
PUT / Create new paste [x] GET /<uuid>/download Download the paste [x]
POST /<uuid> Update the paste by uuid [x] POST / Create new paste [x] # Only support multipart/form-data and raw data
DELETE /<uuid> Delete paste by uuid [x] DELETE /<uuid> Delete paste by uuid [x]
POST /<uuid>/settings Update paste setting, i.e., passcode and valid time [ ] POST /<uuid>/settings Update paste setting, i.e., passcode and valid time [ ]
* uuid: [A-z0-9]{${UUID_LENGTH}}
* option: Render language
Features
* Password protection [ ]
* Expiring paste [ ]
[ ] indicated not implemented [ ] indicated not implemented
Limitation Limitation
* Max. 10MB file size upload (Max. 100MB body size for free Cloudflare plan) * Max. 10MB file size upload (Max. 100MB body size for free Cloudflare plan)
Last update on 30 May. Last update on 2 June.
`; `;
const gen_id = customAlphabet( const gen_id = customAlphabet(
"1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 8); "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", UUID_LENGTH);
export default { export default {
async fetch( async fetch(
@ -50,24 +77,60 @@ export default {
secretAccessKey: env.AWS_SECRET_ACCESS_KEY secretAccessKey: env.AWS_SECRET_ACCESS_KEY
}); });
// if (hostname !== SERVICE_URL) { // Special path
// // Invalid case if (path === "/api" && method == "GET") {
// return new Response(null, { status: 403 }) return new Response(API_SPEC_TEXT);
// } }
if (path === "/") { if (path === "/") {
switch (method) { switch (method) {
// Fetch the HTML for uploading text/file // Fetch the HTML for uploading text/file
case "GET": case "GET":
return new Response(API_DOCS); return await fetch(PASTE_INDEX_HTML_URL);
// Create new paste // Create new paste
case "PUT": case "POST":
let uuid = gen_id(); let uuid = gen_id();
let buffer = await request.arrayBuffer(); let buffer: ArrayBuffer;
let title: string | undefined;
// Handle content-type
const content_type = headers.get("content-type") || "";
// Content-Type: multipart/form-data
if (content_type.includes("form")) {
let formdata = await request.formData();
let data = formdata.get("upload-content");
if (data === null) {
return new Response("Invalid request.\n", {
status: 422
})
}
// File
if (data instanceof File) {
title = data.name ?? undefined;
buffer = await data.arrayBuffer();
// Text
} else {
buffer = new TextEncoder().encode(data)
}
// Raw body
} else {
title = headers.get("title") ?? undefined;
buffer = await request.arrayBuffer();
}
// Check request.body size <= 10MB // Check request.body size <= 10MB
if (buffer.byteLength > 10485760) { if (buffer.byteLength > 10485760) {
return new Response("File size must be under 10MB.\n"); return new Response("Paste size must be under 10MB.\n", {
status: 422
});
}
// Check request.body size not empty
if (buffer.byteLength == 0) {
return new Response("Paste cannot be empty.\n", {
status: 422
});
} }
let res = await s3.fetch(`${env.ENDPOINT}/${uuid}`, { let res = await s3.fetch(`${env.ENDPOINT}/${uuid}`, {
@ -78,10 +141,8 @@ export default {
if (res.ok) { if (res.ok) {
// Upload success // Upload success
let descriptor: PasteIndexEntry = { let descriptor: PasteIndexEntry = {
title: headers.get("title") || undefined, title: title ?? undefined,
last_modified: Date.now(), last_modified: Date.now()
password: undefined,
editable: undefined // Default: true
}; };
let counter = await env.PASTE_INDEX.get("__count__") || "0"; let counter = await env.PASTE_INDEX.get("__count__") || "0";
@ -96,7 +157,7 @@ export default {
} }
} else if (path.length >= 9) { } else if (path.length >= UUID_LENGTH + 1) {
// RegExpr to match /<uuid>/<option> // RegExpr to match /<uuid>/<option>
const found = path.match("/(?<uuid>[A-z0-9]+)(?:/(?<option>[A-z]+))?$"); const found = path.match("/(?<uuid>[A-z0-9]+)(?:/(?<option>[A-z]+))?$");
if (found === null) { if (found === null) {
@ -106,8 +167,8 @@ export default {
} }
// @ts-ignore // @ts-ignore
const {uuid, option} = found.groups; const {uuid, option} = found.groups;
// UUID format: [A-z0-9]{8} // UUID format: [A-z0-9]{UUID_LENGTH}
if (uuid.length !== 8) { if (uuid.length !== UUID_LENGTH) {
return new Response("Invalid UUID.\n", { return new Response("Invalid UUID.\n", {
status: 422 status: 422
}) })
@ -121,24 +182,20 @@ export default {
let descriptor: PasteIndexEntry = JSON.parse(val); let descriptor: PasteIndexEntry = JSON.parse(val);
// Handling /<uuid>/settings // Handling /<uuid>/settings
if (option !== undefined) { if (option === "settings") {
if (option === "settings") { switch(method) {
switch(method) { case "GET":
case "GET": return new Response(get_paste_info(uuid, descriptor));
return new Response(get_paste_info(uuid, descriptor))
case "POST": { case "POST": {
// TODO Implement paste setting update
} return new Response("Service is under maintainance.\n", {
status: 422
});
} }
} else if (option.length !== 0) {
return new Response("Unsupported language.\n", {
status: 405
})
} }
}
}
switch (method) { switch (method) {
// Fetch the paste by uuid // Fetch the paste by uuid
@ -156,46 +213,24 @@ export default {
} }
// Streaming request // Streaming request
res.body.pipeTo(writable); res.body.pipeTo(writable);
return new Response(readable, {
// headers: {
// "Content-Disposition": `attachment; filename="${encodeURIComponent(descriptor.title ?? uuid)}"`
// }
});
}
// Update the paste by uuid // Handle response format
case "POST": { // Direct download
if (!descriptor.editable) { if (option === "download") {
return new Response("This paste does not allow editing.\n", { return new Response(readable, {
status: 405 headers: {
"Content-Disposition": `attachment; filename="${encodeURIComponent(descriptor.title ?? uuid)}"`
}
}); });
} }
let buffer = await request.arrayBuffer(); // Default format
// Check request.body size <= 10MB return new Response(readable);
if (buffer.byteLength > 10485760) {
return new Response("File size must be under 10MB.\n");
}
let res = await s3.fetch(`${env.ENDPOINT}/${uuid}`, {
method: "PUT",
body: buffer
});
if (res.ok) {
// Update last modified time
descriptor.last_modified = Date.now();
await env.PASTE_INDEX.put(uuid, JSON.stringify(descriptor));
return new Response("OK\n");
} else {
return new Response("Unable to update the paste.\n", {
status: 500
});
}
} }
// Delete paste by uuid // Delete paste by uuid
case "DELETE": { case "DELETE": {
if (descriptor.editable !== undefined && descriptor.editable) { if (descriptor.editable !== undefined && !descriptor.editable) {
return new Response("This paste is immutable.\n", { return new Response("This paste is immutable.\n", {
status: 405 status: 405
}); });
@ -228,7 +263,7 @@ export default {
function get_paste_info(uuid: string, descriptor: PasteIndexEntry): string { function get_paste_info(uuid: string, descriptor: PasteIndexEntry): string {
let date = new Date(descriptor.last_modified) let date = new Date(descriptor.last_modified)
return `${SERVICE_URL}/${uuid} return `https://${SERVICE_URL}/${uuid}
ID: ${uuid} ID: ${uuid}
Title: ${descriptor.title || "<empty>"} Title: ${descriptor.title || "<empty>"}
Password: ${(!!descriptor.password)} Password: ${(!!descriptor.password)}
@ -241,5 +276,5 @@ interface PasteIndexEntry {
title?: string title?: string
last_modified: number, last_modified: number,
password?: string password?: string
editable?: boolean editable?: boolean // Default: True
} }