Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,6 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

pastes/*
!pastes/keep
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.9-slim-buster
FROM python:3.9-slim-bullseye

WORKDIR /app

Expand Down
40 changes: 23 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ sce run z
```
- [ ] ensure the server is running locally at `http://localhost:8000`

if youre running cleezy on sce.sjsu.edu, make an .env file like
```
CLEEZY_PASTE_API_KEY=NOTHING_REALLY
```

## APIs
### To add URL
send HTTP POST request to http://localhost:8000/create_url with body
Expand All @@ -37,6 +42,24 @@ Open http://localhost:8000/list in the browser
- send HTTP POST request to http://localhost:8000/delete/myurl
- verify the url was deleted by opening http://localhost:8000/list in the browser

### To create a paste
if you didnt make an env file like above, no need to pass in api key
```sh
curl -X POST "http://localhost:8000/paste/create" \
-H "Content-Type: application/json" \
-H "X-API-Key: your_secret_key_here" \
-d '{"title": "My First Paste", "text": "hello2"}'

# example response is
# {"status":"success","id":"6556e","url":"/paste/6556e"}
```

### To view a paste
```sh
# put the paste id after the `/paste/` in the url, like below
curl http://localhost:8000/paste/6556e
```

## SQLite Migrations
If you have an existing database and want to add a column, see below
```sh
Expand All @@ -50,20 +73,3 @@ apt install -y sqlite3
ALTER TABLE urls
ADD COLUMN expires_at DATETIME DEFAULT NULL;
```
## QR Embeds
### tl;dr THEY DON'T WORK ON DISCORD

here's the discord embed debugger that tries to scrape embed stuff from the url it is provided. 'internal error' means discord wasn't able to do this

<img width="394" height="400" alt="image" src="https://github.com/user-attachments/assets/adc83ef3-7d06-458b-8cc8-3569023559a6" />

here's some logs from cleezy-nginx after the embed debugger was run and a twitter post with the QR url was sent
```
172.25.0.14 - - [01/Sep/2025:19:03:43 +0000] "GET /qr/EMBEDTEST1 HTTP/1.0" 404 185 "-" "Twitterbot/1.0"
172.25.0.14 - - [01/Sep/2025:19:03:43 +0000] "GET /qr/EMBEDTEST1 HTTP/1.0" 404 185 "-" "Twitterbot/1.0"
```
as you can see, only twitter was able to connect to the site and scrape, display embed stuff

<img width="695" height="391" alt="image" src="https://github.com/user-attachments/assets/1369dd42-09fa-4dda-9d0c-def44dbf3a0d" />

ATP, the source of the issue is not very apparent
2 changes: 2 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ services:
- --qr-code-base-url=http://localhost:8000/find
- --port=8000
- --qr-code-center-image-path=/app/assets/SCE_logo.png
- --paste-directory=/app/pastes
- -vvv
ports:
- 8000:8000
Expand All @@ -20,6 +21,7 @@ services:
- ./server.py:/app/server.py
- ./modules:/app/modules
- ./assets:/app/assets
- ./pastes:/app/pastes
environment:
- WATCHFILES_FORCE_POLLING=true

Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ services:
- --qr-code-cache-state-file=/tmp/cleezy-state
- --qr-code-base-url=https://sce.sjsu.edu/s
- --qr-code-center-image-path=/app/assets/SCE_logo.png
- --paste-directory=/app/pastes
- -vvv
volumes:
- cleezy_data:/tmp/
- ./server.py:/app/server.py
- ./modules:/app/modules
- ./assets:/app/assets
- ./pastes:/app/pastes
env_file:
- .env

volumes:
cleezy_data:
Expand Down
5 changes: 5 additions & 0 deletions modules/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,9 @@ def get_args():
default="America/Los_Angeles",
help="the timezone that url expiration checks will use. defaults to America/Los_Angeles"
)
parser.add_argument(
"--paste-directory",
default="/app/pastes",
help="the timezone that url expiration checks will use. defaults to America/Los_Angeles"
)
return parser.parse_args()
8 changes: 8 additions & 0 deletions modules/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@
<p>Return to <a href="{base_url}">homepage</a></p>
"""

REQUEST_TOO_LARGE_HTML = """
<h1>Request too large.</h1>

<p>The bytes sent was size "{request_size}" which exceeds max size of {max_size}.</p>
"""

class HttpResponse(enum.Enum):

OK = (200, "success")
BAD_REQUEST = (400, "<h1>No URL was found in your request</h1>")
NOT_FOUND = (404, NOT_FOUND_HTML)
CONFLICT = (409, "<h1>Alias already exists</h1>")
REQUEST_TOO_LARGE = (413, REQUEST_TOO_LARGE_HTML)
INVALID_ARGUMENT_EXCEPTION = (422, "<h1>Alias is invalid</h1>")
INTERNAL_SERVER_ERROR = (500, "<h1>Internal server error</h1>")
def __init__(self, code, content):
Expand All @@ -25,6 +32,7 @@ def __init__(self, code, content):
400: HttpResponse.BAD_REQUEST,
404: HttpResponse.NOT_FOUND,
409: HttpResponse.CONFLICT,
413: HttpResponse.REQUEST_TOO_LARGE,
422: HttpResponse.INVALID_ARGUMENT_EXCEPTION,
500: HttpResponse.INTERNAL_SERVER_ERROR,
}
20 changes: 20 additions & 0 deletions modules/sqlite_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def maybe_create_table(sqlite_file: str) -> bool:
cursor.execute(create_pastes_table_query)
cursor.execute(create_table_query)
cursor.execute(create_index_query)

db.commit()
return True
except Exception:
Expand Down Expand Up @@ -195,3 +196,22 @@ def increment_used_column(sqlite_file, alias: str, count=1):
finally:
cursor.close()
db.close()

def insert_paste(sqlite_file: str, paste_id: str, title: str):
db = sqlite3.connect(sqlite_file)
cursor = db.cursor()

try:
sql = "INSERT INTO pastes(id, title) VALUES (?, ?)"
val = (paste_id, title)
cursor.execute(sql, val)
db.commit()
return True
except sqlite3.IntegrityError:
return False
except Exception:
logger.exception("Inserting paste had an error")
return False
finally:
cursor.close()
db.close()
Empty file added pastes/keep
Empty file.
79 changes: 78 additions & 1 deletion server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Optional
from fastapi import FastAPI, Request, HTTPException, Response
from fastapi.responses import RedirectResponse, HTMLResponse, FileResponse
from fastapi.responses import RedirectResponse, HTMLResponse, FileResponse, PlainTextResponse
from fastapi.middleware.cors import CORSMiddleware
import logging
import time
Expand All @@ -18,9 +18,19 @@
from modules.cache import Cache
from modules.qr_code import QRCode

from pathlib import Path
import os

CLEEZY_PASTE_API_KEY = os.getenv("CLEEZY_PASTE_API_KEY")

MAX_PASTE_SIZE_BYTES = 10 * 1024 * 1024

app = FastAPI()
args = get_args()

PASTES_DIR = Path(args.paste_directory)
PASTES_DIR.mkdir(exist_ok=True)

alias_queue = Queue()

app.add_middleware(
Expand Down Expand Up @@ -156,6 +166,56 @@ async def delete_url(alias: str):
else:
raise HTTPException(status_code=HttpResponse.NOT_FOUND.code)

@app.post("/paste/create")
async def create_paste(request: Request):
api_key = request.headers.get("x-api-key")

if CLEEZY_PASTE_API_KEY is None:
logging.warning("CLEEZY_PASTE_API_KEY isn't set, skipping api key check")
elif api_key != CLEEZY_PASTE_API_KEY:
raise HTTPException(status_code=401, detail=f"Invalid API Key '{api_key}'")

try:
payload = await request.json()
except Exception:
logging.exception("/paste/create couldnt parse json")
raise HTTPException(
status_code=HttpResponse.BAD_REQUEST.code,
detail="Invalid JSON payload"
)
text_bytes = payload.get("text", "").encode("utf-8")
if len(text_bytes) > MAX_PASTE_SIZE_BYTES:
raise HTTPException(
status_code=HttpResponse.REQUEST_TOO_LARGE,
detail="Paste content exceeds the maximum allowed size of 10MB."
)

paste_id = generate_alias(len(payload.get('text')))

success = sqlite_helpers.insert_paste(DATABASE_FILE, paste_id, payload.get('title', 'Untitled Paste'))
if not success:
raise HTTPException(
status_code=HttpResponse.INTERNAL_SERVER_ERROR,
detail="Failed to save paste metadata."
)

paste_path = PASTES_DIR / str(paste_id)
paste_path.write_bytes(text_bytes)

return {
"status": "success",
"id": paste_id,
"url": f"/paste/{paste_id}"
}


@app.get("/paste/{paste_id}")
async def view_paste(paste_id: str):
paste_path = PASTES_DIR / paste_id
if not paste_path.exists():
raise HTTPException(status_code=HttpResponse.NOT_FOUND.code)
return PlainTextResponse(paste_path.read_text(encoding="utf-8"))

@app.get("/qr/{alias}")
async def qr(alias: str):
logging.debug(f"/qr code generation called with alias: {alias}")
Expand All @@ -178,6 +238,11 @@ async def qr(alias: str):

@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc):
if exc.status_code not in http_code_to_enum:
return HTMLResponse(
status_code=exc.status_code,
content=exc.detail,
)
status_code_enum = http_code_to_enum[exc.status_code]
content = status_code_enum.content
if status_code_enum == HttpResponse.NOT_FOUND:
Expand All @@ -187,6 +252,18 @@ async def http_exception_handler(request, exc):
requested_url=str(original_url),
base_url=str(base_url)
)
if status_code_enum == HttpResponse.REQUEST_TOO_LARGE:
request_size = "Unknown"
try:
body = await request.json()
if isinstance(body, dict) and "text" in body:
request_size = len(body["text"].encode("utf-8"))
except Exception:
pass
content = content.format(
request_size=request_size,
max_size=MAX_PASTE_SIZE_BYTES,
)
return HTMLResponse(
content=content, status_code=status_code_enum.code
)
Expand Down
Loading