Skip to content

Commit 437a52e

Browse files
authored
Merge pull request #43 from jagerman/posting
Add room posting/editing endpoints
2 parents e1cc4ba + e81022a commit 437a52e

File tree

8 files changed

+592
-50
lines changed

8 files changed

+592
-50
lines changed

api.yaml

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,14 @@ paths:
135135
- $ref: "#/components/parameters/pathRoomToken"
136136
requestBody:
137137
description: >
138-
JSON body containing the message details to be submitted to the room. Note that the
139-
`session_id` field is not contained in the response (since it is simply the client's own
140-
ID).
138+
JSON body containing the message details to be submitted to the room.
139+
140+
141+
data and signature are required; whisper_to and whisper_mods are permitted only for
142+
moderators/admins. The former sends a whisper within the room that will only be shown by
143+
the given user; the latter sends a whisper in the room that will be seen by all moderators.
144+
It is possible for a message to use both of these whisper modes at once, for example to
145+
(privately) warn a user and have that warning visible by all other moderators.
141146
required: true
142147
content:
143148
application/json:
@@ -157,6 +162,12 @@ paths:
157162
Base64-encoded message data XEd25519 signature, signed by the poster's X25519
158163
key contained in the session ID.
159164
example: NjgwYzFjOGI0YTljNTliNDk1MDRmMzY5YzFmMzRkYjM4ZTU3Mzk2YzA2ODYwODk3MzI1ZmFhMjNkYTZmNzE3YTk3MmY4MTJjZDU1MGFkMTQ2Yzk1MTdlOGM1NzMyZjgxZDE3NWViODg5OGQxZjQyMjg5ZWNkNjNjODJiMDZjNzM=
165+
whisper_to:
166+
$ref: "#/components/schemas/SessionID"
167+
whisper_mods:
168+
type: boolean
169+
description: >
170+
True if this message is a whisper that should be visible to all room moderators.
160171
files:
161172
type: array
162173
description: >
@@ -257,11 +268,11 @@ paths:
257268
$ref: "#/paths/~1room~1%7BroomToken%7D~1message/post/requestBody"
258269
responses:
259270
200:
260-
description: Message updated successfully
271+
description: Message updated successfully; returns an empty dict as body.
261272
content:
262273
application/json:
263274
schema:
264-
$ref: "#/components/schemas/Message"
275+
type: object
265276
403:
266277
description: >
267278
Forbidden. Returned if the user does not have permission to post messages to this room,

sogs/http.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
# basic success:
1+
# success codes:
22
OK = 200
3+
CREATED = 201
34

45
# error status codes:
56
BAD_REQUEST = 400

sogs/model/exc.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ def __init__(self, msg=None):
6464
super().__init__("Permission denied" if msg is None else msg)
6565

6666

67+
class InvalidData(RuntimeError):
68+
"""Thrown if something in model was fed invalid data, for example a signature of an invalid
69+
size, or an unparseable entity."""
70+
71+
6772
class PostRejected(RuntimeError):
6873
"""
6974
Thrown when a post is refused for some reason other than a permission error (e.g. the post
@@ -95,3 +100,8 @@ def abort_perm_denied(e):
95100
@app.errorhandler(PostRejected)
96101
def abort_post_rejected(e):
97102
flask.abort(http.TOO_MANY_REQUESTS)
103+
104+
105+
@app.errorhandler(InvalidData)
106+
def abort_invalid_data(e):
107+
flask.abort(http.BAD_REQUEST)

sogs/model/room.py

Lines changed: 99 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
BadPermission,
1313
PostRejected,
1414
PostRateLimited,
15+
InvalidData,
1516
)
1617

1718
import os
@@ -464,6 +465,7 @@ def get_messages_for(
464465
self,
465466
user: Optional[User],
466467
*,
468+
sequence: Optional[int] = None,
467469
after: Optional[int] = None,
468470
before: Optional[int] = None,
469471
recent: bool = False,
@@ -476,7 +478,12 @@ def get_messages_for(
476478
whispers meant to be displayed to moderators.
477479
478480
Exactly one of `after`, `begin`, `recent` or `single` must be specified:
479-
- `after=N` returns messages with ids greater than N in ascending order
481+
- `sequence=N` returns messages that have been posted, edited, or deleted since the given
482+
`seqno` (that is: the have seqno greater than N). Messages are returned in sequence
483+
order.
484+
- `after=N` returns messages with ids greater than N in ascending order. This is normally
485+
*not* what you want for fetching messages as it omits edits and deletions; typically you
486+
want to retrieve by seqno instead.
480487
- `before=N` returns messages with ids less than N in descending order
481488
- `recent=True` returns the most recent messages in descending order
482489
- `single=123` returns a singleton list containing the single message with the given message
@@ -490,11 +497,15 @@ def get_messages_for(
490497
mod = self.check_moderator(user)
491498
msgs = []
492499

493-
opt_count = sum((after is not None, before is not None, recent, single is not None))
500+
opt_count = sum(arg is not None for arg in (sequence, after, before, single)) + bool(recent)
494501
if opt_count == 0:
495-
raise RuntimeError("Exactly one of before=, after=, recent=, or single= is required")
502+
raise RuntimeError(
503+
"Exactly one of sequence=, before=, after=, recent=, or single= is required"
504+
)
496505
if opt_count > 1:
497-
raise RuntimeError("Cannot specify more than one of before=, after=, recent=, single=")
506+
raise RuntimeError(
507+
"Cannot specify more than one of sequence=, before=, after=, recent=, single="
508+
)
498509

499510
# Handle id mapping from an old database import in case the client is requesting
500511
# messages since some id from the old db.
@@ -503,28 +514,45 @@ def get_messages_for(
503514
if after <= max_old_id:
504515
after += offset
505516

517+
whisper_clause = (
518+
# For a mod we want to see:
519+
# - all whisper_mods messsages
520+
# - anything directed to us specifically
521+
# - anything we sent (i.e. outbound whispers)
522+
# - non-whispers
523+
"whisper_mods OR whisper = :user OR user = :user OR whisper IS NULL"
524+
if mod
525+
# For a regular user we want to see:
526+
# - anything with whisper_to sent to us
527+
# - non-whispers
528+
else "whisper = :user OR (whisper IS NULL AND NOT whisper_mods)"
529+
if user
530+
# Otherwise for public, non-user access we want to see:
531+
# - non-whispers
532+
else "whisper IS NULL AND NOT whisper_mods"
533+
)
534+
506535
for row in query(
507536
f"""
508537
SELECT * FROM message_details
509-
WHERE room = :r AND data IS NOT NULL AND NOT filtered
538+
WHERE room = :r AND NOT filtered {'AND data IS NOT NULL' if sequence is None else ''}
510539
{
540+
'AND seqno > :sequence' if sequence is not None else
511541
'AND id > :after' if after is not None else
512542
'AND id < :before' if before is not None else
513543
'AND id = :single' if single is not None else
514544
''
515545
}
516-
AND (
517-
whisper IS NULL
518-
{'OR whisper = :user' if user else ''}
519-
{'OR whisper_mods' if mod else ''}
520-
)
546+
AND ({whisper_clause})
521547
{
522548
'' if single is not None else
549+
'ORDER BY seqno ASC LIMIT :limit' if sequence is not None else
523550
'ORDER BY id ASC LIMIT :limit' if after is not None else
524551
'ORDER BY id DESC LIMIT :limit'
525552
}
526553
""",
527554
r=self.id,
555+
sequence=sequence,
528556
after=after,
529557
before=before,
530558
single=single,
@@ -590,6 +618,9 @@ def add_post(
590618
if not self.check_write(user):
591619
raise BadPermission()
592620

621+
if data is None or sig is None or len(sig) != 32:
622+
raise InvalidData()
623+
593624
whisper_mods = bool(whisper_mods)
594625
if (whisper_to or whisper_mods) and not self.check_moderator(user):
595626
app.logger.warning(f"Cannot post a whisper to {self}: {user} is not a moderator")
@@ -655,6 +686,64 @@ def add_post(
655686
send_mule("message_posted", msg['id'])
656687
return msg
657688

689+
def edit_post(self, user: User, msg_id: int, data: bytes, sig: bytes):
690+
"""
691+
Edits a post in the room. The post must exist, must have been authored by the same user,
692+
and must not be deleted. The user must *currently* have write permission (i.e. if they lose
693+
write permission they cannot edit existing posts made before they were restricted).
694+
695+
Edits cannot alter the whisper_to/whisper_mods properties.
696+
697+
Raises:
698+
- BadPermission() if attempting to edit another user's message or not having write
699+
permission in the room.
700+
- A subclass of PostRejected() if the edit is unacceptable, for instance for triggering the
701+
profanity filter.
702+
- NoSuchPost() if the post is deleted.
703+
"""
704+
if not self.check_write(user):
705+
raise BadPermission()
706+
707+
if data is None or sig is None or len(sig) != 32:
708+
raise InvalidData()
709+
710+
filtered = self.should_filter(user, data)
711+
with db.transaction():
712+
author = query(
713+
'''
714+
SELECT "user" FROM messages
715+
WHERE id = :m AND room = :r AND data IS NOT NULL
716+
''',
717+
m=msg_id,
718+
r=self.id,
719+
).first()
720+
if author is None:
721+
raise NoSuchPost()
722+
author = author[0]
723+
if author != user.id:
724+
raise BadPermission()
725+
726+
if filtered:
727+
# Silent filtering is enabled and the edit failed the filter, so we want to drop the
728+
# actual post update.
729+
return
730+
731+
data_size = len(data)
732+
unpadded_data = utils.remove_session_message_padding(data)
733+
734+
query(
735+
"""
736+
UPDATE messages SET
737+
data = :data, data_size = :data_size, signature = :sig WHERE id = :m
738+
""",
739+
m=msg_id,
740+
data=unpadded_data,
741+
data_size=data_size,
742+
sig=sig,
743+
)
744+
745+
send_mule("message_edited", msg_id)
746+
658747
def delete_posts(self, message_ids: List[int], deleter: User):
659748
"""
660749
Deletes the messages with the given ids. The given user performing the delete must be a

sogs/routes/rooms.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from .. import config, http, utils
22
from ..model import room as mroom
3+
from . import auth
34

4-
from flask import abort, jsonify, g, Blueprint
5+
from flask import abort, jsonify, g, Blueprint, request
56

67
# General purpose routes for things like capability retrieval and batching
78

@@ -91,7 +92,7 @@ def messages_since(room, seqno):
9192

9293
limit = utils.get_int_param('limit', 100, min=1, max=256, truncate=True)
9394

94-
return utils.jsonify_with_base64(room.get_messages_for(g.user, limit=limit, after=seqno))
95+
return utils.jsonify_with_base64(room.get_messages_for(g.user, limit=limit, sequence=seqno))
9596

9697

9798
@rooms.get("/room/<Room:room>/messages/before/<int:msg_id>")
@@ -126,6 +127,41 @@ def message_single(room, msg_id):
126127
return utils.jsonify_with_base64(msgs[0])
127128

128129

130+
@rooms.post("/room/<Room:room>/message")
131+
@auth.user_required
132+
def post_message(room):
133+
req = request.json
134+
135+
# TODO: files tracking
136+
137+
msg = room.add_post(
138+
g.user,
139+
data=utils.decode_base64(req.get('data')),
140+
sig=utils.decode_base64(req.get('signature')),
141+
whisper_to=req.get('whisper_to'),
142+
whisper_mods=bool(req.get('whisper_mods')),
143+
)
144+
145+
return utils.jsonify_with_base64(msg), http.CREATED
146+
147+
148+
@rooms.put("/room/<Room:room>/message/<int:msg_id>")
149+
@auth.user_required
150+
def edit_message(room, msg_id):
151+
req = request.json
152+
153+
# TODO: files tracking
154+
155+
room.edit_post(
156+
g.user,
157+
msg_id,
158+
data=utils.decode_base64(req.get('data')),
159+
sig=utils.decode_base64(req.get('signature')),
160+
)
161+
162+
return jsonify({})
163+
164+
129165
@rooms.post("/room/<Room:room>/pin/<int:msg_id>")
130166
def message_pin(room, msg_id):
131167
room.pin(msg_id, g.user)

0 commit comments

Comments
 (0)