diff --git a/.gitignore b/.gitignore index 634f0f0..8686580 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile index 70300bc..7866111 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9-slim-buster +FROM python:3.9-slim-bullseye WORKDIR /app diff --git a/README.md b/README.md index 170555d..be37285 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 - -image - -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 - -image - -ATP, the source of the issue is not very apparent diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0eca4bc..224caeb 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 @@ -20,6 +21,7 @@ services: - ./server.py:/app/server.py - ./modules:/app/modules - ./assets:/app/assets + - ./pastes:/app/pastes environment: - WATCHFILES_FORCE_POLLING=true diff --git a/docker-compose.yml b/docker-compose.yml index d347454..b1d868e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/modules/args.py b/modules/args.py index b2cf099..8ad36ab 100644 --- a/modules/args.py +++ b/modules/args.py @@ -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() diff --git a/modules/constants.py b/modules/constants.py index f74225d..fee9a5c 100644 --- a/modules/constants.py +++ b/modules/constants.py @@ -8,12 +8,19 @@

Return to homepage

""" +REQUEST_TOO_LARGE_HTML = """ +

Request too large.

+ +

The bytes sent was size "{request_size}" which exceeds max size of {max_size}.

+""" + class HttpResponse(enum.Enum): OK = (200, "success") BAD_REQUEST = (400, "

No URL was found in your request

") NOT_FOUND = (404, NOT_FOUND_HTML) CONFLICT = (409, "

Alias already exists

") + REQUEST_TOO_LARGE = (413, REQUEST_TOO_LARGE_HTML) INVALID_ARGUMENT_EXCEPTION = (422, "

Alias is invalid

") INTERNAL_SERVER_ERROR = (500, "

Internal server error

") def __init__(self, code, content): @@ -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, } diff --git a/modules/sqlite_helpers.py b/modules/sqlite_helpers.py index 62a3b6f..45d0629 100644 --- a/modules/sqlite_helpers.py +++ b/modules/sqlite_helpers.py @@ -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: @@ -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() diff --git a/pastes/keep b/pastes/keep new file mode 100644 index 0000000..e69de29 diff --git a/server.py b/server.py index f56a5b1..bd72314 100644 --- a/server.py +++ b/server.py @@ -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 @@ -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( @@ -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}") @@ -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: @@ -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 )