├── 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 | [](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 |
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 |
19 | {% block navbar %}
20 |
21 |
22 | Wastebin
23 |
24 |
25 |
26 | API
27 |
28 |
29 |
30 | GitHub
31 |
32 |
33 |
34 | New
35 |
36 | {% endblock %}
37 |
38 |
39 |
40 | {% block content %}
41 | {% endblock %}
42 |
43 |
44 |
45 | {% block footer %}
46 |
47 | Made with
48 | Python
49 | and a keyboard
50 |
51 | Powered by
52 | FastAPI ,
53 | Deta ,
54 | and long sleepless nights
55 |
56 | {% endblock %}
57 |
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 |
--------------------------------------------------------------------------------