Add support to generate QR code for paste link

This commit is contained in:
Joe Ma 2022-09-11 19:05:18 +08:00
parent f7273ceb79
commit fb3f8d1ef1
No known key found for this signature in database
GPG key ID: 7A0ECF5F5EDC587F
5 changed files with 187 additions and 123 deletions

View file

@ -1,4 +1,5 @@
# Paste # Paste
This is a pastebin-like, simple file sharing application targeted to run on Cloudflare Worker. This is a pastebin-like, simple file sharing application targeted to run on Cloudflare Worker.
[pb.nekoul.com](http://pb.nekoul.com) is the current deployment of this project. [pb.nekoul.com](http://pb.nekoul.com) is the current deployment of this project.
The maximum upload file size is limited to **10 MB** and the paste will be kept for **28 days** only by default. The maximum upload file size is limited to **10 MB** and the paste will be kept for **28 days** only by default.
@ -6,6 +7,7 @@ The maximum upload file size is limited to **10 MB** and the paste will be kept
Please **DO NOT** abuse this service. Please **DO NOT** abuse this service.
## Supported features ## Supported features
- [x] Upload paste - [x] Upload paste
- [x] Download paste - [x] Download paste
- [x] Delete paste - [x] Delete paste
@ -15,8 +17,10 @@ Please **DO NOT** abuse this service.
- [x] View paste in browsers (only for text and media file) - [x] View paste in browsers (only for text and media file)
- [ ] Expiring paste (*not support directly, see [this section](#expiring-paste)*) - [ ] Expiring paste (*not support directly, see [this section](#expiring-paste)*)
- [ ] Render paste code with syntax highlighting - [ ] Render paste code with syntax highlighting
- [x] Generate QR code for paste link
## Service architecture ## Service architecture
This project is designed to use a S3-compatible object storage (via [aws4fetch](https://github.com/mhart/aws4fetch)) as the backend storage This project is designed to use a S3-compatible object storage (via [aws4fetch](https://github.com/mhart/aws4fetch)) as the backend storage
and [Cloudflare Worker KV](https://developers.cloudflare.com/workers/runtime-apis/kv) as index. and [Cloudflare Worker KV](https://developers.cloudflare.com/workers/runtime-apis/kv) as index.
All requests are handled by [Cloudflare Worker](https://developers.cloudflare.com/workers) with the entry point `fetch()`. All requests are handled by [Cloudflare Worker](https://developers.cloudflare.com/workers) with the entry point `fetch()`.
@ -24,6 +28,7 @@ It is worth noting that Cloudflare Worker is run *before* the cache. Therefore,
[Cache API](https://developers.cloudflare.com/workers/runtime-apis/cache/) is used instead to interact with Cloudflare cache. [Cache API](https://developers.cloudflare.com/workers/runtime-apis/cache/) is used instead to interact with Cloudflare cache.
## Environment variable ## Environment variable
|Name|Description| |Name|Description|
|-|-| |-|-|
|`SERVICE_URL`|The URL of the service| |`SERVICE_URL`|The URL of the service|
@ -38,26 +43,36 @@ It is worth noting that Cloudflare Worker is run *before* the cache. Therefore,
**`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `ENDPOINT` should be kept secret, i.e., [put into the encrypted store](https://developers.cloudflare.com/workers/platform/environment-variables/#adding-secrets-via-wrangler).** **`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `ENDPOINT` should be kept secret, i.e., [put into the encrypted store](https://developers.cloudflare.com/workers/platform/environment-variables/#adding-secrets-via-wrangler).**
## API Specification ## API Specification
### GET / ### GET /
Fetch the Web frontpage HTML for uploading text/file (used for browsers) Fetch the Web frontpage HTML for uploading text/file (used for browsers)
### GET /api ### GET /api
Fetch API specification Fetch API specification
### GET /\<uuid\> ### GET /\<uuid\>
Fetch the paste by uuid. *If the password is set, this request requires additional `x-pass` header or to use [HTTP Basic authentication](https://en.wikipedia.org/wiki/Basic_access_authentication).*
Fetch the paste by uuid. *If the password is set, this request requires additional `x-pass` header or to
use [HTTP Basic authentication](https://en.wikipedia.org/wiki/Basic_access_authentication).*
### POST / ### POST /
Create new paste. Currently, only `multipart/form-data` and raw request are supported. Create new paste. Currently, only `multipart/form-data` and raw request are supported.
Add `?qr=1` to enable QR code generation for paste link.
#### For `multipart/form-data` request, #### For `multipart/form-data` request,
|Form Key|Description| |Form Key|Description|
|-|-| |-|-|
|`u`|Upload content| |`u`|Upload content|
|`pass`|Paste's password| |`pass`|Paste's password|
|`read-limit`|The maximum access count| |`read-limit`|The maximum access count|
|`qrcode`|Toggle QR code generation|
#### For raw request, #### For raw request,
|Header Key|Description| |Header Key|Description|
|-|-| |-|-|
|`content-type`|The media type (MIME) of the data and encoding| |`content-type`|The media type (MIME) of the data and encoding|
@ -68,7 +83,9 @@ Create new paste. Currently, only `multipart/form-data` and raw request are supp
The request body contains the upload content. The request body contains the upload content.
### GET /\<uuid\>/\<option\> (Not implemented) ### GET /\<uuid\>/\<option\> (Not implemented)
Fetch the paste (code) in rendered HTML with syntax highlighting Fetch the paste (code) in rendered HTML with syntax highlighting
Add `?qr=1` to enable QR code generation for paste link.
Currently, only the following options is supported for `option` Currently, only the following options is supported for `option`
|Option|Meaning| |Option|Meaning|
|-|-| |-|-|
@ -79,17 +96,21 @@ Currently, only the following options is supported for `option`
*The authentication requirement is as same as `GET /<uuid>`.* *The authentication requirement is as same as `GET /<uuid>`.*
### DELETE /\<uuid\> ### DELETE /\<uuid\>
Delete paste by uuid. *If the password is set, this request requires additional `x-pass` header* Delete paste by uuid. *If the password is set, this request requires additional `x-pass` header*
### POST /\<uuid\>/settings (Not implemented) ### POST /\<uuid\>/settings (Not implemented)
Update paste setting. *If the password is set, this request requires additional `x-pass` header* Update paste setting. *If the password is set, this request requires additional `x-pass` header*
## Expiring paste ## Expiring paste
S3 object lifecycle rules and Cloudflare KV's expiring key can be used to implemented expiring paste. S3 object lifecycle rules and Cloudflare KV's expiring key can be used to implemented expiring paste.
Reference for Amazon S3 can be found in [here](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html) Reference for Amazon S3 can be found in [here](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html)
, and Blackblaze B2 in [here](https://www.backblaze.com/b2/docs/lifecycle_rules.html). , and Blackblaze B2 in [here](https://www.backblaze.com/b2/docs/lifecycle_rules.html).
## Remark ## Remark
You are welcome to use my project and depoly your own service. You are welcome to use my project and depoly your own service.
Due to the fact that the `SERVICE_URL` is hard-coded into the `paste.html`, Due to the fact that the `SERVICE_URL` is hard-coded into the `paste.html`,
you may simply use `Ctrl`+`R` to replace `pb.nekoul.com` with your own service URL. you may simply use `Ctrl`+`R` to replace `pb.nekoul.com` with your own service URL.

View file

@ -4,7 +4,8 @@
"dependencies": { "dependencies": {
"aws4fetch": "^1.0.13", "aws4fetch": "^1.0.13",
"nanoid": "^3.3.4", "nanoid": "^3.3.4",
"js-sha256": "^0.9.0" "js-sha256": "^0.9.0",
"dedent-js": "^1.0.1"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^3.11.0", "@cloudflare/workers-types": "^3.11.0",

View file

@ -28,7 +28,8 @@
<p> <p>
<a href="https://pb.nekoul.com">pb.nekoul.com</a> is a pastebin-like service hosted on Cloudflare Worker.<br> <a href="https://pb.nekoul.com">pb.nekoul.com</a> is a pastebin-like service hosted on Cloudflare Worker.<br>
This service is primarily designed for own usage and interest only.<br> This service is primarily designed for own usage and interest only.<br>
All data may be deleted or expired without any notification and guarantee. Please <b>DO NOT</b> abuse this service.<br> All data may be deleted or expired without any notification and guarantee. Please <b>DO NOT</b> abuse this
service.<br>
The limit for file upload is <b>10 MB</b> and the paste will be kept for <b>28 days</b> only by default.<br> The limit for file upload is <b>10 MB</b> and the paste will be kept for <b>28 days</b> only by default.<br>
The source code is available in my GitHub repository <a href="https://github.com/rikkaneko/paste">[here]</a>.<br> The source code is available in my GitHub repository <a href="https://github.com/rikkaneko/paste">[here]</a>.<br>
This webpage is designed for upload files only. This webpage is designed for upload files only.
@ -58,6 +59,10 @@
<label for="read_limit_input">Read limit: </label> <label for="read_limit_input">Read limit: </label>
<input id="read_limit_input" type="number" name="read-limit" min="1" style="width: 3em"> <input id="read_limit_input" type="number" name="read-limit" min="1" style="width: 3em">
</div> </div>
<div>
<input id="show_qr_checkbox" type="checkbox" name="qrcode">
<label for="show_qr_checkbox">Show QR code on sumbitted</label>
</div>
<br> <br>
<div> <div>
<input id="reset_button" type="reset" value="Reset"> <input id="reset_button" type="reset" value="Reset">
@ -68,48 +73,48 @@
<script> <script>
function update_textarea() { function update_textarea() {
this.style.height = "auto" this.style.height = 'auto';
this.style.height = this.scrollHeight + "px"; this.style.height = this.scrollHeight + 'px';
} }
function update_file_size() { function update_file_size() {
let bytes = this.files[0]?.size ?? 0; let bytes = this.files[0]?.size ?? 0;
let size = bytes + " bytes"; let size = bytes + ' bytes';
const units = ["KiB", "MiB", "GiB", "TiB"]; const units = ['KiB', 'MiB', 'GiB', 'TiB'];
for (let i = 0, approx = bytes / 1024; approx > 1; approx /= 1024, i++) { for (let i = 0, approx = bytes / 1024; approx > 1; approx /= 1024, i++) {
size = approx.toFixed(3) + " " + units[i]; size = approx.toFixed(3) + ' ' + units[i];
} }
document.getElementById("file_size").innerHTML = size; document.getElementById('file_size').innerHTML = size;
} }
function toggle_password() { function toggle_password() {
let input_field = document.getElementById("pass_input"); let input_field = document.getElementById('pass_input');
if (this.checked) { if (this.checked) {
input_field.type = "text"; input_field.type = 'text';
} else { } else {
input_field.type = "password"; input_field.type = 'password';
} }
} }
function reset_form() { function reset_form() {
// Re-enable all input elements // Re-enable all input elements
let elements = document.getElementById("upload_file_form").elements; let elements = document.getElementById('upload_file_form').elements;
for (let i = 0; i < elements.length; i++) { for (let i = 0; i < elements.length; i++) {
elements[i].disabled = false; elements[i].disabled = false;
} }
let size = document.getElementById("file_size"); let size = document.getElementById('file_size');
size.innerHTML = "0 bytes"; size.innerHTML = '0 bytes';
} }
function handle_submit_form(event) { function handle_submit_form(event) {
let elements = this.elements; let elements = this.elements;
let select_file = elements.namedItem("upload_file"); let select_file = elements.namedItem('upload_file');
let text = elements.namedItem("text_input"); let text = elements.namedItem('text_input');
if (!!select_file.value.length ^ !!text.value.length) { if (!!select_file.value.length ^ !!text.value.length) {
// Check file size // Check file size
const size = select_file.files[0]?.size ?? 0; const size = select_file.files[0]?.size ?? 0;
if (size > 10485760) { if (size > 10485760) {
alert("Upload file size must not excess 10 MB."); alert('Upload file size must not excess 10 MB.');
event.preventDefault(); event.preventDefault();
return false; return false;
} }
@ -117,17 +122,17 @@
elements[i].disabled = elements[i].value.length === 0; elements[i].disabled = elements[i].value.length === 0;
} }
} else { } else {
alert("You must either upload a file or upload text, but not bothor neither."); alert('You must either upload a file or upload text, but not bothor neither.');
// Prevent default submission // Prevent default submission
event.preventDefault(); event.preventDefault();
} }
} }
document.getElementById("upload_file").addEventListener("input", update_file_size, false); document.getElementById('upload_file').addEventListener('input', update_file_size, false);
document.getElementById("text_input").addEventListener("input", update_textarea, false); document.getElementById('text_input').addEventListener('input', update_textarea, false);
document.getElementById("show_pass_button").addEventListener("change", toggle_password, false); document.getElementById('show_pass_button').addEventListener('change', toggle_password, false);
document.getElementById("reset_button").addEventListener("click", reset_form, false); document.getElementById('reset_button').addEventListener('click', reset_form, false);
document.getElementById("upload_file_form").addEventListener("submit", handle_submit_form, false) document.getElementById('upload_file_form').addEventListener('submit', handle_submit_form, false);
</script> </script>
<br> <br>
<a href="https://nekoul.com">[Homepage]</a><a href="https://pb.nekoul.com/api">[API]</a> <a href="https://nekoul.com">[Homepage]</a><a href="https://pb.nekoul.com/api">[API]</a>

View file

@ -19,6 +19,7 @@
import {AwsClient} from 'aws4fetch'; import {AwsClient} from 'aws4fetch';
import {customAlphabet} from 'nanoid'; import {customAlphabet} from 'nanoid';
import {sha256} from 'js-sha256'; import {sha256} from 'js-sha256';
import dedent from 'dedent-js';
// Constants // Constants
const SERVICE_URL = 'pb.nekoul.com'; const SERVICE_URL = 'pb.nekoul.com';
@ -27,6 +28,7 @@ const UUID_LENGTH = 4;
export interface Env { export interface Env {
PASTE_INDEX: KVNamespace; PASTE_INDEX: KVNamespace;
QRCODE: ServiceWorkerGlobalScope;
AWS_ACCESS_KEY_ID: string; AWS_ACCESS_KEY_ID: string;
AWS_SECRET_ACCESS_KEY: string; AWS_SECRET_ACCESS_KEY: string;
ENDPOINT: string; ENDPOINT: string;
@ -43,18 +45,19 @@ GET /api Fetch API specification
GET /<uuid> Fetch the paste by uuid [x] GET /<uuid> Fetch the paste by uuid [x]
# Currently, only the following options is supported for <option>, # Currently, only the following options is supported for <option>,
# "settings": Fetch the paste information # "settings": Fetch the paste information, add \`?qr=1\` to enable QR code generation for paste link.
# "download": Download paste as attachment # "download": Download paste as attachment
# "raw": Display paste as plain text # "raw": Display paste as plain text
GET /<uuid>/<option> Fetch the paste (code) in rendered HTML with syntax highlighting [ ] GET /<uuid>/<option> Fetch the paste (code) in rendered HTML with syntax highlighting [ ]
# Only support multipart/form-data and raw request # Only support multipart/form-data and raw request
# For form-data, u=<upload-data>, both title and content-type is deduced from the u # For form-data, u=<upload-data>, both title and content-type is deduced from the u
# The following key is supported for both HTTP form request and headers, prefix "x-" for header keys # Add \`?qr=1\` or qrcode=(on|true) using form-data to enable QR code generation for paste link.
# x-title: File title, i.e., # The following key is supported for both HTTP form request and headers, add the prefix "x-" for header keys in raw request
# title: File title, i.e. main.py
# content-type: The media type (MIME) of the data and encoding, i.e., text/plain; charset=UTF-8; # content-type: The media type (MIME) of the data and encoding, i.e., text/plain; charset=UTF-8;
# x-pass: Paste password # pass: Paste password
# x-read-limit: Limit access times to paste to <read-limit> # read-limit: Limit access times to paste to <read-limit>
POST / Create new paste [x] POST / Create new paste [x]
DELETE /<uuid> Delete paste by uuid [x] DELETE /<uuid> Delete paste by uuid [x]
@ -64,17 +67,18 @@ POST /<uuid>/settings Update paste setting, i.e., passcode and valid time [ ]
* uuid: [A-z0-9]{${UUID_LENGTH}} * uuid: [A-z0-9]{${UUID_LENGTH}}
Features Supported Features
* Password protection [x] * Password protection
* Expiring paste [ ] * Limit access times
* Generate QR code for paste link
[ ] indicated not implemented [ ] indicated not implemented
Limitation Limitation
* Max. 10MB file size upload * Max. 10MB file size upload
* Paste will be kept for 180 days only * Paste will be kept for 28 days only by default
Last update on 7 June. Last update on 11 Sept.
`; `;
const gen_id = customAlphabet( const gen_id = customAlphabet(
@ -87,7 +91,7 @@ export default {
ctx: ExecutionContext, ctx: ExecutionContext,
): Promise<Response> { ): Promise<Response> {
const {url, method, headers} = request; const {url, method, headers} = request;
const {pathname} = new URL(url); const {pathname, searchParams} = new URL(url);
const path = pathname.replace(/\/+$/, '') || '/'; const path = pathname.replace(/\/+$/, '') || '/';
let cache = caches.default; let cache = caches.default;
@ -138,6 +142,7 @@ export default {
let mime_type: string | undefined; let mime_type: string | undefined;
let password: string | undefined; let password: string | undefined;
let read_limit: number | undefined; let read_limit: number | undefined;
let need_qrcode: boolean = false;
// Content-Type: multipart/form-data // Content-Type: multipart/form-data
if (content_type.includes('form')) { if (content_type.includes('form')) {
const formdata = await request.formData(); const formdata = await request.formData();
@ -170,12 +175,18 @@ export default {
read_limit = Number(count) || undefined; read_limit = Number(count) || undefined;
} }
// Check if qrcode generation needed
const qr = formdata.get('qrcode');
if (typeof qr === 'string' && qr.toLowerCase() === 'true' || qr === 'on') {
need_qrcode = true;
}
// Raw body // Raw body
} else { } else {
if (headers.has('x-title')) { if (headers.has('x-title')) {
title = headers.get('x-title') || ''; title = headers.get('x-title') || '';
} }
mime_type = headers.get('content-type') || mime_type; mime_type = headers.get('x-content-type') || mime_type;
password = headers.get('x-pass') || undefined; password = headers.get('x-pass') || undefined;
const count = headers.get('x-read-limit') || undefined; const count = headers.get('x-read-limit') || undefined;
if (count !== undefined && !isNaN(+count)) { if (count !== undefined && !isNaN(+count)) {
@ -184,6 +195,11 @@ export default {
buffer = await request.arrayBuffer(); buffer = await request.arrayBuffer();
} }
// Check if qrcode generation needed
if (searchParams.get('qr') === '1') {
need_qrcode = true;
}
// Check password rules // Check password rules
if (password && !check_password_rules(password)) { if (password && !check_password_rules(password)) {
return new Response('Invalid password. ' + return new Response('Invalid password. ' +
@ -225,7 +241,7 @@ export default {
// Key will be expired after 28 day if unmodified // Key will be expired after 28 day if unmodified
ctx.waitUntil(env.PASTE_INDEX.put(uuid, JSON.stringify(descriptor), {expirationTtl: 100800})); ctx.waitUntil(env.PASTE_INDEX.put(uuid, JSON.stringify(descriptor), {expirationTtl: 100800}));
return new Response(get_paste_info(uuid, descriptor)); return new Response(await get_paste_info(uuid, descriptor, env, need_qrcode));
} else { } else {
return new Response('Unable to upload the paste.\n', { return new Response('Unable to upload the paste.\n', {
status: 500, status: 500,
@ -261,8 +277,10 @@ export default {
// Handling /<uuid>/settings // Handling /<uuid>/settings
if (option === 'settings') { if (option === 'settings') {
switch (method) { switch (method) {
case 'GET': case 'GET': {
return new Response(get_paste_info(uuid, descriptor)); const need_qrcode = searchParams.get('qr') === '1';
return new Response(await get_paste_info(uuid, descriptor, env, need_qrcode));
}
case 'POST': { case 'POST': {
// TODO Implement paste setting update // TODO Implement paste setting update
@ -433,9 +451,10 @@ export default {
}, },
}; };
function get_paste_info(uuid: string, descriptor: PasteIndexEntry): string { async function get_paste_info(uuid: string, descriptor: PasteIndexEntry, env: Env, need_qr: boolean = false): Promise<string> {
const date = new Date(descriptor.last_modified); const date = new Date(descriptor.last_modified);
return `id: ${uuid} let content = dedent`
id: ${uuid}
link: https://${SERVICE_URL}/${uuid} link: https://${SERVICE_URL}/${uuid}
title: ${descriptor.title || '<empty>'} title: ${descriptor.title || '<empty>'}
mime-type: ${descriptor.mime_type ?? '-'} mime-type: ${descriptor.mime_type ?? '-'}
@ -446,6 +465,21 @@ remaining read count: ${descriptor.read_count_remain !== undefined ?
descriptor.read_count_remain ? descriptor.read_count_remain : `0 (expired)` : '-'} descriptor.read_count_remain ? descriptor.read_count_remain : `0 (expired)` : '-'}
created at ${date.toISOString()} created at ${date.toISOString()}
`; `;
if (need_qr) {
// Cloudflare currently does not support doing a subrequest to the same zone, use service binding instead
const res = await env.QRCODE.fetch('https://qrcode.nekoul.com?' + new URLSearchParams({
q: `https://${SERVICE_URL}/${uuid}`,
}));
if (res.ok) {
const qrcode = await res.text();
content += '\n';
content += qrcode;
content += '\n';
}
}
return content;
} }
function check_password_rules(password: string): boolean { function check_password_rules(password: string): boolean {

View file

@ -6,6 +6,9 @@ workers_dev = false
kv_namespaces = [ kv_namespaces = [
{ binding = "PASTE_INDEX", id = "a578863da0564cd7beadd9ce4a2d53e8", preview_id = "66d9440e13124099a5e508fe1ff0a489" } { binding = "PASTE_INDEX", id = "a578863da0564cd7beadd9ce4a2d53e8", preview_id = "66d9440e13124099a5e508fe1ff0a489" }
] ]
services = [
{ binding = "QRCODE", service = "qrcode-gen", environment = "production" }
]
# [secret] # [secret]
# AWS_ACCESS_KEY_ID # AWS_ACCESS_KEY_ID