Skip to content

Commit 5d82646

Browse files
authored
Merge pull request #69 from majestrate/file-upload-2022-02-23
file upload endpoints
2 parents 202ec32 + 27ce595 commit 5d82646

File tree

14 files changed

+598
-249
lines changed

14 files changed

+598
-249
lines changed

api.yaml

Lines changed: 0 additions & 200 deletions
Original file line numberDiff line numberDiff line change
@@ -16,206 +16,6 @@ externalDocs:
1616
description: Find out more about the Oxen project
1717
url: http://oxen.io
1818
paths:
19-
/room/{roomToken}/file:
20-
post:
21-
tags: [Files]
22-
summary: "Uploads a file to a room."
23-
description: >
24-
Takes the request as binary in the body and takes other properties via submitted headers.
25-
This saves space, particularly for large uploads. The user must have upload and posting
26-
permissions for the room. The file will have a default lifetime of 1 hour, but that is
27-
extended to 15 days when the containing message referencing the uploaded file is posted.
28-
29-
30-
See also the `.../fileJson` endpoint for submitting via a json body.
31-
parameters:
32-
- $ref: "#/components/parameters/pathRoomToken"
33-
- name: X-Filename
34-
in: header
35-
description: >
36-
Suggested filename of the upload. Typically the basename of the file uploaded from the
37-
user.
38-
schema:
39-
type: string
40-
requestBody:
41-
description: The file content, in bytes.
42-
required: true
43-
content:
44-
'*/*':
45-
{}
46-
responses:
47-
200:
48-
description: successful operation
49-
content:
50-
application/json:
51-
schema:
52-
type: object
53-
properties:
54-
id:
55-
type: integer
56-
format: int64
57-
description: "The id of the file on the server."
58-
403:
59-
description: >
60-
Upload forbidden. This response code indicates that the user does not have posting
61-
and/or upload permissions in the room either because of room settings, user restriction,
62-
or because the user is banned.
63-
content:
64-
application/json:
65-
schema:
66-
type: object
67-
properties:
68-
banned:
69-
type: boolean
70-
description: >
71-
True if the upload was denied because the user is banned, omitted otherwise.
72-
noWrite:
73-
type: boolean
74-
description: >
75-
True if the upload was denied because the user does not have write access to
76-
the room (but is not banned). Omitted otherwise.
77-
noUpload:
78-
type: boolean
79-
description: >
80-
True if the upload was denied because the user does not have upload access to
81-
the room (but is not banned and has write permissions). Omitted otherwise.
82-
83-
/room/{roomToken}/fileJSON:
84-
post:
85-
tags: [Files]
86-
summary: "Uploads a file to a room using a JSON encoded body."
87-
description: >
88-
This is less efficient when a binary upload is possible because the body must be passed as
89-
base64-encoded data (which is 33% larger). The user must have upload and posting
90-
permissions for the room. [NOT YET IMPLEMENTED: The file will have a default lifetime of 1
91-
hour, but that is extended to 15 days when the containing message referencing the upload
92-
is submitted.]
93-
parameters:
94-
- $ref: "#/components/parameters/pathRoomToken"
95-
requestBody:
96-
required: true
97-
content:
98-
application/json:
99-
schema:
100-
type: object
101-
required: [filename, content]
102-
properties:
103-
filename:
104-
type: string
105-
description: "Suggested filename of the upload. Typically the basename of the file uploaded from the user."
106-
content:
107-
type: string
108-
format: byte
109-
description: The file content, in base64 encoding.
110-
responses:
111-
200:
112-
$ref: "#/paths/~1room~1%7BroomToken%7D~1file/post/responses/200"
113-
403:
114-
$ref: "#/paths/~1room~1%7BroomToken%7D~1file/post/responses/403"
115-
116-
/room/{roomToken}/file/{fileId}:
117-
get:
118-
tags: [Files]
119-
summary: "Retrieves a file from the room via JSON."
120-
description: >
121-
Retrieves a file via a fileId from the room via a JSON object response. This is noticeably
122-
less efficient (particularly for large files) than the binary version when making direct
123-
requests because the file data must be encoded using base64 encoding.
124-
parameters:
125-
- $ref: "#/components/parameters/pathRoomToken"
126-
- $ref: "#/components/parameters/pathFileId"
127-
responses:
128-
200:
129-
description: successful operation; returns the file in JSON.
130-
content:
131-
application/json:
132-
schema:
133-
type: object
134-
properties:
135-
filename:
136-
type: string
137-
description: >
138-
The suggested filename of the file. Omitted if the file was uploaded without a
139-
filename (e.g. from older clients, or clients that specify an empty filename.)
140-
size:
141-
type: integer
142-
format: int64
143-
description: >
144-
The file size, in bytes. (*Not* the length of the base64-encoded data.)
145-
uploaded:
146-
type: number
147-
format: double
148-
description: The unix timestamp when the file was uploaded.
149-
expires:
150-
type: number
151-
format: double
152-
nullable: true
153-
description: >
154-
The unix timestamp when the file is scheduled to be removed. Will be null if
155-
the attachment is permanent, such as for room images or attachments in pinned
156-
messages.
157-
403:
158-
$ref: "#/paths/~1room~1%7BroomToken%7D/get/responses/403"
159-
404:
160-
description: >
161-
The referenced file does not exist. (It may have expired, or may be invalid.)
162-
163-
/room/{roomToken}/file/{fileId}/{filename}:
164-
get:
165-
tags: [Files]
166-
summary: "Retrieves a file from the room as binary."
167-
description: >
168-
Retrieves a file via a fileId from the room, returning the file content directly as the
169-
binary response body. The filename parameter is ignored and may be empty: it is primarily
170-
included to aid in clients that want the request to include a filename, and differentiates
171-
this as a request retrieving the file itself rather than the file as a JSON response. See
172-
the version without `/filename` for a JSON-returning version.
173-
parameters:
174-
- $ref: "#/components/parameters/pathRoomToken"
175-
- $ref: "#/components/parameters/pathFileId"
176-
- name: filename
177-
in: path
178-
required: true
179-
description: >
180-
Filename if known by the requesting client, and empty otherwise. The value of this
181-
parameter is ignored by the server itself: it is included to differentiate this request
182-
from the JSON version, and so that clients may include a filename in the request URL for
183-
contexts where that is useful.
184-
schema:
185-
type: integer
186-
format: int64
187-
responses:
188-
200:
189-
description: successful operation; returns the file, in raw bytes.
190-
headers:
191-
Content-Length:
192-
description: The size of the file.
193-
schema:
194-
type: integer
195-
format: int64
196-
example: 12345
197-
Date:
198-
description: The HTTP timestamp at which the file was uploaded.
199-
schema:
200-
type: string
201-
example: "Thu, 7 Oct 2021 00:42:00 GMT"
202-
Expires:
203-
description: >
204-
The HTTP timestamp at which the file is scheduled to expire. This header is omitted
205-
if the attachment is non-expiring (e.g. for attachments in a pinned message [NOT YET
206-
IMPLEMENTED]).
207-
schema:
208-
type: string
209-
example: "Fri, 22 Oct 2021 00:42:42 GMT"
210-
content:
211-
application/octet-stream:
212-
schema:
213-
type: string
214-
format: binary
215-
403:
216-
$ref: "#/paths/~1room~1%7BroomToken%7D~1file~1%7BfileId%7D/get/responses/403"
217-
404:
218-
$ref: "#/paths/~1room~1%7BroomToken%7D~1file~1%7BfileId%7D/get/responses/404"
21919
/user/{sessionId}/permission:
22020
post:
22121
tags: [Users]

conftest.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212

1313
import sqlalchemy # noqa: E402
1414

15+
import atexit, shutil, tempfile # noqa: E402 E401
16+
17+
_tempdirs = set()
1518

1619
sogs.omq.test_suite = True
1720

@@ -91,7 +94,9 @@ def db(request, pgsql):
9194
value is the db module itself (so typically you don't import it at all but instead get it
9295
through this fixture, which also creates an empty db for you).
9396
"""
94-
97+
d = tempfile.mkdtemp(prefix='tmp_pysogs')
98+
_tempdirs.add(d)
99+
config.UPLOAD_PATH = d
95100
trace = request.config.getoption("--sql-tracing")
96101

97102
from sogs import db as db_
@@ -270,3 +275,5 @@ def no_rate_limit():
270275

271276

272277
web.app.config.update({'TESTING': True})
278+
279+
atexit.register(lambda: [shutil.rmtree(d) for d in _tempdirs])

sogs/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
REQUIRE_BLIND_KEYS = False
3636
TEMPLATE_PATH = 'templates'
3737
STATIC_PATH = 'static'
38+
UPLOAD_PATH = 'uploads'
3839

3940
# Will be true if we're running as a uwsgi app, false otherwise; used where we need to do things
4041
# only in one case or another (e.g. database initialization only via app mode).
@@ -106,6 +107,7 @@ def bool_opt(name):
106107
'files': {
107108
'expiry': ('UPLOAD_DEFAULT_EXPIRY_DAYS', None, float),
108109
'max_size': ('UPLOAD_FILE_MAX_SIZE', None, int),
110+
'uploads_dir': ('UPLOAD_PATH', path_exists, val_or_none),
109111
},
110112
'rooms': {
111113
'active_threshold': ('ROOM_DEFAULT_ACTIVE_THRESHOLD', None, float),

sogs/model/exc.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def __init__(self, token):
1818

1919

2020
class NoSuchFile(NotFound):
21-
"""Thrown when trying to construct a File from a token that doesn't exist"""
21+
"""Thrown when trying to construct a File from an id that doesn't exist"""
2222

2323
def __init__(self, id):
2424
self.id = id

sogs/model/file.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ class File:
1313
id - the numeric file id, i.e. primary key
1414
room - the Room that this file belongs to (only retrieved on demand).
1515
uploader - the User that uploaded this file (only retrieved on demand).
16+
post_id - the id of the post to which this file is attached, None if unattached.
1617
size - the size (in bytes) of this file
1718
uploaded - unix timestamp when the file was uploaded
1819
expiry - unix timestamp when the file expires. None for non-expiring files.
1920
path - the path of this file on disk, relative to the base data directory.
2021
filename - the suggested filename provided by the user. None for there is no suggestion
21-
(this will always be the case for files uploaded by legacy Session clients).
22+
(this will always be the case for files uploaded by legacy Session clients, and
23+
sometimes by newer Session clients, e.g. when uploading from a paste).
2224
"""
2325

2426
def __init__(self, row=None, *, id=None):
@@ -37,17 +39,28 @@ def __init__(self, row=None, *, id=None):
3739
self.id,
3840
self._fetch_room_id,
3941
self._fetch_uploader_id,
42+
self.post_id,
4043
self.size,
4144
self.uploaded,
4245
self.expiry,
4346
self.filename,
4447
self.path,
4548
) = (
4649
row[c]
47-
for c in ('id', 'room', 'uploader', 'size', 'uploaded', 'expiry', 'filename', 'path')
50+
for c in (
51+
'id',
52+
'room',
53+
'uploader',
54+
'message',
55+
'size',
56+
'uploaded',
57+
'expiry',
58+
'filename',
59+
'path',
60+
)
4861
)
49-
self._uploader = None
5062
self._room = None
63+
self._uploader = None
5164

5265
@property
5366
def room(self):
@@ -72,7 +85,7 @@ def room_id(self):
7285
Accesses the id of the room to which this file was uploaded. Equivalent to .room.id, except
7386
that we don't fetch/cache the Room row.
7487
"""
75-
return self._fetch_room_id if self._room is None else self._fetch_room_id
88+
return self._fetch_room_id if self._room is None else self._room.id
7689

7790
@property
7891
def uploader(self):

sogs/model/room.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,9 @@ def image(self, file: Union[File, int]):
269269
if not isinstance(file, File):
270270
file = File(id=file)
271271

272+
if file.room_id != self.id:
273+
raise NoSuchFile(file.room_id)
274+
272275
file.set_expiry(forever=True)
273276

274277
if self.image:
@@ -1263,10 +1266,10 @@ def get_file(self, file_id: int):
12631266
r=self.id,
12641267
old_fid=file_id,
12651268
).first()
1266-
1267-
if row:
1269+
if not row:
1270+
return
1271+
if row['expiry'] is None or row['expiry'] > time.time():
12681272
return File(row)
1269-
return None
12701273

12711274
def upload_file(
12721275
self,
@@ -1293,11 +1296,18 @@ def upload_file(
12931296
if not self.check_upload(uploader):
12941297
raise BadPermission()
12951298

1296-
files_dir = "uploads/" + self.token
1299+
files_dir = os.path.join(config.UPLOAD_PATH, self.token)
12971300
os.makedirs(files_dir, exist_ok=True)
12981301

1299-
if filename is not None:
1300-
filename = re.sub(config.UPLOAD_FILENAME_BAD, "_", filename)
1302+
if filename is None:
1303+
upload_filename = None
1304+
else:
1305+
# nulls and / are prohibited characters on pretty much any system, so substitute them
1306+
# out for a REPLACEMENT CHARACTER (U+FFFD) if in the given filename.
1307+
filename = filename.replace('\0', '\uFFFD').replace('/', '\uFFFD')
1308+
1309+
# For the actual filename we write to disk we heavily sanitize:
1310+
upload_filename = re.sub(config.UPLOAD_FILENAME_BAD, "_", filename)
13011311

13021312
file_id, file_path = None, None
13031313

@@ -1323,17 +1333,17 @@ def upload_file(
13231333
filename=filename,
13241334
)
13251335

1326-
if filename is None:
1327-
filename = '(unnamed)'
1336+
if upload_filename is None:
1337+
upload_filename = '(unnamed)'
13281338

1329-
if len(filename) > config.UPLOAD_FILENAME_MAX:
1330-
filename = (
1331-
filename[: config.UPLOAD_FILENAME_KEEP_PREFIX]
1339+
if len(upload_filename) > config.UPLOAD_FILENAME_MAX:
1340+
upload_filename = (
1341+
upload_filename[: config.UPLOAD_FILENAME_KEEP_PREFIX]
13321342
+ "..."
1333-
+ filename[-config.UPLOAD_FILENAME_KEEP_SUFFIX :]
1343+
+ upload_filename[-config.UPLOAD_FILENAME_KEEP_SUFFIX :]
13341344
)
13351345

1336-
file_path = f"{files_dir}/{file_id}_{filename}"
1346+
file_path = f"{files_dir}/{file_id}_{upload_filename}"
13371347

13381348
with open(file_path, 'wb') as f:
13391349
f.write(content)

0 commit comments

Comments
 (0)