├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── VERSION ├── auth.ts ├── client.sh ├── db.ts ├── deno.json ├── paste.test.ts ├── paste.ts ├── runtests.sh ├── server.ts ├── static ├── css │ ├── about.css │ ├── colors.css │ ├── highlight.css │ └── styles.css └── js │ ├── burnable-clip.js │ ├── clipboard.js │ ├── login-check.js │ └── recent-posts.js ├── templates ├── 404.ts ├── about.ts ├── burn.ts ├── footer.ts ├── index.ts ├── layout.ts ├── login.ts ├── navbar.ts └── register.ts └── types.ts /.gitignore: -------------------------------------------------------------------------------- 1 | data/* 2 | node_modules/* 3 | test.* 4 | *.db 5 | .envrc 6 | .env 7 | .vim/* 8 | deno.lock 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Nutty 2 | 3 | Thank you for considering contributing to Nutty! We welcome contributions from the community to help improve and grow the project. 4 | 5 | ## How to Contribute 6 | 7 | We appreciate contributions in various forms, including bug reports, feature requests, documentation improvements, code enhancements, and more. To contribute, please follow these steps: 8 | 9 | 1. **Fork the Repository**: Click the "Fork" button on the top right of the repository page to create your own fork. 10 | 2. **Clone Your Fork**: Clone the repository to your local machine using the following command: 11 | ```bash 12 | git clone https://github.com/JLCarveth/nutty.git 13 | ``` 14 | 3. **Create a Branch**: Create a new branch for your contributionL 15 | ```bash 16 | git checkout -b feature/your-feature 17 | ``` 18 | 4. **Make Changes**: Implement your changes, fix bugs, or add features. Please make sure to write well-formatted and well-documented code. 19 | 20 | 5. **Test Your Changes**: Ensure that your changes work as expected and do not introduce new issues. Run the test suite to ensure there are no regressions. 21 | 22 | 6. **Commit Changes**: Commit your changes with a descriptive commit message: 23 | ```bash 24 | git commit -sm "Your commit message" 25 | ``` 26 | 7. **Push Changes**: Push your changes to your fork: 27 | ```bash 28 | git push origin feature/your-feature 29 | ``` 30 | 31 | 8. **Create a Pull Request**: Head to the [Nutty repository](https://github.com/JLCarveth/nutty.git) on GitHub and make a pull request. 32 | 33 | ## Report Issues 34 | If you encounter any issues or have suggestions for improvements, do not hesitate to [open an issue](https://github.com/JLCarveth/nutty/issues/new/choose). 35 | 36 | ## License 37 | By contributing to Nutty, you agree that your contribbutions will be licensed under the LICENSE file. 38 | 39 | Thank you for contributing to Nutty! We appreciate your time and effort in improving the project! 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 John L. Carveth 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nutty 2 | #### A simple HTTP paste server - Contributions Welcome! 3 | 4 | Nutty is a self-hostable paste server, allowing you to easily upload and share text files instantly. 5 | 6 | ## Features 7 | - A simple HTTP interface 8 | - A simple Web interface (WIP) 9 | - Public and private pastes 10 | - [Low memory footprint](https://github.com/JLCarveth/nutty/assets/23156861/b449813a-719a-4c9b-8922-f91f70101b1d) 11 | - Burn-on-read - Pastes that can only be read once 12 | - Automatic Syntax Highlighting 13 | 14 | ## Installation 15 | 16 | 1. Make sure you have Deno installed on your system. If not, you can install it by following the instructions on the [Deno website](https://deno.land/#installation). 17 | 2. Clone the repository by running `git clone https://github.com/JLCarveth/nutty.git`. 18 | 3. Change into the cloned directory by running `cd nutty`. 19 | 4. Run the `paste.ts` file with Deno by running `deno run -A --unstable paste.ts`. 20 | 21 | A simple systemd service can also be setup to handle stopping/starting Nutty: 22 | ```Systemd 23 | [Unit] 24 | Description=File pasting API 25 | 26 | [Service] 27 | ExecStart=/home/jlcarveth/.deno/bin/deno run -A --unstable paste.ts 28 | Restart=always 29 | User=jlcarveth 30 | Group=jlcarveth 31 | WorkingDirectory=/opt/paste 32 | EnvironmentFile=/opt/paste/.env 33 | StandardOutput=journal 34 | StandardError=journal 35 | 36 | [Install] 37 | WantedBy=multi-user.target 38 | ``` 39 | ### Environment 40 | Nutty depends on a couple of environment variables to be established before running. 41 | |Variable Name|Description|Example Values| 42 | |---|---|---| 43 | |`TARGET_DIR`|The directory where pastes will be stored.|`/opt/paste/data`| 44 | |`BASE_URL`|The base URL at which the API can be accessed.|`https://paste.mysite.com/api`| 45 | |`SECRET_KEY`|Used for signing JWTs. A secret key can be generated with `openssl rand -base64 32`|`GDZ1FzBF18dtAk2enanqqxskVf5hptmPjy/pcBm384M=`| 46 | |`PORT`|The port to listen to. Default is 5335|`5335`| 47 | |`PUBLIC_PASTES`|Whether to allow users to create pastes without an access token.|1,0,true,false| 48 | |`DEBUG`|Logs incoming requests for debugging purposes.|1,0,true,false| 49 | |`DOMAIN`|The domain used for the HTTP cookie, as well as the burn URLs. |`paste.jlcarveth.dev`| 50 | 51 | ## Usage 52 | There is a simple client bash script which provides a simple example of using Nutty. 53 | ``` 54 | export TOKEN=$(curl https://paste.jlcarveth.dev/api/login -X POST -H "Content-Type: application/json" -d '{"email":"jlcarveth@gmail.com","password":"notmypassword"}') 55 | export EMAIL="jlcarveth@gmail.com" # Email is used for signing PGP messages 56 | uuid=$(cat file.txt | bash client.sh) 57 | ``` 58 | If the client runs successfully, a UUID associated with the new paste is returned. This paste can then be retrieved with a simple GET request: 59 | ``` 60 | curl $API_URL/$UUID -H "X-Access-Token: $TOKEN" 61 | ``` 62 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | v1.12.3 2 | -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility class for implementing authentication functionality 3 | */ 4 | const SECRET_KEY = Deno.env.get("SECRET_KEY") || "__NOKEY__"; 5 | 6 | const encoder = new TextEncoder(); 7 | const keyBuf = encoder.encode(SECRET_KEY); 8 | 9 | const KEY = await crypto.subtle.importKey( 10 | "raw", 11 | keyBuf, 12 | { 13 | name: "HMAC", 14 | hash: "SHA-512", 15 | }, 16 | true, 17 | ["sign", "verify"], 18 | ); 19 | 20 | import { createHmac, randomBytes } from "node:crypto"; 21 | import { 22 | create, 23 | verify as verifyToken, 24 | } from "https://deno.land/x/djwt@v2.8/mod.ts"; 25 | 26 | export async function generateToken(payload: Record) { 27 | if (SECRET_KEY === "__NOKEY__") { 28 | console.error("No SECRET_KEY provided, please set SECRET_KEY in .env"); 29 | Deno.exit(1); 30 | } 31 | return await create({ alg: "HS512", typ: "JWT" }, payload, KEY); 32 | } 33 | 34 | export async function verify(token: string) { 35 | return await verifyToken(token, KEY); 36 | } 37 | 38 | export function salt() { 39 | return randomBytes(32).toString("base64"); 40 | } 41 | 42 | export function hash(value: string) { 43 | return saltedHash(value, salt()); 44 | } 45 | 46 | export function saltedHash(value: string, salt: string) { 47 | const hash = createHmac("sha512", salt).update(value).digest("base64"); 48 | return `${hash}:${salt}`; 49 | } 50 | 51 | /** 52 | * Compare a plaintext password to a hash:salt 53 | */ 54 | export function compare(password: string, hash: string) { 55 | const salt = hash.split(":")[1]; 56 | return saltedHash(password, salt) === hash; 57 | } 58 | 59 | /** 60 | * SQLiteService - Service wrapper for sqlite3 61 | */ 62 | import { Database } from "https://deno.land/x/sqlite3@0.9.1/mod.ts"; 63 | 64 | const DB_NAME = Deno.env.get("DB_NAME") || "users.db"; 65 | const db = new Database(DB_NAME); 66 | 67 | interface User { 68 | userid: string; 69 | email: string; 70 | password: string; 71 | } 72 | let instance: SQLiteService | null = null; 73 | export class SQLiteService { 74 | static getInstance() { 75 | if (!instance) { 76 | instance = new SQLiteService(); 77 | } 78 | return instance; 79 | } 80 | 81 | constructor() { 82 | try { 83 | db.exec( 84 | `CREATE TABLE IF NOT EXISTS users (\ 85 | userid text primary key not null,\ 86 | email text unique not null,\ 87 | password text not null)`, 88 | ); 89 | } catch (err) { 90 | console.error("Error creating table", err); 91 | Deno.exit(1); 92 | } 93 | } 94 | 95 | /** 96 | * Attempts to login with a given userid and password, 97 | * comparing to the hash:salt stored in the database. 98 | */ 99 | login(email: string, password: string) { 100 | const stmt = db.prepare("SELECT * FROM users WHERE email = ?"); 101 | const user = stmt.get(email) as User | undefined; 102 | if (!user) { 103 | throw new Error("Authentication Error"); 104 | } 105 | const salt = user.password.split(":")[1]; 106 | if (!salt) { 107 | throw new Error("Error fetching salt from user record"); 108 | } 109 | 110 | if (!compare(password, user.password)) { 111 | throw new Error("Authentication Error"); 112 | } 113 | 114 | const token = generateToken({ 115 | //Payload 116 | userid: user.userid, 117 | email: user.email, 118 | }); 119 | 120 | return token; 121 | } 122 | 123 | /** 124 | * Registers a new user account. Generates a UUID to act as userid, and hashes the 125 | * required password. 126 | */ 127 | register(email: string, password: string) { 128 | const hashed = hash(password); 129 | try { 130 | const uuid = crypto.randomUUID(); 131 | db.exec("INSERT INTO users (userid, email, password) VALUES (?,?,?)", [ 132 | uuid, 133 | email, 134 | hashed, 135 | ]); 136 | return uuid; 137 | } catch (err) { 138 | throw err; 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /client.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Read environment variables 4 | EMAIL=${EMAIL} 5 | TOKEN=${TOKEN} 6 | API_URL=${API_URL} 7 | 8 | # Read piped input 9 | TEXT=$(cat) 10 | 11 | # Encrypt text with gpg 12 | ENCRYPTED_TEXT=$(echo "${TEXT}" | gpg --encrypt --armor --recipient "${EMAIL}") 13 | 14 | # Send POST request with curl 15 | UUID=$(curl -X POST -H "X-Access-Token: ${TOKEN}" -d "${ENCRYPTED_TEXT}" "${API_URL}/paste") 16 | echo $UUID 17 | -------------------------------------------------------------------------------- /db.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * SQLiteService - Service wrapper for sqlite3 3 | */ 4 | import { Database } from "https://deno.land/x/sqlite3@0.9.1/mod.ts"; 5 | import { compare, generateToken, hash } from "./auth.ts"; 6 | 7 | const DB_NAME = Deno.env.get("DB_NAME") || "users.db"; 8 | const db = new Database(DB_NAME); 9 | 10 | interface User { 11 | userid: string; 12 | email: string; 13 | password: string; 14 | } 15 | let instance: SQLiteService | null = null; 16 | 17 | export class SQLiteService { 18 | static getInstance() { 19 | if (!instance) { 20 | instance = new SQLiteService(); 21 | } 22 | return instance; 23 | } 24 | 25 | constructor() { 26 | try { 27 | db.exec( 28 | `CREATE TABLE IF NOT EXISTS users (\ 29 | userid text primary key not null,\ 30 | email text unique not null,\ 31 | password text not null)`, 32 | ); 33 | 34 | db.exec(`CREATE TABLE IF NOT EXISTS burn_on_read (\ 35 | uuid text primary key not null)`); 36 | } catch (err) { 37 | console.error("Error creating table", err); 38 | Deno.exit(1); 39 | } 40 | } 41 | 42 | /** 43 | * Attempts to login with a given userid and password, 44 | * comparing to the hash:salt stored in the database. 45 | */ 46 | login(email: string, password: string) { 47 | const stmt = db.prepare("SELECT * FROM users WHERE email = ?"); 48 | const user = stmt.get(email) as User | undefined; 49 | if (!user) { 50 | throw new Error("Authentication Error"); 51 | } 52 | const salt = user.password.split(":")[1]; 53 | if (!salt) { 54 | throw new Error("Error fetching salt from user record"); 55 | } 56 | 57 | if (!compare(password, user.password)) { 58 | throw new Error("Authentication Error"); 59 | } 60 | 61 | const token = generateToken({ 62 | //Payload 63 | userid: user.userid, 64 | email: user.email, 65 | }); 66 | 67 | return token; 68 | } 69 | 70 | /** 71 | * Registers a new user account. Generates a UUID to act as userid, and hashes the 72 | * required password. 73 | */ 74 | register(email: string, password: string) { 75 | const hashed = hash(password); 76 | try { 77 | const uuid = crypto.randomUUID(); 78 | db.exec("INSERT INTO users (userid, email, password) VALUES (?,?,?)", [ 79 | uuid, 80 | email, 81 | hashed, 82 | ]); 83 | return uuid; 84 | } catch (err) { 85 | throw err; 86 | } 87 | } 88 | 89 | /** 90 | * Inserts a new UUID into the burn_on_read database table 91 | */ 92 | createBurnable(uuid: string) { 93 | try { 94 | db.exec("INSERT INTO burn_on_read (uuid) VALUES (?)", [uuid]); 95 | } catch (err) { 96 | throw err; 97 | } 98 | } 99 | 100 | /** 101 | * Returns true if the burn_on_read table contains the given UUID */ 102 | isBurnable(uuid: string) { 103 | try { 104 | const stmt = db.prepare(`SELECT uuid FROM burn_on_read WHERE uuid = ?`); 105 | const result = stmt.get(uuid); 106 | stmt.finalize(); 107 | if (result === undefined) return false; 108 | return true; 109 | } catch (err) { 110 | throw err; 111 | } 112 | } 113 | 114 | /** 115 | * Remove a record from the burn_on_read table 116 | */ 117 | removeBurnable(uuid: string) { 118 | try { 119 | db.exec(`DELETE FROM burn_on_read WHERE uuid = ?`, [uuid]); 120 | } catch (err) { 121 | throw err; 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks" : { 3 | "start" : "deno run -A --unstable-ffi paste.ts", 4 | "test" : "bash runtests.sh" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /paste.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This test file is meant to be run by the bash script runtests.sh, 3 | * it is not meant to be run standalone using `deno test`, since these 4 | * are end-to-end tests and require the paste.ts service to be running. 5 | * 6 | * @author John L. Carveth 7 | * @date 2023-12-14 8 | */ 9 | import { 10 | assert, 11 | assertEquals, 12 | } from "https://deno.land/std@0.209.0/assert/mod.ts"; 13 | import { verify } from "./auth.ts"; 14 | 15 | const baseURL = Deno.env.get("BASE_URL"); 16 | 17 | const uuidRegex = 18 | /^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i; 19 | 20 | let token = ""; 21 | 22 | /** 23 | * Test #1 - Simple Registration 24 | */ 25 | Deno.test("Simple Registration", async () => { 26 | const body = { 27 | email: "test@mail.com", 28 | password: "password", 29 | }; 30 | 31 | const resp = await fetch(`${baseURL}/register`, { 32 | method: "POST", 33 | headers: { "Content-Type": "application/json" }, 34 | body: JSON.stringify(body), 35 | }); 36 | 37 | if (!resp.ok) { 38 | throw Error( 39 | `Error making registration request. ${resp.status} ${resp.statusText}`, 40 | ); 41 | } 42 | 43 | /* Response should be a UUID */ 44 | const uuid = await resp.text(); 45 | assert(uuidRegex.test(uuid), `Returned value ${uuid} is not a valid UUIDv4`); 46 | }); 47 | 48 | /** 49 | * Test #2 - Registration with an already-used email address 50 | */ 51 | Deno.test("Registration with taken email address", async () => { 52 | const body = { 53 | email: "test@mail.com", 54 | password: "password", 55 | }; 56 | 57 | const resp = await fetch(`${baseURL}/register`, { 58 | method: "POST", 59 | headers: { "Content-Type": "application/json" }, 60 | body: JSON.stringify(body), 61 | }); 62 | 63 | /* Must consume the response body */ 64 | const _text = await resp.text(); 65 | 66 | if (!resp.ok) { 67 | return assertEquals( 68 | resp.statusText, 69 | "Conflict", 70 | `Unexpected response from the server. Expected 'Conflict', recieved ${resp.statusText}`, 71 | ); 72 | } 73 | 74 | throw new Error(`Unexpected Response. ${resp.statusText}`); 75 | }); 76 | 77 | /** 78 | * Test #3 - Simple Login test 79 | */ 80 | Deno.test("Simple login request", async () => { 81 | const body = { 82 | email: "test@mail.com", 83 | password: "password", 84 | }; 85 | 86 | const resp = await fetch(`${baseURL}/login`, { 87 | method: "POST", 88 | headers: { "Content-Type": "application/json" }, 89 | body: JSON.stringify(body), 90 | }); 91 | 92 | if (!resp.ok) { 93 | throw new Error(`Unexpected Response: ${resp.status} ${resp.statusText}`); 94 | } 95 | 96 | token = await resp.text(); 97 | assert(await verify(token), "Token could not be verified."); 98 | }); 99 | 100 | /** 101 | * Test #4 - Test an invalid login 102 | */ 103 | Deno.test("Invalid login credentials", async () => { 104 | const body = { 105 | email: "bad@mail.com", 106 | password: "password", 107 | }; 108 | 109 | const resp = await fetch(`${baseURL}/login`, { 110 | method: "POST", 111 | headers: { "Content-Type": "application/json" }, 112 | body: JSON.stringify(body), 113 | }); 114 | 115 | const text = await resp.text(); 116 | if (!resp.ok) { 117 | assertEquals( 118 | resp.status, 119 | 401, 120 | `Unexpected response status code. Expected 401, received ${resp.status}`, 121 | ); 122 | assertEquals( 123 | text, 124 | "Unauthorized", 125 | `Unexpected response text. Expected 'Unauthorized', received ${text}`, 126 | ); 127 | 128 | return; 129 | } 130 | }); 131 | 132 | /** 133 | * Test #5 - Making a public paste 134 | */ 135 | Deno.test("Making a public paste", async () => { 136 | const PUBLIC = Deno.env.get("PUBLIC_PASTES"); 137 | const body = "Hello, World!"; 138 | 139 | const response = await fetch(`${baseURL}/paste`, { 140 | method: "POST", 141 | headers: { "Content-Type": "text/plain" }, 142 | body: body, 143 | }); 144 | 145 | if (!response.ok) { 146 | if (!PUBLIC) { 147 | assertEquals( 148 | response.status, 149 | 401, 150 | `Unexpected HTTP status, expected 401, recieved ${response.status}`, 151 | ); 152 | return; 153 | } 154 | 155 | throw new Error( 156 | `Unexpected Error: ${response.status} ${response.statusText}`, 157 | ); 158 | } 159 | 160 | const uuid = await response.text(); 161 | assert(uuidRegex.test(uuid), `Returned value ${uuid} is not a valid UUIDv4`); 162 | }); 163 | 164 | /** 165 | * Test #6 - Validating an existing access token 166 | */ 167 | Deno.test("Validating a token", async () => { 168 | const resp = await fetch(`${baseURL}/auth/status`, { 169 | headers: { "X-Access-Token": token }, 170 | }); 171 | 172 | if (!resp.ok) { 173 | throw new Error(`Couldn't verify token.`); 174 | } 175 | 176 | const text = await resp.text(); 177 | assertEquals( 178 | text, 179 | "OK", 180 | `Unexpected response, expected 'OK', recieved ${text}`, 181 | ); 182 | }); 183 | 184 | /** 185 | * Test #7 - Fetching all of a user's pastes (authenticated) 186 | */ 187 | Deno.test("Fetching user's pastes", async () => { 188 | /* Intially, route should return [] */ 189 | const resp = await fetch(`${baseURL}/paste`, { 190 | headers: { "X-Access-Token": token }, 191 | }); 192 | 193 | if (!resp.ok) { 194 | throw new Error(`Error fetching pastes. ${resp.status} ${resp.statusText}`); 195 | } 196 | 197 | let json = await resp.json(); 198 | assertEquals( 199 | JSON.stringify(json), 200 | "[]", 201 | `Unexpected response. Expected [], recieved ${JSON.stringify(json)}`, 202 | ); 203 | 204 | /* Add a new paste */ 205 | const resp2 = await fetch(`${baseURL}/paste`, { 206 | method: "POST", 207 | headers: { "Content-Type": "text/plain", "X-Access-Token" : token }, 208 | body: "Hello, World!", 209 | }); 210 | 211 | if (!resp.ok) { 212 | throw new Error(`Error creating new paste. ${resp2.status} ${resp.statusText}`); 213 | } 214 | 215 | const uuid = await resp2.text(); 216 | 217 | /* Ensure new paste is returned in array from GET-/api/paste */ 218 | const resp3 = await fetch(`${baseURL}/paste`, { 219 | headers: { "X-Access-Token": token }, 220 | }); 221 | 222 | if (!resp3.ok) { 223 | throw new Error(`Error fetching pastes. ${resp.status} ${resp.statusText}`); 224 | } 225 | 226 | json = await resp3.json(); 227 | assert(json[0] === uuid, `Expected ${uuid}, recieved ${json[0]}`); 228 | }); 229 | -------------------------------------------------------------------------------- /paste.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A Pastebin-like backend using Zippy 3 | * 4 | * @author John L. Carveth 5 | * @version 1.12.2 6 | * @namespace nutty 7 | * 8 | * Provides basic authentication via /api/login and /api/register routes. 9 | * Tokens are provided with the "X-Access-Token" header 10 | * Post a text file to /api/paste and a UUID is returned on success. 11 | * GET /api/:uuid to retrieve that text file. 12 | * GET /api/paste to return the UUIDs of all stored pastes 13 | */ 14 | import { addRoute, get, listen, post } from "./server.ts"; 15 | import { 16 | extname, 17 | resolve, 18 | SEP, 19 | } from "https://deno.land/std@0.202.0/path/mod.ts"; 20 | import { serveFile } from "https://deno.land/std@0.179.0/http/file_server.ts"; 21 | import { verify } from "./auth.ts"; 22 | import { SQLiteService as service } from "./db.ts"; 23 | import { highlightText } from "https://deno.land/x/speed_highlight_js@v1.2.6/dist/index.js"; 24 | import { detectLanguage } from "https://deno.land/x/speed_highlight_js@v1.2.6/dist/detect.js"; 25 | 26 | import { Layout, LayoutData } from "./templates/layout.ts"; 27 | import { Index } from "./templates/index.ts"; 28 | import { Login } from "./templates/login.ts"; 29 | import { Register } from "./templates/register.ts"; 30 | import { _404 } from "./templates/404.ts"; 31 | import { Burn } from "./templates/burn.ts"; 32 | import { About } from "./templates/about.ts"; 33 | 34 | const SQLiteService = service.getInstance(); 35 | const TARGET_DIR = Deno.env.get("TARGET_DIR") || "/opt/paste/"; 36 | const BASE_URL = Deno.env.get("BASE_URL"); 37 | const PUBLIC_PASTES = Deno.env.get("PUBLIC_PASTES") || false; 38 | const MAX_SIZE = Number(Deno.env.get("MAX_SIZE")) || 1e6; 39 | const DOMAIN = Deno.env.get("DOMAIN"); 40 | 41 | export const PORT = Number.parseInt( Deno.env.get("PORT") ?? 5335); 42 | export const version = "1.12.2"; 43 | 44 | function getCookieValue(cookieString: string, cookieName: string) { 45 | const cookies = cookieString.split("; "); 46 | for (let i = 0; i < cookies.length; i++) { 47 | const cookieParts = cookies[i].split("="); 48 | if (cookieParts[0] === cookieName) { 49 | return cookieParts[1]; 50 | } 51 | } 52 | return null; 53 | } 54 | 55 | function serveIndex() { 56 | const data: LayoutData = { 57 | title: "Paste.ts", 58 | content: Index(), 59 | version, 60 | scripts: [ 61 | ``, 62 | ``, 63 | ], 64 | }; 65 | return new Response(Layout(data), { 66 | headers: { "Content-Type": "text/html" }, 67 | }); 68 | } 69 | 70 | function serveAbout() { 71 | const data: LayoutData = { 72 | title: "About Paste.ts", 73 | content: About(), 74 | version, 75 | stylesheets: [``], 76 | scripts: [ 77 | ``, 78 | ], 79 | }; 80 | 81 | return new Response(Layout(data), { 82 | headers: { "Content-Type": "text/html" }, 83 | }); 84 | } 85 | 86 | /* Serve HTML webpages */ 87 | get("/index.html", serveIndex); 88 | get("/", serveIndex); 89 | get("/about", serveAbout); 90 | get("/login", () => { 91 | const data: LayoutData = { 92 | title: "Paste.ts", 93 | content: Login(), 94 | version, 95 | }; 96 | return new Response(Layout(data), { 97 | headers: { "Content-Type": "text/html" }, 98 | }); 99 | }); 100 | get("/register", () => { 101 | const data: LayoutData = { 102 | title: "Paste.ts", 103 | content: Register(), 104 | version, 105 | }; 106 | 107 | return new Response(Layout(data), { 108 | headers: { "Content-Type": "text/html" }, 109 | }); 110 | }); 111 | 112 | get("/burn/:uuid", (req, _path, params) => { 113 | const filename = params?.uuid; 114 | if (!filename) return new Response("Bad Request", { status: 400 }); 115 | 116 | const data: LayoutData = { 117 | title: "Paste.ts", 118 | version, 119 | content: Burn(`${DOMAIN}/paste/${filename}`), 120 | scripts: [ 121 | '', 122 | '', 123 | ], 124 | }; 125 | 126 | return new Response(Layout(data), { 127 | headers: { "Content-Type": "text/html" }, 128 | }); 129 | }); 130 | 131 | get("/paste/:uuid", async (req, _path, params) => { 132 | const filename = params?.uuid; 133 | const cookie = getCookieValue(req.headers.get("Cookie") ?? "", "token"); 134 | const token = req.headers.get("X-Access-Token") || cookie; 135 | 136 | if (!filename) return new Response("Bad Request", { status: 400 }); 137 | 138 | /* Before checking token, see if a public paste w/ this UUID exists */ 139 | if (PUBLIC_PASTES) { 140 | try { 141 | await Deno.lstat(`${TARGET_DIR}/public/${filename}`); 142 | const text = await Deno.readTextFile(`${TARGET_DIR}/public/${filename}`); 143 | const language = detectLanguage(text); 144 | const highlighted = await highlightText(text, language, false); 145 | 146 | const data: LayoutData = { 147 | title: "Paste.ts", 148 | content: 149 | `
${highlighted}
`, 152 | version, 153 | stylesheets: [''], 154 | scripts: [ 155 | '', 156 | '', 157 | ], 158 | }; 159 | 160 | /* Check if burnable, delete file if so */ 161 | if (SQLiteService.isBurnable(filename)) { 162 | await Deno.remove(`${TARGET_DIR}/public/${filename}`); 163 | SQLiteService.removeBurnable(filename); 164 | } 165 | 166 | return new Response(Layout(data), { 167 | headers: { "Content-Type": "text/html" }, 168 | }); 169 | } catch (_err) { 170 | /* Public paste not found, continue checking authentication */ 171 | } 172 | } 173 | 174 | if (!token) { 175 | /* No token provided, PUBLIC_PASTES is either false or no public paste was found */ 176 | return new Response("Unauthorized", { status: 401 }); 177 | } 178 | 179 | let uuid = ""; 180 | try { 181 | const payload = await verify(token); 182 | uuid = payload.userid as string; 183 | } catch (_err) { 184 | /* Invalid / Expired token was provided */ 185 | return new Response("Unauthorized", { status: 401 }); 186 | } 187 | 188 | /* Check that the directory exists */ 189 | try { 190 | await Deno.lstat(`${TARGET_DIR}/${uuid}/${filename}`); 191 | const text = await Deno.readTextFile(`${TARGET_DIR}/${uuid}/${filename}`); 192 | const language = detectLanguage(text); 193 | const highlighted = await highlightText(text, language, false); 194 | 195 | const data: LayoutData = { 196 | title: "Paste.ts", 197 | content: 198 | `
${highlighted}
`, 199 | version, 200 | stylesheets: [''], 201 | scripts: [''], 202 | }; 203 | /* Check if burnable, delete file if so */ 204 | if (SQLiteService.isBurnable(filename)) { 205 | await Deno.remove(`${TARGET_DIR}/${uuid}/${filename}`); 206 | SQLiteService.removeBurnable(filename); 207 | } 208 | 209 | return new Response(Layout(data), { 210 | headers: { "Content-Type": "text/html" }, 211 | }); 212 | } catch (_err) { 213 | return new Response("Not Found", { status: 404 }); 214 | } 215 | }); 216 | 217 | /** 218 | * Serve static CSS files 219 | * @function 220 | * @name GET-/css/* 221 | * @memberof nutty 222 | * @returns the requested CSS file, or an HTTP error 223 | */ 224 | get("/css/*", async (_req, _path, params) => { 225 | if (!params) return new Response("Bad Request", { status: 400 }); 226 | 227 | const filepath = `static/css/${params[0]}`; 228 | const resolvedPath = resolve(Deno.cwd(), filepath); 229 | 230 | /* Ensure the requested file is contained within the static directory */ 231 | if (!resolvedPath.startsWith(`${Deno.cwd()}${SEP}static${SEP}css`)) { 232 | return new Response("Bad Request", { status: 400 }); 233 | } 234 | 235 | /* Ensure the file has a .css extension to prevent serving non-css files */ 236 | if (extname(resolvedPath) !== ".css") { 237 | return new Response("Bad Request", { status: 400 }); 238 | } 239 | 240 | /* Check for existence of params[0] within /static/css/ */ 241 | try { 242 | await Deno.lstat(filepath); 243 | } catch (_err) { 244 | return new Response("Not found.", { status: 404 }); 245 | } 246 | 247 | try { 248 | const css = await Deno.readTextFile(filepath); 249 | return new Response(css, { headers: { "Content-Type": "text/css" } }); 250 | } catch (_err) { 251 | return new Response("Server Error", { status: 500 }); 252 | } 253 | }); 254 | 255 | /** 256 | * Serve static JS files 257 | * @function 258 | * @name GET-/js/* 259 | * @memberof nutty 260 | * @returns the requested file, or an HTTP error 261 | */ 262 | get("/js/*", async (_req, _path, params) => { 263 | if (!params) return new Response("Bad Request", { status: 400 }); 264 | 265 | const filepath = `static/js/${params[0]}`; 266 | const resolvedPath = resolve(Deno.cwd(), filepath); 267 | 268 | /* Ensure the requested file is contained within the static directory */ 269 | if (!resolvedPath.startsWith(`${Deno.cwd()}${SEP}static${SEP}js`)) { 270 | return new Response("Bad Request", { status: 400 }); 271 | } 272 | 273 | /* Check for existence of params[0] within static/js */ 274 | try { 275 | await Deno.lstat(filepath); 276 | } catch (_err) { 277 | return new Response("Not Found", { status: 404 }); 278 | } 279 | 280 | /* Finally, serve the file */ 281 | try { 282 | const js = await Deno.readTextFile(filepath); 283 | return new Response(js, { headers: { "Content-Type": "text/javascript" } }); 284 | } catch (_err) { 285 | return new Response("Server Error", { status: 500 }); 286 | } 287 | }); 288 | 289 | /** 290 | * Authenticate with the API to recieve an access token 291 | * @function 292 | * @name POST-/api/login 293 | * @memberof nutty 294 | * @param {string} uuid - the UUID that was obtained through registration 295 | * @param {string} password - the valid password for the account 296 | * @returns {string} a jsonwebtoken on success 297 | */ 298 | post("/api/login", async (req, _path, _params) => { 299 | let body; 300 | if (req.headers.get("Content-Type")?.includes("x-www-form-urlencoded")) { 301 | const query = await req.text(); 302 | const params = new URLSearchParams(query); 303 | body = { 304 | email: params.get("email"), 305 | password: params.get("password"), 306 | }; 307 | } else { 308 | body = await req.json(); 309 | } 310 | 311 | const email = body.email; 312 | const password = body.password; 313 | 314 | if (!email || !password) { 315 | return new Response("Invalid request. Missing parameters.", { 316 | status: 400, 317 | }); 318 | } 319 | try { 320 | const token = await SQLiteService.login(email, password); 321 | const headers = { 322 | "Set-Cookie": `token=${token}; Max-Age=86400; Path=/`, 323 | }; 324 | 325 | if (req.headers.get("Accept")?.includes("text/html")) { 326 | headers["Location"] = `/`; 327 | return new Response(token, { headers, status: 302 }); 328 | } 329 | return new Response(token, { headers }); 330 | } catch (_err) { 331 | if (req.headers.get("Content-Type")?.includes("x-www-form-urlencoded")) { 332 | const headers = { "Location": "/login#failed" }; 333 | return new Response(null, { headers, status: 302 }); 334 | } 335 | return new Response("Unauthorized", { status: 401 }); 336 | } 337 | }); 338 | 339 | /** 340 | * Removes any existing tokens stored as a cookie 341 | * @function 342 | * @name POST-/api/logout 343 | * @memberof nutty 344 | * @returns A 302 redirect 345 | */ 346 | post("/api/logout", (_req, _path, _params) => { 347 | const headers = { 348 | "Set-Cookie": `token=""; Max-Age=0; Path=/`, 349 | "Location": "/", 350 | }; 351 | 352 | return new Response(null, { status: 302, headers }); 353 | }); 354 | 355 | /** 356 | * Registers a new account 357 | * @function 358 | * @name POST-/api/regiser 359 | * @memberof nutty 360 | * @param {string} password - the valid password for the account 361 | * @returns {string} a UUID 362 | */ 363 | post("/api/register", async (req, _path, _params) => { 364 | let body; 365 | if (req.headers.get("Content-Type")?.includes("x-www-form-urlencoded")) { 366 | const query = await req.text(); 367 | const params = new URLSearchParams(query); 368 | body = { 369 | email: params.get("email"), 370 | password: params.get("password"), 371 | }; 372 | } else { 373 | body = await req.json(); 374 | } 375 | 376 | const email = body.email; 377 | const password = body.password; 378 | 379 | if (!email || !password) { 380 | return new Response("Missing parameters", { status: 400 }); 381 | } 382 | 383 | try { 384 | const uuid = SQLiteService.register(email, password); 385 | 386 | if (req.headers.get("Accept")?.includes("text/html")) { 387 | const headers = { "Location": "/login" }; 388 | return new Response(null, { headers, status: 302 }); 389 | } 390 | 391 | return new Response(uuid); 392 | } catch (err) { 393 | if (err.message === "UNIQUE constraint failed: users.email") { 394 | return new Response("Conflict", { status: 409 }); 395 | } 396 | return new Response("Server Error", { status: 500 }); 397 | } 398 | }); 399 | 400 | /** 401 | * Allows the client to check the validity of their login token 402 | * @function 403 | * @name GET-/api/auth/status 404 | * @memberof nutty 405 | * @returns {string} 200 OK if the token is valid, 401 Unauthorized if not. 406 | */ 407 | get("/api/auth/status", async (req, _path, _params) => { 408 | const cookie = getCookieValue(req.headers.get("Cookie") ?? "", "token"); 409 | const token = req.headers.get("X-Access-Token") || cookie; 410 | 411 | if (!token) return new Response("Unauthorized", { status: 401 }); 412 | try { 413 | // verify() throws an error if token is invalid 414 | await verify(token); 415 | return new Response("OK", { status: 200 }); 416 | } catch (_err) { 417 | return new Response("Unauthorized", { status: 401 }); 418 | } 419 | }); 420 | 421 | /** 422 | * Stores the provided text file on the filesystem, and returns a UUID to the client 423 | * for later access. 424 | * @function 425 | * @name POST-/api/paste 426 | * @memberof nutty 427 | * @param {string} text - the text content to be stored 428 | * @returns {string} a UUID identifying the new paste 429 | */ 430 | post("/api/paste", async (req, _path, _params) => { 431 | const contentLength = req.headers.get("Content-Length"); 432 | 433 | if (contentLength && Number(contentLength) > MAX_SIZE) { 434 | return new Response("Payload too large.", { status: 413 }); 435 | } 436 | const filename = crypto.randomUUID(); 437 | const cookie = getCookieValue(req.headers.get("Cookie") ?? "", "token"); 438 | const token = req.headers.get("X-Access-Token") || cookie; 439 | 440 | const accepts = req.headers.get("Accept"); 441 | const contentType = req.headers.get("Content-Type"); 442 | const html = (accepts !== null) ? accepts.includes("text/html") : false; 443 | 444 | let text; 445 | 446 | /* Add the UUID to the burn_on_read table if param set */ 447 | let burn; 448 | 449 | if (contentType) { 450 | if (contentType.includes("application/x-www-form-urlencoded") && html) { 451 | const formData = await req.formData(); 452 | text = formData.get("text") as string; 453 | burn = formData.get("burn") as string; 454 | } else { 455 | text = await req.text(); 456 | const queryParams = new URL(req.url).searchParams; 457 | burn = queryParams.get("burn"); 458 | } 459 | } 460 | if (burn) { 461 | console.log(`BURN`); 462 | SQLiteService.createBurnable(filename); 463 | } 464 | 465 | const data = (new TextEncoder()).encode(text); 466 | 467 | if (!token) { 468 | if (!PUBLIC_PASTES) { 469 | return new Response( 470 | "Unauthorized", 471 | { 472 | status: 401, 473 | }, 474 | ); 475 | } else { 476 | await Deno.writeFile(`${TARGET_DIR}/public/${filename}`, data); 477 | if (accepts !== null && accepts.includes("text/html")) { 478 | return new Response(null, { 479 | status: 302, 480 | headers: { 481 | "Location": (burn ? `/burn/${filename}` : `/paste/${filename}`), 482 | }, 483 | }); 484 | } 485 | return new Response(filename); 486 | } 487 | } 488 | 489 | let uuid = ""; 490 | try { 491 | const payload = await verify(token); 492 | uuid = payload.userid as string; 493 | } catch (_err) { 494 | return new Response("Unauthorized", { status: 401 }); 495 | } 496 | 497 | await Deno.mkdir(`${TARGET_DIR}/${uuid}`, { recursive: true }); 498 | await Deno.writeFile(`${TARGET_DIR}/${uuid}/${filename}`, data); 499 | 500 | if (accepts !== null && accepts.includes("text/html")) { 501 | return new Response(null, { 502 | status: 302, 503 | headers: { 504 | "Location": (burn ? `/burn/${filename}` : `/paste/${filename}`), 505 | }, 506 | }); 507 | } 508 | return new Response(filename); 509 | }); 510 | 511 | /** 512 | * Returns an array of UUIDs of all pastes belonging to the user 513 | * @function 514 | * @name GET-/api/paste 515 | * @memberof nutty 516 | * @returns {Array} an array of UUIDs associated to pastes 517 | */ 518 | get("/api/paste", async (req, _path, _params) => { 519 | const cookie = getCookieValue(req.headers.get("Cookie") ?? "", "token"); 520 | const token = req.headers.get("X-Access-Token") || cookie; 521 | 522 | if (token) { 523 | let uuid = ""; 524 | try { 525 | const payload = await verify(token); 526 | uuid = payload.userid as string; 527 | } catch (_err) { 528 | return new Response("Unauthorized", { status: 401 }); 529 | } 530 | 531 | /* Check that directory exists */ 532 | try { 533 | await Deno.lstat(`${TARGET_DIR}/${uuid}`); 534 | } catch (_err) { 535 | return new Response(JSON.stringify([])); 536 | } 537 | 538 | const files = []; 539 | for await (const filename of Deno.readDir(`${TARGET_DIR}/${uuid}`)) { 540 | files.push(filename.name); 541 | } 542 | return new Response(JSON.stringify(files)); 543 | } else { 544 | /* Return top 5 most recent public pastes, if PUBLIC_PASTES=1 */ 545 | if (!PUBLIC_PASTES) { 546 | return new Response("Unauthorized", { status: 401 }); 547 | } 548 | 549 | const publicFiles = []; 550 | let recentFiles = []; 551 | try { 552 | const publicDir = `${TARGET_DIR}/public`; 553 | 554 | for await (const dirEntry of Deno.readDir(publicDir)) { 555 | const file = await Deno.stat(`${publicDir}/${dirEntry.name}`); 556 | file.name = dirEntry.name; 557 | publicFiles.push(file); 558 | } 559 | 560 | recentFiles = publicFiles.sort((a, b) => { 561 | return b.mtime!.getTime() - a.mtime!.getTime(); 562 | }).slice(0, 5); 563 | } catch (_err) { 564 | return new Response("Server Error", { status: 500 }); 565 | } 566 | return new Response(JSON.stringify(recentFiles.map((item) => item.name))); 567 | } 568 | }); 569 | 570 | /** 571 | * Returns the current version of Nutty 572 | * @function 573 | * @name GET-/api/version 574 | * @memberof nutty 575 | * @returns {string} the current version of Nutty 576 | */ 577 | get("/api/version", (_req, _path, _params) => { 578 | return new Response(version); 579 | }); 580 | 581 | /** 582 | * Symlinks a paste to the public folder, allowing it to be accessed by anyone 583 | * without a login token. 584 | * TODO: Is GET the best method for this route? PUT instead? 585 | * TODO: What if paste is already public? 586 | * @function 587 | * @name GET-/api/share/:uuid 588 | * @param {string} uuid - the uuid of the paste to be made public 589 | * @memberof nutty 590 | * @returns {string} the a URL pointing to the public paste 591 | */ 592 | get("/api/share/:uuid", async (req, _path, params) => { 593 | const uuid = params?.uuid; 594 | const cookie = getCookieValue(req.headers.get("Cookie") ?? "", "token"); 595 | const token = req.headers.get("X-Access-Token") || cookie; 596 | let userid = ""; 597 | 598 | if (!token) return new Response("Unauthorized"); 599 | try { 600 | const payload = await verify(token); 601 | userid = payload.userid as string; 602 | } catch (_err) { 603 | return new Response("Unauthorized"); 604 | } 605 | 606 | // check if file exists 607 | try { 608 | await Deno.lstat(`${TARGET_DIR}/${userid}/${uuid}`); 609 | } catch (_err) { 610 | return new Response("File not found."); 611 | } 612 | 613 | //File exists, create symlink 614 | await Deno.symlink( 615 | `${TARGET_DIR}/${userid}/${uuid}`, 616 | `${TARGET_DIR}/public/${uuid}`, 617 | ); 618 | // Return https://api.jlcarveth.dev/api/:uuid 619 | return new Response(`${BASE_URL}/${uuid}`); 620 | }); 621 | 622 | // Dynamic URLs have to be matched last 623 | /** 624 | * Returns the paste with the given UUID, if the user has access/the paste is public. 625 | * @function 626 | * @name GET-/api/:uuid 627 | * @param {string} uuid - the uuid of the paste to retrieve 628 | * @memberof nutty 629 | * @returns {string} the contents of the paste with given UUID 630 | */ 631 | get("/api/:uuid", async (req, _path, params) => { 632 | const filename = params?.uuid; 633 | const cookie = getCookieValue(req.headers.get("Cookie") ?? "", "token"); 634 | const token = req.headers.get("X-Access-Token") || cookie; 635 | 636 | if (!filename) return new Response("Bad Request", { status: 400 }); 637 | 638 | let uuid = ""; 639 | // Before checking token, look in TARGET_DIR/public for the uuid 640 | try { 641 | await Deno.lstat(`${TARGET_DIR}/public/${filename}`); 642 | const response = await serveFile(req, TARGET_DIR + "/public/" + filename); 643 | 644 | /* Check if burnable, delete file if so */ 645 | if (SQLiteService.isBurnable(filename)) { 646 | await Deno.remove(`${TARGET_DIR}/public/${filename}`); 647 | SQLiteService.removeBurnable(filename); 648 | } 649 | return response; 650 | } catch (_err) { 651 | // File not found in public/, continue with authentication 652 | } 653 | if (!token) return new Response("Unauthorized", { status: 401 }); 654 | try { 655 | const payload = await verify(token); 656 | uuid = payload.userid as string; 657 | } catch (_err) { 658 | return new Response("Unauthorized", { status: 401 }); 659 | } 660 | try { 661 | await Deno.lstat(`${TARGET_DIR}/${uuid}/${filename}`); 662 | } catch (_err) { 663 | return new Response("Not Found", { status: 404 }); 664 | } 665 | const response = serveFile(req, TARGET_DIR + "/" + uuid + "/" + filename); 666 | /* Check if burnable, delete file if so */ 667 | if (SQLiteService.isBurnable(filename)) { 668 | await Deno.remove(`${TARGET_DIR}/${uuid}/${filename}`); 669 | SQLiteService.removeBurnable(filename); 670 | } 671 | 672 | return response; 673 | }); 674 | 675 | /** 676 | * Removes the paste with the given UUID, if the user has access. 677 | * @function 678 | * @name DELETE-/api/:uuid 679 | * @param {string} uuid - the uuid of the paste to remove 680 | * @memberof nutty 681 | * @returns {string} OK 682 | */ 683 | addRoute("/api/:uuid", "DELETE", async (req, _path, params) => { 684 | const cookie = getCookieValue(req.headers.get("Cookie") ?? "", "token"); 685 | const token = req.headers.get("X-Access-Token") || cookie; 686 | if (!token) return new Response("Unauthorized", { status: 401 }); 687 | let userID = ""; 688 | try { 689 | const payload = await verify(token); 690 | userID = payload.userid as string; 691 | } catch (_err) { 692 | return new Response("Unauthorized", { status: 401 }); 693 | } 694 | 695 | const uuid = params?.uuid; 696 | // Try to remove public/uuid if it exists 697 | try { 698 | await Deno.remove(`${TARGET_DIR}/public/${uuid}`); 699 | } catch (_err) { 700 | // Do nothing, symlink not found 701 | } 702 | try { 703 | await Deno.remove(`${TARGET_DIR}/${userID}/${uuid}`); 704 | } catch (err) { 705 | if (err instanceof Deno.errors.NotFound) { 706 | return new Response("Not Found", { status: 404 }); 707 | } 708 | return new Response("An unexpected error has occurred.", { status: 500 }); 709 | } 710 | return new Response("OK"); 711 | }); 712 | 713 | /* A catch-all 404 page if no other route matches. */ 714 | get("*", () => { 715 | const data: LayoutData = { 716 | title: "Not Found", 717 | content: _404(), 718 | version, 719 | }; 720 | return new Response(Layout(data), { 721 | headers: { "Content-Type": "text/html" }, 722 | }); 723 | }); 724 | 725 | listen(PORT); 726 | -------------------------------------------------------------------------------- /runtests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | deno_path=$(which deno) 3 | 4 | if [ -z "$deno_path" ]; then 5 | echo "Error: deno is not installed" 6 | exit 1 7 | fi 8 | 9 | # Set the DB_NAME environment variable 10 | export DB_NAME="test.db" 11 | 12 | # Start the paste service 13 | $deno_path run -A --unstable-ffi paste.ts & 14 | paste_pid=$(ps aux | grep "$deno_path run -A --unstable-ffi paste.ts" | grep -v grep | awk '{print $2}') 15 | 16 | # Run tests 17 | $deno_path test -A --unstable-ffi 18 | 19 | # Cleanup testing environment 20 | kill $paste_pid 21 | rm test.db 22 | 23 | -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Zippy - Minimal Web Server 3 | * 4 | * This was made for fun, should probably not be used for serious production workloads. 5 | * - John L. Carveth 6 | */ 7 | import { Route, RouteHandler } from "./types.ts"; 8 | 9 | const routes: Route[] = []; 10 | const DEBUG = Deno.env.get("DEBUG") === "true"; 11 | 12 | 13 | /** 14 | * Handles incoming HTTP requests 15 | */ 16 | async function serveHttp(conn: Deno.Conn) { 17 | const httpConn = Deno.serveHttp(conn); 18 | 19 | for await (const requestEvent of httpConn) { 20 | const method = requestEvent.request.method; 21 | const path = requestEvent.request.url; 22 | 23 | if (DEBUG) { 24 | console.log(`${method}-${path}`); 25 | } 26 | 27 | /* Iterate through registered routes for a match */ 28 | for (const route of routes) { 29 | if (route.action === method && route.path.test(path)) { 30 | const match = route.path.exec(requestEvent.request.url); 31 | const params = match?.pathname.groups; 32 | /* Pass off the request info, params to the registered handler */ 33 | const response = route.handler( 34 | requestEvent.request, 35 | route.path, 36 | params, 37 | ); 38 | requestEvent.respondWith(response); 39 | break; 40 | } 41 | } 42 | } 43 | } 44 | 45 | /** 46 | * Assigns a new GET route to the web server. 47 | */ 48 | export function get(path: string, handler: RouteHandler) { 49 | const urlPattern = new URLPattern({ pathname: path }); 50 | routes.push({ path: urlPattern, action: "GET", handler: handler }); 51 | } 52 | 53 | /** 54 | * Assigns a new POST route to the web server. 55 | */ 56 | export function post(path: string, handler: RouteHandler) { 57 | const urlPattern = new URLPattern({ pathname: path }); 58 | routes.push({ path: urlPattern, action: "POST", handler: handler }); 59 | } 60 | 61 | /** 62 | * Assign a new route to the web server. 63 | */ 64 | export function addRoute(path: string, action: string, handler: RouteHandler) { 65 | const urlPattern = new URLPattern({ pathname: path }); 66 | routes.push({ path: urlPattern, action: action, handler: handler }); 67 | } 68 | 69 | /** 70 | * Call this function to start the server. 71 | * @param port The port the server will listen to 72 | */ 73 | export async function listen(port: number) { 74 | const listener = Deno.listen({ port: port }); 75 | if (DEBUG) console.log(`Now listening on localhost:${port}/`); 76 | for await (const conn of listener) { 77 | serveHttp(conn); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /static/css/about.css: -------------------------------------------------------------------------------- 1 | body { 2 | line-height: 1.6; 3 | } 4 | 5 | h2, h3 { 6 | color: #333; 7 | } 8 | 9 | section { 10 | margin-bottom: 30px; 11 | } 12 | -------------------------------------------------------------------------------- /static/css/colors.css: -------------------------------------------------------------------------------- 1 | /* Colors from tailwindcss.com :) */ 2 | 3 | :root { 4 | /* Slate Colors */ 5 | --slate-50: #f8fafc; 6 | --slate-100: #f1f5f9; 7 | --slate-200: #e2e8f0; 8 | --slate-300: #cbd5e1; 9 | --slate-400: #94a3b8; 10 | --slate-500: #64748b; 11 | --slate-600: #475569; 12 | --slate-700: #334155; 13 | --slate-800: #1e293b; 14 | --slate-900: #0f172a; 15 | --slate-950: #020617; 16 | 17 | /* Gray Colors */ 18 | --gray-50: #f9fafb; 19 | --gray-100: #f3f4f6; 20 | --gray-200: #e5e7eb; 21 | --gray-300: #d1d5db; 22 | --gray-400: #9ca3af; 23 | --gray-500: #6b7280; 24 | --gray-600: #4b5563; 25 | --gray-700: #374151; 26 | --gray-800: #1f2937; 27 | --gray-900: #111827; 28 | --gray-950: #030712; 29 | 30 | /* Zinc Colors */ 31 | --zinc-50: #fafafa; 32 | --zinc-100: #f4f4f5; 33 | --zinc-200: #e4e4e7; 34 | --zinc-300: #d4d4d8; 35 | --zinc-400: #a1a1aa; 36 | --zinc-500: #71717a; 37 | --zinc-600: #52525b; 38 | --zinc-700: #3f3f46; 39 | --zinc-800: #27272a; 40 | --zinc-900: #18181b; 41 | --zinc-950: #09090b; 42 | 43 | /* Neutral Colors */ 44 | --neutral-50: #fafafa; 45 | --neutral-100: #f5f5f5; 46 | --neutral-200: #e5e5e5; 47 | --neutral-300: #d4d4d4; 48 | --neutral-400: #a3a3a3; 49 | --neutral-500: #737373; 50 | --neutral-600: #525252; 51 | --neutral-700: #404040; 52 | --neutral-800: #262626; 53 | --neutral-900: #171717; 54 | --neutral-950: #0a0a0a; 55 | 56 | /* Red Colors */ 57 | --red-50: #fef2f2; 58 | --red-100: #fee2e2; 59 | --red-200: #fecaca; 60 | --red-300: #fca5a5; 61 | --red-400: #f87171; 62 | --red-500: #ef4444; 63 | --red-600: #dc2626; 64 | --red-700: #b91c1c; 65 | --red-800: #991b1b; 66 | --red-900: #7f1d1d; 67 | --red-950: #450a0a; 68 | 69 | /* Orange Colors */ 70 | --orange-50: #fff7ed; 71 | --orange-100: #ffedd5; 72 | --orange-200: #fed7aa; 73 | --orange-300: #fdba74; 74 | --orange-400: #fb923c; 75 | --orange-500: #f97316; 76 | --orange-600: #ea580c; 77 | --orange-700: #c2410c; 78 | --orange-800: #9a3412; 79 | --orange-900: #7c2d12; 80 | --orange-950: #431407; 81 | 82 | /* Amber Colors */ 83 | --amber-50: #fffbeb; 84 | --amber-100: #fef3c7; 85 | --amber-200: #fde68a; 86 | --amber-300: #fcd34d; 87 | --amber-400: #fbbf24; 88 | --amber-500: #f59e0b; 89 | --amber-600: #d97706; 90 | --amber-700: #b45309; 91 | --amber-800: #92400e; 92 | --amber-900: #78350f; 93 | --amber-950: #451a03; 94 | 95 | /* Yellow Colors */ 96 | --yellow-50: #fefce8; 97 | --yellow-100: #fef9c3; 98 | --yellow-200: #fef08a; 99 | --yellow-300: #fde047; 100 | --yellow-400: #facc15; 101 | --yellow-500: #eab308; 102 | --yellow-600: #ca8a04; 103 | --yellow-700: #a16207; 104 | --yellow-800: #854d0e; 105 | --yellow-900: #713f12; 106 | --yellow-950: #422006; 107 | 108 | /* Lime Colors */ 109 | --lime-50: #f7fee7; 110 | --lime-100: #ecfccb; 111 | --lime-200: #d9f99d; 112 | --lime-300: #bef264; 113 | --lime-400: #a3e635; 114 | --lime-500: #84cc16; 115 | --lime-600: #65a30d; 116 | --lime-700: #4d7c0f; 117 | --lime-800: #3f6212; 118 | --lime-900: #365314; 119 | --lime-950: #1a2e05; 120 | 121 | /* Green Colors */ 122 | --green-50: #f0fdf4; 123 | --green-100: #dcfce7; 124 | --green-200: #bbf7d0; 125 | --green-300: #86efac; 126 | --green-400: #4ade80; 127 | --green-500: #22c55e; 128 | --green-600: #16a34a; 129 | --green-700: #15803d; 130 | --green-800: #166534; 131 | --green-900: #14532d; 132 | --green-950: #052e16; 133 | 134 | /* Blue Colors */ 135 | --blue-50: #eff6ff; 136 | --blue-100: #dbeafe; 137 | --blue-200: #bfdbfe; 138 | --blue-300: #93c5fd; 139 | --blue-400: #60a5fa; 140 | --blue-500: #3b82f6; 141 | --blue-600: #2563eb; 142 | --blue-700: #1d4ed8; 143 | --blue-800: #1e40af; 144 | --blue-900: #1e3a8a; 145 | --blue-950: #172554; 146 | 147 | /* Teal Colors */ 148 | --teal-50: #f0fdfa; 149 | --teal-100: #ccfbf1; 150 | --teal-200: #99f6e4; 151 | --teal-300: #5eead4; 152 | --teal-400: #2dd4bf; 153 | --teal-500: #14b8a6; 154 | --teal-600: #0d9488; 155 | --teal-700: #0f766e; 156 | --teal-800: #115e59; 157 | --teal-900: #134e4a; 158 | --teal-950: #042f2e; 159 | 160 | /* Cyan Colors */ 161 | --cyan-50: #ecfeff; 162 | --cyan-100: #cffafe; 163 | --cyan-200: #a5f3fc; 164 | --cyan-300: #67e8f9; 165 | --cyan-400: #22d3ee; 166 | --cyan-500: #06b6d4; 167 | --cyan-600: #0891b2; 168 | --cyan-700: #0e7490; 169 | --cyan-800: #155e75; 170 | --cyan-900: #164e63; 171 | --cyan-950: #083344; 172 | 173 | /* Sky Colors */ 174 | --sky-50: #f0f9ff; 175 | --sky-100: #e0f2fe; 176 | --sky-200: #bae6fd; 177 | --sky-300: #7dd3fc; 178 | --sky-400: #38bdf8; 179 | --sky-500: #0ea5e9; 180 | --sky-600: #0284c7; 181 | --sky-700: #0369a1; 182 | --sky-800: #075985; 183 | --sky-900: #0c4a6e; 184 | --sky-950: #082f49; 185 | } 186 | -------------------------------------------------------------------------------- /static/css/highlight.css: -------------------------------------------------------------------------------- 1 | [class*="shj-lang-"] { 2 | white-space: pre; 3 | margin: 10px 0; 4 | border-radius: 10px; 5 | padding: 30px 20px; 6 | background: white; 7 | color: #112; 8 | box-shadow: 0 0 5px #0001; 9 | text-shadow: none; 10 | font: normal 18px Consolas, "Courier New", Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 11 | line-height: 24px; 12 | box-sizing: border-box; 13 | max-width: min(100%, 100vw) 14 | } 15 | .shj-inline { 16 | margin: 0; 17 | padding: 2px 5px; 18 | display: inline-block; 19 | border-radius: 5px 20 | } 21 | 22 | [class*="shj-lang-"]::selection, 23 | [class*="shj-lang-"] ::selection {background: #bdf5} 24 | [class*="shj-lang-"] > div { 25 | display: flex; 26 | overflow: auto 27 | } 28 | [class*="shj-lang-"] > div :last-child { 29 | flex: 1; 30 | outline: none 31 | } 32 | .shj-numbers { 33 | padding-left: 5px; 34 | counter-reset: line 35 | } 36 | .shj-numbers div {padding-right: 5px} 37 | .shj-numbers div::before { 38 | color: #999; 39 | display: block; 40 | content: counter(line); 41 | opacity: .5; 42 | text-align: right; 43 | margin-right: 5px; 44 | counter-increment: line 45 | } 46 | 47 | .shj-syn-cmnt {font-style: italic} 48 | 49 | .shj-syn-err, 50 | .shj-syn-kwd {color: #e16} 51 | .shj-syn-num, 52 | .shj-syn-class {color: #f60} 53 | .shj-numbers, 54 | .shj-syn-cmnt {color: #999} 55 | .shj-syn-insert, 56 | .shj-syn-str {color: #7d8} 57 | .shj-syn-bool {color: #3bf} 58 | .shj-syn-type, 59 | .shj-syn-oper {color: #5af} 60 | .shj-syn-section, 61 | .shj-syn-func {color: #84f} 62 | .shj-syn-deleted, 63 | .shj-syn-var {color: #f44} 64 | 65 | .shj-oneline {padding: 12px 10px} 66 | .shj-lang-http.shj-oneline .shj-syn-kwd { 67 | background: #25f; 68 | color: #fff; 69 | padding: 5px 7px; 70 | border-radius: 5px 71 | } 72 | -------------------------------------------------------------------------------- /static/css/styles.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,200;0,500;1,800&family=Open+Sans&family=Roboto+Condensed&display=swap'); 2 | 3 | :root { 4 | --primary-bg: #304C89; 5 | --accent-text: #F5E0B7; 6 | } 7 | 8 | body { 9 | padding: 0; 10 | margin: 0; 11 | font-family: 'Open Sans', sans-serif; 12 | min-height: 100vh; 13 | display: flex; 14 | flex-direction: column; 15 | } 16 | 17 | main { 18 | padding: 1rem; 19 | padding-left: 2rem; 20 | padding-right: 2rem; 21 | display: flex; 22 | flex-direction: row; 23 | gap: 20px; 24 | flex: 1; 25 | } 26 | 27 | .navbar { 28 | background-color: var(--sky-900); 29 | overflow: hidden; 30 | top: 0; 31 | width: 100%; 32 | display: flex; 33 | justify-content: space-between; 34 | align-items: center; 35 | } 36 | 37 | .navbar a { 38 | float: left; 39 | display: block; 40 | color: var(--amber-100); 41 | padding: 14px 16px; 42 | text-decoration: none; 43 | font-family: 'Montserrat'; 44 | font-weight: 800; 45 | font-style: italic; 46 | } 47 | 48 | .navbar a:visited { 49 | color: var(--amber-100); 50 | } 51 | 52 | .navbar a:hover { 53 | background-color: #ddd; 54 | color: black; 55 | } 56 | 57 | a { 58 | color: #00A9A5; 59 | } 60 | 61 | a:visited { 62 | color: #082D0F; 63 | } 64 | 65 | .right-side { 66 | display: flex; 67 | align-items: center; 68 | color: white; 69 | } 70 | 71 | .welcome { 72 | padding-right: 2rem; 73 | } 74 | 75 | p.version { 76 | float: right; 77 | padding: 14px 16px; 78 | color: var(--amber-100); 79 | } 80 | 81 | 82 | footer { 83 | display: flex; 84 | flex-direction: column; 85 | align-items: center; 86 | background-color: var(--sky-900); 87 | color: var(--amber-100); 88 | width: 100%; 89 | margin-top: auto; 90 | } 91 | 92 | ul { 93 | line-height: 2; 94 | } 95 | 96 | ul.footer-links { 97 | line-height: 1; 98 | text-align: center; 99 | list-style-type: none; 100 | color: var(--amber-100); 101 | } 102 | 103 | ul.footer-links li { 104 | float: left; 105 | padding-left: 20px; 106 | padding-right: 20px; 107 | } 108 | 109 | ul.footer-links a { 110 | text-decoration: none; 111 | color: var(--amber-100); 112 | } 113 | 114 | div.middle { 115 | margin-left: auto; 116 | margin-right: auto; 117 | margin-top: 4rem; 118 | } 119 | 120 | .text-align-center { 121 | text-align: center; 122 | } 123 | 124 | input.form-control { 125 | border-radius: 4px; 126 | border: 1px solid black; 127 | padding: 10px 20px; 128 | margin-bottom: 10px; 129 | width: 100%; 130 | } 131 | 132 | form { 133 | flex: 4; 134 | display: flex; 135 | flex-direction: column; 136 | 137 | textarea { 138 | font-size: small; 139 | flex-grow: 2; 140 | padding: 10px; 141 | margin-bottom: 10px; 142 | font-family: 'Open Sans', sans-serif; 143 | border: none; 144 | border-radius: 15px; 145 | box-shadow: 8px 14px 38px rgba(39, 44, 49, 0.06), 1px 3px 8px rgba(39, 44, 49, 0.03); 146 | } 147 | } 148 | 149 | .alert { 150 | padding: 1rem; 151 | border: solid 1px #FF5C3A; 152 | background: #FF8C74; 153 | color: var(--amber-100); 154 | border-radius: 10px; 155 | margin-bottom: 2rem; 156 | } 157 | 158 | .code-block { 159 | max-height: 75vh; 160 | overflow: scroll; 161 | padding: 20px; 162 | border: solid 1px #ccc; 163 | border-radius: 20px; 164 | box-shadow: 0px 10px 10px 2px rgba(0, 0, 0, 0.72); 165 | position: relative; 166 | flex-grow: 1; 167 | } 168 | 169 | .copy { 170 | position: absolute; 171 | top: 0; 172 | right: 0; 173 | margin: 20px; 174 | padding: 10px; 175 | border: solid 1px #CCC; 176 | border-radius: 10px; 177 | } 178 | 179 | button { 180 | font-size: 16px; 181 | padding: 12px 16px; 182 | background: var(--sky-800); 183 | color: var(--amber-100); 184 | border: none; 185 | border-radius: 8px; 186 | font-weight: bold; 187 | transition: all ease 0.3s; 188 | 189 | &:hover { 190 | background: var(--sky-600); 191 | color: var(--amber-100); 192 | transform: translate3d(0, -1px, 0) scale(1.03); 193 | } 194 | 195 | &:active { 196 | background: var(--sky-900); 197 | } 198 | 199 | &.lrg { 200 | font-size: 20px; 201 | padding: 15px 30px; 202 | } 203 | 204 | &.sm { 205 | font-size: 14px; 206 | padding: 8px 10px; 207 | } 208 | 209 | &.xs { 210 | font-size: 12px; 211 | padding: 6px 8px; 212 | } 213 | 214 | &.outset { 215 | box-shadow: inset 0 1px 0 hsl(224, 84%, 74%), 0 1px 3px hsla(0, 0%, 0%, 0.2); 216 | } 217 | } 218 | 219 | .buttons { 220 | display: flex; 221 | 222 | button { 223 | width: 100%; 224 | margin: 10px; 225 | } 226 | } 227 | 228 | 229 | .info { 230 | border: solid 1px #ccc; 231 | padding: 2rem; 232 | border-radius: 10px; 233 | background: #fff8ee; 234 | } 235 | 236 | .burn { 237 | accent-color: var(--sky-900); 238 | padding: 15px 0 15px 0; 239 | } 240 | 241 | #recent-posts { 242 | box-shadow: 8px 14px 38px rgba(39, 44, 49, 0.06), 1px 3px 8px rgba(39, 44, 49, 0.03); 243 | padding: 1rem; 244 | background: white; 245 | /* Adds a white background */ 246 | border-radius: 15px; 247 | /* Rounds the corners of the container */ 248 | max-width: 600px; 249 | /* Sets a max-width for better layout */ 250 | flex: 1; 251 | } 252 | 253 | #recent-posts h1 { 254 | color: var(--sky-900); 255 | /* Darker color for the title for better readability */ 256 | font-size: 24px; 257 | /* Larger font size for the title */ 258 | } 259 | 260 | #recent-posts ul { 261 | list-style: none; 262 | /* Removes default list styling */ 263 | padding: 0; 264 | /* Removes default padding */ 265 | padding-top: 10px; 266 | border-top: solid 1px var(--sky-900); 267 | } 268 | 269 | #recent-posts li { 270 | transition: all 0.5s ease; 271 | margin-bottom: 15px; 272 | border-radius: 10px; 273 | background: var(--gray-100); 274 | padding: 10px; 275 | /* Adds padding inside each list item */ 276 | } 277 | 278 | #recent-posts li:hover { 279 | background: #e5e7eb; 280 | } 281 | 282 | #recent-posts a { 283 | text-decoration: none; 284 | color: var(--gray-800); 285 | /* Sets the link color to a dark shade */ 286 | display: block; 287 | /* Makes the link fill the entire list item */ 288 | width: 100%; 289 | } 290 | 291 | @keyframes copied-animation { 292 | 0% { 293 | transform: scale(1); 294 | } 295 | 296 | 50% { 297 | transform: scale(1.2); 298 | } 299 | 300 | 100% { 301 | transform: scale(1); 302 | } 303 | } 304 | 305 | button svg { 306 | animation: copied-animation 0.5s ease-in-out; 307 | } 308 | 309 | @media screen and (max-width: 768px) { 310 | main { 311 | flex-direction: column; 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /static/js/burnable-clip.js: -------------------------------------------------------------------------------- 1 | const url = document.getElementById("url"); 2 | const button = document.getElementById("copy"); 3 | 4 | button.onclick = async (event) => { 5 | event.preventDefault(); 6 | try { 7 | await navigator.clipboard.writeText(url.textContent); 8 | 9 | /* Briefly set the button text to indicate text was copied */ 10 | const buttonText = button.innerText; 11 | button.innerText = "Copied!"; 12 | setTimeout(() => { 13 | button.innerText = buttonText; 14 | }, 2500); 15 | } catch (_err) { 16 | const buttonText = button.innerText; 17 | button.innerText = "Something Went Wrong"; 18 | setTimeout(() => { 19 | button.innerText = buttonText; 20 | }, 2500); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /static/js/clipboard.js: -------------------------------------------------------------------------------- 1 | const button = document.getElementById("copy"); 2 | const codeBlock = document.getElementById("code-block"); 3 | 4 | let timeoutID; 5 | 6 | button.onclick = async (event) => { 7 | event.preventDefault(); 8 | if (timeoutID != null) return; 9 | try { 10 | const textToCopy = codeBlock.innerText; 11 | const originalIcon = button.querySelector("svg"); 12 | await navigator.clipboard.writeText(textToCopy); 13 | console.log( 14 | codeBlock.innerText.length + " characters copied to clipboard.", 15 | ); 16 | 17 | /* Briefly set the button text to indicate text was copied */ 18 | button.innerText = "Copied!"; 19 | const successIcon = document.createElement("svg"); 20 | successIcon.innerHTML = 21 | '\ 22 | \ 23 | \ 24 | \ 25 | '; 26 | button.innerHTML = ''; 27 | button.appendChild(successIcon); 28 | successIcon.classList.add('copied-animation'); 29 | 30 | // Clear any existing timeouts 31 | clearTimeout(timeoutID); 32 | 33 | timeoutID = setTimeout(() => { 34 | button.innerText = ""; 35 | button.appendChild(originalIcon); 36 | timeoutID = null; 37 | }, 2500); 38 | } catch (_err) { 39 | button.innerText = "Something Went Wrong"; 40 | timeoutID = setTimeout(() => { 41 | button.innerText = ""; 42 | button.appendChild(originalIcon); 43 | timeoutID = null; 44 | }, 2500); 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /static/js/login-check.js: -------------------------------------------------------------------------------- 1 | const resp = await fetch("/api/auth/status"); 2 | 3 | if (!resp.ok) { 4 | console.log(`Request failed. ${resp.status} ${resp.statusText}`); 5 | } 6 | 7 | const _status = await resp.text(); 8 | 9 | if (_status === "OK") { 10 | const welcomeElement = document.getElementById("welcome"); 11 | welcomeElement.innerText = "Welcome back!"; 12 | 13 | const logoutLink = document.createElement('a'); 14 | logoutLink.href = "#"; 15 | logoutLink.innerText = "Logout"; 16 | logoutLink.onclick = logout; 17 | 18 | welcomeElement.parentElement.appendChild(logoutLink); 19 | } 20 | 21 | async function logout() { 22 | const resp = await fetch(`/api/logout`, { method: "POST" }); 23 | if (!resp.ok) { 24 | console.error(`Error logging out. ${resp.status} ${resp.statusText}`); 25 | } 26 | 27 | if (resp.redirected) { 28 | window.location = resp.url; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /static/js/recent-posts.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fetches the most recent public pastes from `GET-/api/paste` 3 | * and updates a hidden element to display the pastes. 4 | */ 5 | const response = await fetch(`/api/paste`); 6 | if (!response.ok) { 7 | throw new Error("Could not fetch recent posts."); 8 | } 9 | const posts = await response.json(); 10 | 11 | /* Update the #recent-posts HTML element */ 12 | const htmlElement = document.getElementById("recent-posts"); 13 | const list = document.createElement("ul"); 14 | 15 | const h1 = document.createElement("h1"); 16 | h1.innerText = 'Recent Posts'; 17 | htmlElement.appendChild(h1); 18 | 19 | for (const uuid of posts) { 20 | const li = document.createElement("li"); 21 | const a = document.createElement("a"); 22 | a.innerText = uuid; 23 | a.href = `/paste/${uuid}`; 24 | li.appendChild(a); 25 | list.appendChild(li); 26 | } 27 | 28 | htmlElement.style.display = "block"; 29 | htmlElement.appendChild(list); 30 | -------------------------------------------------------------------------------- /templates/404.ts: -------------------------------------------------------------------------------- 1 | export function _404() { 2 | return ` 3 |
4 |

Not Found

5 |

Go back Home

6 |
7 | ` 8 | } 9 | -------------------------------------------------------------------------------- /templates/about.ts: -------------------------------------------------------------------------------- 1 | export function About() { 2 | return (` 3 |
4 |

About Paste.ts

5 |
6 |

What is Paste.ts?

7 |

Paste.ts is a Pastebin-like website designed for developers to share and collaborate on code snippets. It provides a secure and user-friendly platform for posting, retrieving, and managing text files, making it an invaluable tool for programmers and software developers.

8 |
9 | 10 |
11 |

Key Features

12 |
    13 |
  • User Authentication: Paste.ts offers a robust authentication system, allowing users to register and log in securely using their email and password. This ensures that your pastes are protected and only accessible to you.
  • 14 |
  • Paste Management: With Paste.ts, you can easily post text files (or "pastes") and receive a unique UUID for each paste. You can retrieve your pastes using the UUID or list all your paste UUIDs for easy access.
  • 15 |
  • Public Pastes: If desired, you can share your pastes publicly by creating a symlink in the "public" folder. This feature allows others to access your pastes without authentication, promoting collaboration and code sharing.
  • 16 |
  • Burnable Pastes: Paste.ts offers a unique "burnable" paste feature. When enabled, your paste file will be automatically deleted after being viewed once, providing an extra layer of security and privacy.
  • 17 |
  • Syntax Highlighting: To enhance readability and make your code snippets more visually appealing, Paste.ts automatically detects the programming language and provides syntax highlighting using the "speed_highlight_js" library.
  • 18 |
19 |
20 | 21 |
22 |

Built for Developers

23 |

Paste.ts is built with developers in mind, offering a robust and feature-rich platform for sharing and collaborating on code. Whether you need to share a snippet with a colleague, collaborate on a project, or simply store code snippets for later reference, Paste.ts has you covered.

24 |
25 |
26 | `); 27 | } 28 | -------------------------------------------------------------------------------- /templates/burn.ts: -------------------------------------------------------------------------------- 1 | export function Burn(url : string) { 2 | return ` 3 |
4 | Here is your burnable URL:

${url}

5 | 6 |
`; 7 | } 8 | -------------------------------------------------------------------------------- /templates/footer.ts: -------------------------------------------------------------------------------- 1 | export function Footer() { 2 | return ` 3 | 10 | `; 11 | } 12 | -------------------------------------------------------------------------------- /templates/index.ts: -------------------------------------------------------------------------------- 1 | export function Index() { 2 | return ` 3 |
4 |
5 |
6 | 7 |
8 |
9 |
10 | 11 | 12 |
13 |
14 |
`; 15 | } 16 | -------------------------------------------------------------------------------- /templates/layout.ts: -------------------------------------------------------------------------------- 1 | import { Navbar } from "./navbar.ts"; 2 | import { Footer } from "./footer.ts"; 3 | 4 | export type LayoutData = { 5 | title?: string; 6 | content?: string; 7 | version?: string; 8 | scripts?: string[]; 9 | stylesheets?: string[]; 10 | }; 11 | 12 | export function Layout(data: LayoutData) { 13 | return ` 14 | 15 | 16 | 17 | ${data.title} 18 | 19 | 20 | 21 | 22 | ${data.stylesheets?.join("\n") ?? ""} 23 | 24 | 25 | ${Navbar({ version: data.version })} 26 |
27 | ${data.content} 28 |
29 | ${Footer()} 30 | ${data.scripts?.join("\n") ?? ""} 31 | 32 | `; 33 | } 34 | -------------------------------------------------------------------------------- /templates/login.ts: -------------------------------------------------------------------------------- 1 | const BASE_URL = Deno.env.get("BASE_URL"); 2 | export function Login() { 3 | return (` 4 |
5 | 8 |

Login

9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 | 29 | `); 30 | } 31 | -------------------------------------------------------------------------------- /templates/navbar.ts: -------------------------------------------------------------------------------- 1 | import { LayoutData } from "./layout.ts" 2 | 3 | export function Navbar(data: LayoutData) { 4 | return ` 5 | 15 | ` 16 | } 17 | -------------------------------------------------------------------------------- /templates/register.ts: -------------------------------------------------------------------------------- 1 | const BASE_URL = Deno.env.get("BASE_URL"); 2 | 3 | export function Register() { 4 | return ` 5 |
6 |

Register

7 |
8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 | `; 16 | } 17 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | export type RouteHandler = ( 2 | req: Request, 3 | path: URLPattern, 4 | params?: Record, 5 | ) => Promise | Response; 6 | 7 | export interface Route { 8 | path: URLPattern; 9 | action: string; 10 | handler: RouteHandler; 11 | } 12 | --------------------------------------------------------------------------------