|
17 | 17 | along with MystBin. If not, see <https://www.gnu.org/licenses/>. |
18 | 18 | """ |
19 | 19 | from __future__ import annotations |
| 20 | +import asyncio |
20 | 21 |
|
21 | 22 | import datetime |
22 | 23 | import json |
23 | 24 | import pathlib |
24 | 25 | import re |
| 26 | +import uuid |
25 | 27 | from random import sample |
26 | | -from typing import Dict, List, Optional, Union |
| 28 | +from typing import Coroutine, cast, Dict, List, Optional, Union |
27 | 29 |
|
28 | 30 | from asyncpg import Record |
| 31 | +import pydantic |
29 | 32 | from fastapi import APIRouter, File, Response, UploadFile |
30 | 33 | from fastapi.responses import UJSONResponse |
31 | 34 | from fastapi.encoders import jsonable_encoder |
|
35 | 38 | from utils.db import _recursive_hook as recursive_hook |
36 | 39 | from utils.ratelimits import limit |
37 | 40 |
|
| 41 | +try: |
| 42 | + import ujson |
| 43 | + _loads = ujson.loads |
| 44 | +except ModuleNotFoundError: |
| 45 | + import json |
| 46 | + _loads = json.loads |
| 47 | + |
38 | 48 |
|
39 | 49 | _WORDS_LIST = open(pathlib.Path("utils/words.txt")).readlines() |
40 | 50 | word_list = [word.title() for word in _WORDS_LIST if len(word) > 3] |
|
54 | 64 | del __p, __f # micro-opt, don't keep unneeded variables in-ram |
55 | 65 |
|
56 | 66 |
|
57 | | -def generate_paste_id(): |
| 67 | +def generate_paste_id(n: int = 3): |
58 | 68 | """Generate three random words.""" |
59 | | - word_samples = sample(word_list, 3) |
| 69 | + word_samples = sample(word_list, n) |
60 | 70 | return "".join(word_samples).replace("\n", "") |
61 | 71 |
|
62 | 72 |
|
@@ -137,7 +147,7 @@ async def find_discord_tokens(request: MystbinRequest, pastes: payloads.PastePut |
137 | 147 | response_model=responses.PastePostResponse, |
138 | 148 | responses={ |
139 | 149 | 201: {"model": responses.PastePostResponse}, |
140 | | - 400: {"content": {"application/json": {"example": {"error": "files.length: You have provided a bad paste"}}}}, |
| 150 | + 400: {"content": {"application/json": {"example": {"error": "files.length: You have provided too many files"}}}}, |
141 | 151 | }, |
142 | 152 | status_code=201, |
143 | 153 | name="Create paste", |
@@ -178,42 +188,87 @@ async def put_pastes( |
178 | 188 | return UJSONResponse(paste, status_code=201) |
179 | 189 |
|
180 | 190 |
|
181 | | -@router.put( |
182 | | - "/images/upload/{paste_id}", |
| 191 | +@router.post( |
| 192 | + "/rich-paste", |
183 | 193 | tags=["pastes"], |
| 194 | + response_model=responses.PastePostResponse, |
184 | 195 | responses={ |
185 | 196 | 201: {"model": responses.PastePostResponse}, |
186 | | - 401: {"model": errors.Unauthorized}, |
187 | | - 404: {"model": errors.NotFound}, |
| 197 | + 400: {"content": {"application/json": {"example": {"error": "files.length: You have provided too many files"}}}} |
188 | 198 | }, |
189 | | - include_in_schema=False, |
| 199 | + status_code=201, |
| 200 | + name="Create Rich Paste" |
190 | 201 | ) |
191 | 202 | @limit("postpastes") |
192 | | -async def get_image_upload_link( |
193 | | - request: MystbinRequest, paste_id: str, password: Optional[str] = None, images: List[UploadFile] = File(...) |
| 203 | +async def post_rich_paste( |
| 204 | + request: MystbinRequest, |
| 205 | + data: bytes = File(None, max_length=(__config["paste"]["character_limit"] * __config["paste"]["file_limit"]) + 500), |
| 206 | + images: List[UploadFile] = File(None), |
194 | 207 | ): |
195 | | - """user = request.state.user |
196 | | - if not user: |
197 | | - return UJSONResponse({"error": "Unauthorized", "notice": "You must be signed in to use this route"}, status_code=401)""" |
198 | | - |
199 | | - paste = await request.app.state.db.get_paste(paste_id, password) |
200 | | - if paste is None: |
201 | | - return UJSONResponse({"error": "Not Found"}, status_code=404) |
| 208 | + |
| 209 | + reads = data |
| 210 | + try: |
| 211 | + payload = payloads.RichPastePost.parse_raw(reads, content_type="application/json") |
| 212 | + print(payload) |
| 213 | + except pydantic.ValidationError as e: |
| 214 | + return UJSONResponse({"detail": e.errors()}, status_code=422) |
| 215 | + except: |
| 216 | + return UJSONResponse({"error": f"multipart.section.data: Invalid JSON"}) |
| 217 | + |
| 218 | + paste_id = generate_paste_id() |
| 219 | + |
| 220 | + if images: |
| 221 | + async def _partial(target, spool: UploadFile): |
| 222 | + data = await spool.read() |
| 223 | + await request.app.state.client.put(target, data=data, headers=headers) # TODO figure out how to pass spooled object instead of load into memory |
| 224 | + |
| 225 | + image_idx = {} |
| 226 | + headers = {"Content-Type": "application/octet-stream", "AccessKey": f"{__config['bunny_cdn']['token']}"} |
| 227 | + partials: list[Coroutine] = [] |
| 228 | + |
| 229 | + for image in images: # TODO honour config filesize limit |
| 230 | + origin = image.filename.split(".")[-1] |
| 231 | + new_name = f"{('%032x' % uuid.uuid4().int)[:8]}-{paste_id}.{origin}" |
| 232 | + url = f"https://{__config['bunny_cdn']['hostname']}.b-cdn.net/images/{new_name}" |
| 233 | + image_idx[image.filename] = url |
| 234 | + target = f"https://storage.bunnycdn.com/{__config['bunny_cdn']['hostname']}/images/{new_name}" |
| 235 | + partials.append(_partial(target, image)) |
| 236 | + |
| 237 | + for n, file in enumerate(payload.files): |
| 238 | + if file.attachment is not None: |
| 239 | + if file.attachment not in image_idx: |
| 240 | + return UJSONResponse({"error": f"files.{n}.attachment: Unkown attachment '{file.attachment}'"}) |
| 241 | + |
| 242 | + file.__dict__["attachment"] = image_idx[file.attachment] |
| 243 | + |
| 244 | + await asyncio.wait(partials, return_when=asyncio.ALL_COMPLETED) |
| 245 | + |
| 246 | + if err := enforce_multipaste_limit(request.app, payload): |
| 247 | + return err |
202 | 248 |
|
203 | | - headers = {"Content-Type": "application/octet-stream", "AccessKey": f"{__config['bunny_cdn']['token']}"} |
| 249 | + notice = None |
204 | 250 |
|
205 | | - for image in images: |
206 | | - i = image.filename[0] |
207 | | - await request.app.state.db.update_paste_with_files( |
208 | | - paste_id=paste_id, tab_id=i, url=f"https://mystbin.b-cdn.net/images/{image.filename}" |
209 | | - ) |
| 251 | + if tokens := await find_discord_tokens(request, payload): |
| 252 | + gist = await upload_to_gist(request, "\n".join(tokens)) |
| 253 | + notice = f"Discord tokens have been found and uploaded to {gist['html_url']}" |
210 | 254 |
|
211 | | - URL = f'https://storage.bunnycdn.com/{__config["bunny_cdn"]["hostname"]}/images/{image.filename}' |
212 | | - data = await image.read() |
| 255 | + author: Optional[int] = request.state.user["id"] if request.state.user else None |
213 | 256 |
|
214 | | - await request.app.state.client.put(URL, headers=headers, data=data) |
| 257 | + paste = await request.app.state.db.put_paste( |
| 258 | + paste_id=paste_id, |
| 259 | + pages=payload.files, |
| 260 | + expires=payload.expires, |
| 261 | + author=author, |
| 262 | + password=payload.password, |
| 263 | + origin_ip=request.headers.get("x-forwarded-for", request.client.host) |
| 264 | + if request.app.config["paste"]["log_ip"] |
| 265 | + else None, |
| 266 | + ) |
215 | 267 |
|
216 | | - return Response(status_code=201) |
| 268 | + paste["notice"] = notice |
| 269 | + paste = responses.PastePostResponse(**paste) # type: ignore # this is a problem for future me #TODO |
| 270 | + paste = recursive_hook(paste.dict()) |
| 271 | + return UJSONResponse(paste, status_code=201) |
217 | 272 |
|
218 | 273 |
|
219 | 274 | desc = f"""Get a paste by ID. |
|
0 commit comments