Skip to content

Commit 4b4a9b9

Browse files
committed
Upload files in single request with images
1 parent 76116dd commit 4b4a9b9

File tree

6 files changed

+128
-70
lines changed

6 files changed

+128
-70
lines changed

config-template.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
"google_application_secret": "",
2121
"github_bot_token": ""
2222
},
23+
"bunny_cdn": {
24+
"hostname": "mystbin",
25+
"token": ""
26+
},
2327
"site": {
2428
"frontend_site": "https://mysite.com",
2529
"backend_site": "https://api.mysite.com",
@@ -29,6 +33,7 @@
2933
"paste": {
3034
"character_limit": 300000,
3135
"file_limit": 5,
36+
"filesize_limit": "8mb",
3237
"log_ip": true
3338
},
3439
"debug": {

mystbin/backend/models/payloads.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,14 @@ class PasteFile(BaseModel):
2727
filename: str
2828

2929
class Config:
30-
schema_extra = {"content": "explosions everywhere", "filename": "kaboom.txt"}
30+
schema_extra = {"example": {"content": "explosions everywhere", "filename": "kaboom.txt"}}
31+
32+
33+
class RichPasteFile(PasteFile):
34+
attachment: str | None
35+
36+
class Config:
37+
schema_extra = {"example": {"content": "explosions everywhere", "filename": "kaboom.txt", "attachment": "image1.png"}}
3138

3239

3340
class PastePut(BaseModel):
@@ -43,13 +50,35 @@ class Config:
4350
"files": [
4451
{"content": "import this", "filename": "foo.py"},
4552
{
46-
"content": "doc.md",
47-
"filename": "**do not use this in production**",
53+
"filename": "doc.md",
54+
"content": "**do not use this in production**",
4855
},
4956
],
5057
}
5158
}
5259

60+
class RichPastePost(PastePut):
61+
files: List[RichPasteFile]
62+
63+
class Config:
64+
schema_extra = {
65+
"example": {
66+
"expires": "2020-11-16T13:46:49.215Z",
67+
"password": None,
68+
"files": [
69+
{
70+
"content": "import this",
71+
"filename": "foo.py",
72+
"attachment": None
73+
},
74+
{
75+
"filename": "doc.md",
76+
"content": "**do not use this in production**",
77+
"attachment": "image2.jpeg"
78+
},
79+
],
80+
}
81+
}
5382

5483
class PastePatch(BaseModel):
5584
new_expires: Optional[datetime.datetime] = None

mystbin/backend/models/responses.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@ class File(BaseModel):
2727
content: str
2828
loc: int
2929
charcount: int
30-
tab_id: int | None
31-
image: str | None
30+
attachment: str | None
3231

3332
class Config:
3433
schema_extra = {
@@ -37,8 +36,7 @@ class Config:
3736
"content": "import datetime\\nprint(datetime.datetime.utcnow())",
3837
"loc": 2,
3938
"charcount": 49,
40-
"tab_id": 0,
41-
"url": "...",
39+
"attachment": "https://mystbin.b-cdn.com/umbra_sucks.jpeg",
4240
}
4341
}
4442

mystbin/backend/routers/pastes.py

Lines changed: 83 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,18 @@
1717
along with MystBin. If not, see <https://www.gnu.org/licenses/>.
1818
"""
1919
from __future__ import annotations
20+
import asyncio
2021

2122
import datetime
2223
import json
2324
import pathlib
2425
import re
26+
import uuid
2527
from random import sample
26-
from typing import Dict, List, Optional, Union
28+
from typing import Coroutine, cast, Dict, List, Optional, Union
2729

2830
from asyncpg import Record
31+
import pydantic
2932
from fastapi import APIRouter, File, Response, UploadFile
3033
from fastapi.responses import UJSONResponse
3134
from fastapi.encoders import jsonable_encoder
@@ -35,6 +38,13 @@
3538
from utils.db import _recursive_hook as recursive_hook
3639
from utils.ratelimits import limit
3740

41+
try:
42+
import ujson
43+
_loads = ujson.loads
44+
except ModuleNotFoundError:
45+
import json
46+
_loads = json.loads
47+
3848

3949
_WORDS_LIST = open(pathlib.Path("utils/words.txt")).readlines()
4050
word_list = [word.title() for word in _WORDS_LIST if len(word) > 3]
@@ -54,9 +64,9 @@
5464
del __p, __f # micro-opt, don't keep unneeded variables in-ram
5565

5666

57-
def generate_paste_id():
67+
def generate_paste_id(n: int = 3):
5868
"""Generate three random words."""
59-
word_samples = sample(word_list, 3)
69+
word_samples = sample(word_list, n)
6070
return "".join(word_samples).replace("\n", "")
6171

6272

@@ -137,7 +147,7 @@ async def find_discord_tokens(request: MystbinRequest, pastes: payloads.PastePut
137147
response_model=responses.PastePostResponse,
138148
responses={
139149
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"}}}},
141151
},
142152
status_code=201,
143153
name="Create paste",
@@ -178,42 +188,87 @@ async def put_pastes(
178188
return UJSONResponse(paste, status_code=201)
179189

180190

181-
@router.put(
182-
"/images/upload/{paste_id}",
191+
@router.post(
192+
"/rich-paste",
183193
tags=["pastes"],
194+
response_model=responses.PastePostResponse,
184195
responses={
185196
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"}}}}
188198
},
189-
include_in_schema=False,
199+
status_code=201,
200+
name="Create Rich Paste"
190201
)
191202
@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),
194207
):
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
202248

203-
headers = {"Content-Type": "application/octet-stream", "AccessKey": f"{__config['bunny_cdn']['token']}"}
249+
notice = None
204250

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']}"
210254

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
213256

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+
)
215267

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)
217272

218273

219274
desc = f"""Get a paste by ID.

mystbin/backend/utils/db.py

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -182,16 +182,6 @@ async def get_paste(self, paste_id: str, password: Optional[str] = None) -> Opti
182182
contents = cast(List[asyncpg.Record], await self._do_query(query, paste_id, conn=conn))
183183
resp = dict(resp[0])
184184
resp["files"] = [{a: str(b) for a, b in x.items()} for x in contents]
185-
186-
images = await self.get_images(paste_id=paste_id)
187-
188-
for index, file in enumerate(resp["files"]):
189-
try:
190-
file["image"] = images[index]["url"]
191-
file["tab_id"] = images[index]["tab_id"]
192-
except IndexError:
193-
pass
194-
195185
return resp
196186
else:
197187
return None
@@ -232,7 +222,7 @@ async def put_paste(
232222
*,
233223
paste_id: str,
234224
origin_ip: Optional[str],
235-
pages: List[payloads.PasteFile],
225+
pages: List[payloads.RichPasteFile] | List[payloads.PasteFile],
236226
expires: Optional[datetime.datetime] = None,
237227
author: Optional[int] = None,
238228
password: Optional[str] = None,
@@ -281,13 +271,14 @@ async def put_paste(
281271
page.content,
282272
page.filename,
283273
page.content.count("\n") + 1, # add an extra for line 1
274+
getattr(page, "attachment", None)
284275
)
285276
)
286277

287278
files_query = """
288-
INSERT INTO files (parent_id, content, filename, loc)
289-
VALUES ($1, $2, $3, $4)
290-
RETURNING index, filename, loc, charcount, content
279+
INSERT INTO files (parent_id, content, filename, loc, attachment)
280+
VALUES ($1, $2, $3, $4, $5)
281+
RETURNING index, filename, loc, charcount, content, attachment
291282
"""
292283
inserted = []
293284
async with conn.transaction():
@@ -301,18 +292,6 @@ async def put_paste(
301292

302293
return resp
303294

304-
async def update_paste_with_files(self, *, paste_id: str, tab_id: str, url: str) -> None:
305-
query = """INSERT INTO images VALUES($1, $2, $3)"""
306-
307-
async with self.pool.acquire() as conn:
308-
await self._do_query(query, paste_id, int(tab_id), url)
309-
310-
async def get_images(self, *, paste_id: str):
311-
312-
query = """SELECT * FROM images WHERE parent_id = $1"""
313-
async with self.pool.acquire() as conn:
314-
return [dict(x) for x in await self._do_query(query, paste_id)]
315-
316295
@wrapped_hook_callback
317296
async def edit_paste(
318297
self,

mystbin/database/schema.sql

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,10 @@ CREATE TABLE IF NOT EXISTS files (
3232
loc INTEGER NOT NULL,
3333
charcount INTEGER GENERATED ALWAYS AS (LENGTH(content)) STORED,
3434
index SERIAL NOT NULL,
35+
attachment TEXT,
3536
PRIMARY KEY (parent_id, index)
3637
);
3738

38-
CREATE TABLE IF NOT EXISTS images (
39-
parent_id TEXT REFERENCES pastes(id) ON DELETE CASCADE,
40-
tab_id BIGINT,
41-
url TEXT,
42-
index SERIAL NOT NULL,
43-
PRIMARY KEY (parent_id, index)
44-
45-
);
46-
4739
CREATE TABLE IF NOT EXISTS bookmarks (
4840
userid bigint not null references users(id) ON DELETE CASCADE,
4941
paste text not null references pastes(id) ON DELETE CASCADE,

0 commit comments

Comments
 (0)