├── static ├── icon.png ├── favicon.ico ├── base.js ├── style.css ├── view.js └── new.js ├── requirements.txt ├── .github └── dependabot.disabled.yaml ├── Spacefile ├── Discovery.md ├── templates ├── 404.html ├── view.html ├── new.html └── base.html ├── license.txt ├── document.py ├── readme.md ├── database.py ├── .gitignore └── main.py /static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemonyte/wastebin/HEAD/static/icon.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | deta~=1.2.0 2 | fastapi~=0.120.4 3 | jinja2~=3.1.6 4 | uvicorn~=0.38.0 5 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemonyte/wastebin/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /static/base.js: -------------------------------------------------------------------------------- 1 | function toggleElement(id) { 2 | document.getElementById(id).classList.toggle("w3-show"); 3 | } 4 | -------------------------------------------------------------------------------- /.github/dependabot.disabled.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | -------------------------------------------------------------------------------- /Spacefile: -------------------------------------------------------------------------------- 1 | # Spacefile Docs: https://go.deta.dev/docs/spacefile/v0 2 | v: 0 3 | icon: ./static/icon.png 4 | micros: 5 | - name: wastebin 6 | src: ./ 7 | engine: python3.9 8 | primary: true 9 | run: uvicorn main:app 10 | dev: uvicorn main:app --reload 11 | public_routes: 12 | - /doc/* 13 | - /raw/* 14 | - /static/* 15 | - /api/get/* 16 | presets: 17 | api_keys: true 18 | provide_actions: true 19 | -------------------------------------------------------------------------------- /Discovery.md: -------------------------------------------------------------------------------- 1 | --- 2 | app_name: "Wastebin" 3 | title: "Wastebin" 4 | tagline: "Simple, easy text & code sharing." 5 | theme_color: "#468fff" 6 | git: "https://github.com/lemonyte/wastebin" 7 | --- 8 | 9 | Wastebin is a simple, easy way to share text and code using links. 10 | Every paste is saved in your personal Space, so you can delete your data at any time. 11 | Try the public demo [here](https://wastebin.deta.dev/doc/readme.md). 12 | 13 | ## Features 14 | 15 | - Built-in syntax highlighting 16 | - Expiration dates 17 | - One-time view 18 | - Filenames 19 | - File upload 20 | - API 21 | - [Open-source](https://github.com/lemonyte/wastebin) 22 | - ~~Waifus?~~ 23 | -------------------------------------------------------------------------------- /templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |

404 - Not Found

6 |
7 | 8 |
9 | {% endblock %} 10 | 11 | {% block scripts %} 12 | {{ super() }} 13 | 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Lemonyte 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 | -------------------------------------------------------------------------------- /templates/view.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block head %} 4 | {{ super() }} 5 | 6 | {% endblock %} 7 | 8 | {% block navbar %} 9 | {{ super() }} 10 | 11 | 12 | Raw 13 | 14 | 15 | 16 | Download 17 | 18 | 19 | 20 | Duplicate 21 | 22 | 23 | 24 | Copy 25 | 26 | 27 | 28 | Share 29 | 30 | {% endblock %} 31 | 32 | {% block content %} 33 |
34 | {% endblock %} 35 | 36 | {% block scripts %} 37 | {{ super() }} 38 | 39 | {% endblock %} 40 | -------------------------------------------------------------------------------- /document.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | import time 4 | from typing import Optional 5 | 6 | from pydantic import BaseModel, Field, FieldValidationInfo, field_validator 7 | 8 | 9 | def generate_id(length: int = 8) -> str: 10 | return "".join(random.choices(string.ascii_lowercase, k=length)) 11 | 12 | 13 | class Document(BaseModel): 14 | content: str 15 | id: str = Field(default_factory=generate_id, min_length=1) 16 | filename: str = "" 17 | highlighting_language: str = "" 18 | date_created: int = Field(default_factory=lambda: int(time.time())) 19 | ephemeral: bool = False 20 | expire_in: Optional[int] = Field(default=None, exclude=True) 21 | expire_at: Optional[int] = Field(default=None, validate_default=True) 22 | 23 | @field_validator("expire_at") 24 | @classmethod 25 | def validate_expire_at(cls, value: Optional[int], info: FieldValidationInfo) -> Optional[int]: 26 | if value is None: 27 | expire_in = info.data.get("expire_in") 28 | date_created = info.data.get("date_created") 29 | if expire_in is not None and date_created is not None: 30 | value = date_created + expire_in 31 | return value 32 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Wastebin 2 | 3 | Welcome to Wastebin, a simple, minimal pastebin service. 4 | 5 | > [!NOTE] 6 | > This app will probably stop receiving updates for the foreseeable future. 7 | > If I decide to pursue this project further, I will probably just start from scratch using another tech stack due to some technical limitations and difficulties in Python & Jinja2. 8 | 9 | ## Demo 10 | 11 | A public demo is available [here](https://wastebin.deta.dev/doc/readme.md). 12 | 13 | You can view the source code of the app in the app itself, for example . 14 | 15 | ## Installation 16 | 17 | Install your own instance of Wastebin on Deta Space by clicking the button below. 18 | 19 | [![Install on Space](https://deta.space/buttons/dark.svg)](https://deta.space/discovery/@lemonpi/wastebin) 20 | 21 | ## Why is it called Wastebin? 22 | 23 | Because "w" was the only letter left that worked with "-astebin" and wasn't already used. 24 | 25 | ## Planned features 26 | 27 | - [ ] Passwords for documents 28 | - [ ] Uploading folders or multiple files at once 29 | 30 | ## Known issues 31 | 32 | - Ephemeral documents are the source of many problems and edge cases 33 | - Loading a document requires JavaScript, this was done to avoid making two requests to the server fetching the document 34 | 35 | ## Privacy 36 | 37 | When you install your own copy of Wastebin, all the data is stored in your own Deta Space. 38 | This means that you have full control over your data, and can delete it at any time. 39 | 40 | ## License 41 | 42 | [MIT License](license.txt) 43 | -------------------------------------------------------------------------------- /static/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | color-scheme: dark; 3 | color: #e0e0e0; 4 | background-color: #1e1e1e; 5 | font-family: Verdana, sans-serif; 6 | } 7 | 8 | footer { 9 | padding: 10px; 10 | color: #858585; 11 | text-align: center; 12 | user-select: none; 13 | -webkit-user-select: none; 14 | } 15 | 16 | .icon { 17 | padding-left: 3px; 18 | padding-right: 5px; 19 | } 20 | 21 | .navbar { 22 | color: white; 23 | background-color: #468fff; 24 | } 25 | 26 | .dropdown-content, 27 | .navbar input, 28 | .navbar select { 29 | color: white !important; 30 | background-color: #3164b1 !important; 31 | width: auto !important; 32 | } 33 | 34 | #content { 35 | /* FIXME: magic numbers */ 36 | width: calc(100vw - 100px); 37 | min-height: calc(100vh - 50px - 95px); 38 | height: fit-content; 39 | margin: 50px 50px 0px 50px; 40 | overflow: auto; 41 | font-family: monospace; 42 | text-align: center; 43 | position: relative; 44 | } 45 | 46 | .document { 47 | text-align: left; 48 | margin: 0; 49 | } 50 | 51 | .document code { 52 | padding: 0 !important; 53 | overflow: unset !important; 54 | } 55 | 56 | #hidden-input, 57 | #highlighted-input { 58 | border: none; 59 | outline: none; 60 | width: inherit; 61 | margin: 0; 62 | padding: 0; 63 | min-height: inherit; 64 | text-align: left; 65 | overflow: hidden; 66 | white-space: pre; 67 | position: absolute; 68 | top: 0; 69 | left: 0; 70 | } 71 | 72 | #hidden-input { 73 | background: transparent; 74 | caret-color: white; 75 | resize: none; 76 | } 77 | 78 | #highlighted-input { 79 | z-index: -1; 80 | } 81 | -------------------------------------------------------------------------------- /templates/new.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block navbar %} 4 | {{ super() }} 5 |
6 | 10 |
11 | 16 | 37 |
38 | 39 | 40 | Save 41 | 42 |
43 | {% endblock %} 44 | 45 | {% block content %} 46 | 47 |
48 | {% endblock %} 49 | 50 | {% block scripts %} 51 | {{ super() }} 52 | 53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block head %} 6 | {% block title %}Wastebin{% endblock %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% endblock %} 15 | 16 | 17 | 18 | 38 | 39 |
40 | {% block content %} 41 | {% endblock %} 42 |
43 | 44 | 58 | 59 | {% block scripts %} 60 | 61 | 62 | {% endblock %} 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /static/view.js: -------------------------------------------------------------------------------- 1 | async function share() { 2 | const data = { 3 | url: window.location.href, 4 | title: "Wastebin", 5 | text: documentData.filename, 6 | }; 7 | if (window.navigator.canShare && window.navigator.canShare(data)) { 8 | await window.navigator.share(data); 9 | } else { 10 | await navigator.clipboard.writeText(window.location.href); 11 | alert("Link copied to clipboard."); 12 | } 13 | } 14 | 15 | async function copy() { 16 | await navigator.clipboard.writeText(documentData.content); 17 | } 18 | 19 | function duplicate() { 20 | localStorage.setItem("document-data", JSON.stringify(documentData)); 21 | window.location.pathname = "/"; 22 | } 23 | 24 | function downloadText() { 25 | const link = document.createElement("a"); 26 | link.href = rawURL; 27 | link.download = documentData.filename || "paste.txt"; 28 | link.click(); 29 | } 30 | 31 | function raw() { 32 | window.location = rawURL; 33 | } 34 | 35 | function handleShortcuts(event) { 36 | if (event.ctrlKey) { 37 | switch (event.key) { 38 | case "d": 39 | event.preventDefault(); 40 | duplicate(); 41 | break; 42 | case "s": 43 | event.preventDefault(); 44 | downloadText(); 45 | break; 46 | } 47 | } 48 | } 49 | 50 | async function load() { 51 | const response = await fetch(`/api/get/${id}`); 52 | if (!response.ok) { 53 | switch (response.status) { 54 | case 404: 55 | document.write(await response.text()); 56 | break; 57 | 58 | default: 59 | alert("An unexpected error occurred. Please try again or report a bug with logs."); 60 | break; 61 | } 62 | return; 63 | } 64 | documentData = await response.json(); 65 | if (documentData.filename) { 66 | document.title = `${documentData.filename} - ${document.title}`; 67 | } 68 | codeElement.textContent = documentData.content; 69 | if (!extension.includes("/")) { 70 | codeElement.classList.add("hljs", `language-${extension}`); 71 | } else if (documentData.highlighting_language) { 72 | codeElement.classList.add("hljs", `language-${documentData.highlighting_language}`); 73 | } 74 | hljs.highlightAll(); 75 | } 76 | 77 | const id = window.location.pathname.replace("/doc/", ""); 78 | const extension = window.location.pathname.split(".").slice(-1)[0]; 79 | const rawURL = `${window.location.origin}/raw/${id}`; 80 | const codeElement = document.getElementsByTagName("code")[0]; 81 | let documentData; 82 | 83 | window.addEventListener("keydown", handleShortcuts); 84 | window.addEventListener("load", load); 85 | -------------------------------------------------------------------------------- /database.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | from abc import ABC, abstractmethod 4 | from threading import Thread 5 | 6 | from deta import Base 7 | 8 | from document import Document 9 | 10 | 11 | class DocumentNotFoundError(Exception): 12 | pass 13 | 14 | 15 | class DocumentExistsError(Exception): 16 | pass 17 | 18 | 19 | class DocumentDB(ABC): 20 | @abstractmethod 21 | def get(self, id: str) -> Document: 22 | pass 23 | 24 | @abstractmethod 25 | def put(self, document: Document) -> str: 26 | pass 27 | 28 | @abstractmethod 29 | def delete(self, id: str): 30 | pass 31 | 32 | 33 | class FileDB(DocumentDB): 34 | def __init__(self, path: str = "./data/documents", clean_interval: int = 60 * 60 * 24): 35 | self.path = path 36 | self.clean_interval = clean_interval 37 | if not os.path.exists(self.path): 38 | os.makedirs(self.path, exist_ok=True) 39 | self._clean_thread = Thread(target=self._clean_expired, daemon=True) 40 | self._clean_thread.start() 41 | 42 | def _clean_expired(self): 43 | while True: 44 | for name in os.listdir(self.path): 45 | path = os.path.join(self.path, name) 46 | if os.path.isfile(path): 47 | with open(path, "r", encoding="utf-8") as file: 48 | document = Document.model_validate_json(file.read()) 49 | if document.expire_at and document.expire_at < int(time.time()): 50 | os.remove(path) 51 | time.sleep(self.clean_interval) 52 | 53 | def get(self, id: str) -> Document: 54 | try: 55 | with open(f"{self.path}/{id}.json", "r", encoding="utf-8") as file: 56 | return Document.model_validate_json(file.read()) 57 | except OSError as exc: 58 | raise DocumentNotFoundError() from exc 59 | 60 | def put(self, document: Document) -> str: 61 | path = f"{self.path}/{document.id}.json" 62 | if os.path.exists(path): 63 | raise DocumentExistsError() 64 | with open(path, "w", encoding="utf-8") as file: 65 | file.write(document.model_dump_json()) 66 | return document.id 67 | 68 | def delete(self, id: str): 69 | path = f"{self.path}/{id}.json" 70 | if os.path.exists(path): 71 | os.remove(path) 72 | 73 | 74 | class DetaDB(DocumentDB): 75 | def __init__(self, name: str): 76 | self._db = Base(name) 77 | 78 | def get(self, id: str) -> Document: 79 | document = self._db.get(id) 80 | if document is None: 81 | raise DocumentNotFoundError() 82 | return Document.model_validate(document) 83 | 84 | def put(self, document: Document) -> str: 85 | try: 86 | self._db.insert(document.model_dump(), key=document.id, expire_at=document.expire_at) # type: ignore 87 | return document.id 88 | except Exception as exc: 89 | raise DocumentExistsError() from exc 90 | 91 | def delete(self, id: str): 92 | self._db.delete(id) 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Custom ignores 2 | __pycache__/ 3 | .vscode/ 4 | .vs/ 5 | .idea/ 6 | .deta/ 7 | .space/ 8 | .env 9 | .venv/ 10 | env/ 11 | [Bb]uild/ 12 | [Dd]ist/ 13 | [Bb]in/ 14 | [Oo]bj/ 15 | *.exe 16 | [Ll]og*/ 17 | !*.spec 18 | 19 | # Byte-compiled / optimized / DLL files 20 | __pycache__/ 21 | *.py[cod] 22 | *$py.class 23 | 24 | # C extensions 25 | *.so 26 | 27 | # Distribution / packaging 28 | .Python 29 | build/ 30 | develop-eggs/ 31 | dist/ 32 | downloads/ 33 | eggs/ 34 | .eggs/ 35 | lib/ 36 | lib64/ 37 | parts/ 38 | sdist/ 39 | var/ 40 | wheels/ 41 | share/python-wheels/ 42 | *.egg-info/ 43 | .installed.cfg 44 | *.egg 45 | MANIFEST 46 | 47 | # PyInstaller 48 | # Usually these files are written by a python script from a template 49 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 50 | *.manifest 51 | #*.spec 52 | 53 | # Installer logs 54 | pip-log.txt 55 | pip-delete-this-directory.txt 56 | 57 | # Unit test / coverage reports 58 | htmlcov/ 59 | .tox/ 60 | .nox/ 61 | .coverage 62 | .coverage.* 63 | .cache 64 | nosetests.xml 65 | coverage.xml 66 | *.cover 67 | *.py,cover 68 | .hypothesis/ 69 | .pytest_cache/ 70 | cover/ 71 | 72 | # Translations 73 | *.mo 74 | *.pot 75 | 76 | # Django stuff: 77 | *.log 78 | local_settings.py 79 | db.sqlite3 80 | db.sqlite3-journal 81 | 82 | # Flask stuff: 83 | instance/ 84 | .webassets-cache 85 | 86 | # Scrapy stuff: 87 | .scrapy 88 | 89 | # Sphinx documentation 90 | docs/_build/ 91 | 92 | # PyBuilder 93 | .pybuilder/ 94 | target/ 95 | 96 | # Jupyter Notebook 97 | .ipynb_checkpoints 98 | 99 | # IPython 100 | profile_default/ 101 | ipython_config.py 102 | 103 | # pyenv 104 | # For a library or package, you might want to ignore these files since the code is 105 | # intended to run in multiple environments; otherwise, check them in: 106 | # .python-version 107 | 108 | # pipenv 109 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 110 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 111 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 112 | # install all needed dependencies. 113 | #Pipfile.lock 114 | 115 | # poetry 116 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 117 | # This is especially recommended for binary packages to ensure reproducibility, and is more 118 | # commonly ignored for libraries. 119 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 120 | #poetry.lock 121 | 122 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 123 | __pypackages__/ 124 | 125 | # Celery stuff 126 | celerybeat-schedule 127 | celerybeat.pid 128 | 129 | # SageMath parsed files 130 | *.sage.py 131 | 132 | # Environments 133 | .env 134 | .venv 135 | env/ 136 | venv/ 137 | ENV/ 138 | env.bak/ 139 | venv.bak/ 140 | 141 | # Spyder project settings 142 | .spyderproject 143 | .spyproject 144 | 145 | # Rope project settings 146 | .ropeproject 147 | 148 | # mkdocs documentation 149 | /site 150 | 151 | # mypy 152 | .mypy_cache/ 153 | .dmypy.json 154 | dmypy.json 155 | 156 | # Pyre type checker 157 | .pyre/ 158 | 159 | # pytype static type analyzer 160 | .pytype/ 161 | 162 | # Cython debug symbols 163 | cython_debug/ 164 | 165 | # PyCharm 166 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 167 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 168 | # and can be added to the global gitignore or merged into this file. For a more nuclear 169 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 170 | #.idea/ -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from fastapi import FastAPI, HTTPException, Request, status 4 | from fastapi.responses import HTMLResponse, PlainTextResponse 5 | from fastapi.staticfiles import StaticFiles 6 | from fastapi.templating import Jinja2Templates 7 | 8 | from database import DetaDB, DocumentExistsError, DocumentNotFoundError 9 | from document import Document 10 | 11 | app = FastAPI() 12 | app.mount("/static", StaticFiles(directory="static"), name="static") 13 | templates = Jinja2Templates(directory="templates") 14 | db = DetaDB("documents") 15 | 16 | 17 | @app.get("/", response_class=HTMLResponse) 18 | async def new(request: Request): 19 | return templates.TemplateResponse("new.html", {"request": request}) 20 | 21 | 22 | @app.get("/doc/{id:path}", response_class=HTMLResponse) 23 | async def view(id: str, request: Request): 24 | return templates.TemplateResponse("view.html", {"request": request, "id": id}) 25 | 26 | 27 | @app.get("/raw/{id:path}", response_class=PlainTextResponse) 28 | async def raw(id: str): 29 | document = await api_get(id) 30 | return document.content 31 | 32 | 33 | @app.post("/api/new", response_model=Document) 34 | async def api_new(document: Document): 35 | try: 36 | db.put(document) 37 | return document 38 | except DocumentExistsError as exc: 39 | raise HTTPException(status_code=status.HTTP_409_CONFLICT) from exc 40 | 41 | 42 | @app.get("/api/get/{id:path}", response_model=Document) 43 | async def api_get(id: str): 44 | try: 45 | document = db.get(os.path.splitext(id)[0]) 46 | if document.ephemeral: 47 | db.delete(id) 48 | return document 49 | except DocumentNotFoundError as exc: 50 | if os.path.isfile(id): 51 | try: 52 | with open(id, "r", encoding="utf-8") as file: 53 | content = file.read() 54 | except UnicodeDecodeError: 55 | with open(id, "rb") as file: 56 | content = repr(file.read()) 57 | return Document( 58 | content=content, 59 | id=id, 60 | filename=id, 61 | ) 62 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) from exc 63 | 64 | 65 | @app.get("/__space/actions") 66 | async def space_actions(): 67 | return { 68 | "actions": [ 69 | { 70 | "name": "new", 71 | "title": "New paste", 72 | "path": "/api/new", 73 | "input": [ 74 | { 75 | "name": "content", 76 | "type": "string", 77 | }, 78 | { 79 | "name": "filename", 80 | "type": "string", 81 | "optional": True, 82 | }, 83 | { 84 | "name": "ephemeral", 85 | "type": "boolean", 86 | }, 87 | { 88 | "name": "expire_in", 89 | "type": "number", 90 | "optional": True, 91 | }, 92 | { 93 | "name": "id", 94 | "type": "string", 95 | "optional": True, 96 | }, 97 | ], 98 | }, 99 | ] 100 | } 101 | 102 | 103 | @app.exception_handler(404) 104 | async def not_found_handler(request: Request, _): 105 | with open(__file__, "r", encoding="utf-8") as file: 106 | text = file.read() 107 | code = text[text.find("@app.exception_handler(404)"):] 108 | return templates.TemplateResponse("404.html", {"request": request, "code": code}, 404) 109 | -------------------------------------------------------------------------------- /static/new.js: -------------------------------------------------------------------------------- 1 | async function save() { 2 | const content = hiddenInput.value.trimEnd(); 3 | if (!content) { 4 | return; 5 | } 6 | try { 7 | saveButton.classList.add("w3-disabled"); 8 | const id = optionElements.id.value.trim(); 9 | const ephemeral = optionElements.ephemeral.checked; 10 | let expireAt = null; 11 | if (optionElements.expire.checked) { 12 | let date = optionElements.expireAtDate.valueAsNumber; 13 | let time = optionElements.expireAtTime.value; 14 | if (date && time) { 15 | date = date / 1000; 16 | time = time.split(":"); 17 | expireAt = date + parseInt(time[0]) * 60 * 60 + parseInt(time[1]) * 60; 18 | } 19 | } 20 | 21 | const response = await fetch("/api/new", { 22 | method: "POST", 23 | headers: { "Content-Type": "application/json" }, 24 | body: JSON.stringify({ 25 | content: content, 26 | ...(id && { id }), 27 | filename: optionElements.filename.value.trim(), 28 | highlighting_language: optionElements.highlightingLanguage.value, 29 | ephemeral: ephemeral, 30 | expire_at: expireAt, 31 | }), 32 | }); 33 | 34 | if (!response.ok) { 35 | switch (response.status) { 36 | case 409: 37 | alert("ID already exists. Please choose another ID."); 38 | break; 39 | 40 | default: 41 | alert("An unexpected error occurred. Please try again or report a bug with logs."); 42 | break; 43 | } 44 | return; 45 | } 46 | 47 | const data = await response.json(); 48 | 49 | if (ephemeral) { 50 | await navigator.clipboard.writeText(`${window.location.href}doc/${data.id}`); 51 | alert("Link copied to clipboard. This link can only be used once."); 52 | } else { 53 | window.location.pathname += `doc/${data.id}`; 54 | } 55 | } finally { 56 | saveButton.classList.remove("w3-disabled"); 57 | } 58 | } 59 | 60 | function fileToContent(file) { 61 | if (!file || (!file.type.startsWith("text/") && !file.type.endsWith("json") && !file.type.endsWith("javascript"))) { 62 | return; 63 | } 64 | optionElements.filename.value = file.name; 65 | const reader = new FileReader(); 66 | reader.onload = (event) => { 67 | hiddenInput.value = event.target.result; 68 | updateInput(); 69 | }; 70 | reader.readAsText(file); 71 | } 72 | 73 | function uploadFile() { 74 | const fileInput = document.createElement("input"); 75 | fileInput.type = "file"; 76 | fileInput.click(); 77 | fileInput.onchange = () => { 78 | const file = fileInput.files[0]; 79 | fileToContent(file); 80 | }; 81 | } 82 | 83 | function syncScroll() { 84 | highlightedInput.firstChild.scrollLeft = hiddenInput.scrollLeft; 85 | } 86 | 87 | function updateInput() { 88 | hiddenInput.rows = hiddenInput.value.split("\n").length; 89 | highlightedInput.style.height = document.getElementById("content").style.height = 90 | hiddenInput.scrollHeight.toString() + "px"; 91 | 92 | if (hiddenInput.value === "") { 93 | hiddenInput.style.color = "white"; 94 | saveButton.classList.add("w3-disabled"); 95 | } else { 96 | hiddenInput.style.color = "transparent"; 97 | saveButton.classList.remove("w3-disabled"); 98 | } 99 | 100 | // Extra newline as a workaround for trailing newline not showing in code element. 101 | highlightedInput.firstChild.textContent = hiddenInput.value + "\n"; 102 | highlightedInput.firstChild.classList.remove(...highlightedInput.firstChild.classList); 103 | if (hljs.listLanguages().includes(optionElements.highlightingLanguage.value)) { 104 | highlightedInput.firstChild.classList.add("hljs"); 105 | highlightedInput.firstChild.classList.add(`language-${optionElements.highlightingLanguage.value}`); 106 | } 107 | hljs.highlightElement(highlightedInput.firstChild); 108 | syncScroll(); 109 | } 110 | 111 | function handleTab(event) { 112 | if (event.key === "Tab") { 113 | event.preventDefault(); 114 | const tab = " "; 115 | const code = event.target.value; 116 | const beforeTab = code.slice(0, event.target.selectionStart); 117 | const afterTab = code.slice(event.target.selectionEnd, event.target.value.length); 118 | const cursorPos = event.target.selectionStart + tab.length; 119 | event.target.value = beforeTab + tab + afterTab; 120 | event.target.selectionStart = cursorPos; 121 | event.target.selectionEnd = cursorPos; 122 | updateInput(); 123 | } 124 | } 125 | 126 | function handleShortcuts(event) { 127 | if (event.ctrlKey) { 128 | switch (event.key) { 129 | case "s": 130 | event.preventDefault(); 131 | save(); 132 | break; 133 | } 134 | } 135 | } 136 | 137 | function load() { 138 | const documentDataJson = localStorage.getItem("document-data"); 139 | if (documentDataJson) { 140 | const documentData = JSON.parse(documentDataJson); 141 | hiddenInput.value = documentData.content; 142 | optionElements.filename.value = documentData.filename; 143 | localStorage.removeItem("document-data"); 144 | } 145 | hiddenInput.selectionEnd = 0; 146 | updateInput(); 147 | 148 | const languages = ["[auto]", ...hljs.listLanguages()]; 149 | for (const language of languages) { 150 | const option = document.createElement("option"); 151 | option.value = language !== "[auto]" ? language : ""; 152 | option.innerText = language; 153 | optionElements.highlightingLanguage.appendChild(option); 154 | } 155 | } 156 | 157 | document.body.ondragover = (event) => { 158 | event.preventDefault(); 159 | }; 160 | 161 | document.body.ondrop = (event) => { 162 | event.preventDefault(); 163 | let file; 164 | if (event.dataTransfer.items && event.dataTransfer.items[0].kind === "file") { 165 | file = event.dataTransfer.items[0].getAsFile(); 166 | } else { 167 | file = event.dataTransfer.files[0]; 168 | } 169 | fileToContent(file); 170 | }; 171 | 172 | const hiddenInput = document.getElementById("hidden-input"); 173 | const highlightedInput = document.getElementById("highlighted-input"); 174 | const saveButton = document.getElementById("save-button"); 175 | const optionElements = { 176 | filename: document.getElementById("option-filename"), 177 | id: document.getElementById("option-id"), 178 | highlightingLanguage: document.getElementById("option-hl-lang"), 179 | ephemeral: document.getElementById("option-ephemeral"), 180 | expire: document.getElementById("option-expire"), 181 | expireAtDate: document.getElementById("option-expire-at-date"), 182 | expireAtTime: document.getElementById("option-expire-at-time"), 183 | } 184 | 185 | hiddenInput.addEventListener("input", updateInput); 186 | hiddenInput.addEventListener("scroll", syncScroll); 187 | hiddenInput.addEventListener("keydown", handleTab); 188 | 189 | window.addEventListener("keydown", handleShortcuts); 190 | window.addEventListener("load", load); 191 | --------------------------------------------------------------------------------