Skip to content

Commit 0aa366a

Browse files
authored
Merge pull request #46 from jagerman/mods
Moderator/admin control endpoints
2 parents 437a52e + 948e33a commit 0aa366a

File tree

10 files changed

+984
-346
lines changed

10 files changed

+984
-346
lines changed

api.yaml

Lines changed: 161 additions & 105 deletions
Large diffs are not rendered by default.

sogs/model/room.py

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -940,6 +940,8 @@ def set_moderator(self, user: User, *, added_by: User, admin=False, visible=True
940940
Sets `user` as a moderator or admin of this room. Replaces current admin/moderator/visible
941941
status with the new values if the user is already a moderator/admin of the room.
942942
943+
`admin` can be specified as None to not touch the current admin permission on the room.
944+
943945
added_by is the user performing the update and must have admin permission.
944946
"""
945947

@@ -951,12 +953,13 @@ def set_moderator(self, user: User, *, added_by: User, admin=False, visible=True
951953
raise BadPermission()
952954

953955
query(
954-
"""
955-
INSERT INTO user_permission_overrides (room, "user", moderator, admin, visible_mod)
956-
VALUES (:r, :u, TRUE, :admin, :visible)
956+
f"""
957+
INSERT INTO user_permission_overrides
958+
(room, "user", moderator, {'admin,' if admin is not None else ''} visible_mod)
959+
VALUES (:r, :u, TRUE, {':admin,' if admin is not None else ''} :visible)
957960
ON CONFLICT (room, "user") DO UPDATE SET
958961
moderator = excluded.moderator,
959-
admin = excluded.admin,
962+
{'admin = excluded.admin,' if admin is not None else ''}
960963
visible_mod = excluded.visible_mod
961964
""",
962965
r=self.id,
@@ -970,16 +973,22 @@ def set_moderator(self, user: User, *, added_by: User, admin=False, visible=True
970973

971974
app.logger.info(f"{added_by} set {user} as {'admin' if admin else 'moderator'} of {self}")
972975

973-
def remove_moderator(self, user: User, *, removed_by: User):
974-
"""Remove `user` as a moderator/admin of this room. Requires admin permission."""
976+
def remove_moderator(self, user: User, *, removed_by: User, remove_admin_only: bool = False):
977+
"""
978+
Remove `user` as a moderator/admin of this room. Requires admin permission.
979+
980+
If `remove_admin_only` is True then user will have admin permissions removed but will remain
981+
a room moderator if already a room moderator or admin.
982+
"""
975983

976984
if not self.check_admin(removed_by):
977985
raise BadPermission()
978986

979987
query(
980-
"""
988+
f"""
981989
UPDATE user_permission_overrides
982-
SET moderator = FALSE, admin = FALSE, visible_mod = TRUE
990+
SET admin = FALSE
991+
{', moderator = FALSE, visible_mod = TRUE' if not remove_admin_only else ''}
983992
WHERE room = :r AND "user" = :u
984993
""",
985994
r=self.id,
@@ -1321,6 +1330,63 @@ def get_rooms():
13211330
return [Room(row) for row in query("SELECT * FROM rooms ORDER BY token")]
13221331

13231332

1333+
def get_rooms_with_permission(
1334+
user: User,
1335+
*,
1336+
tokens: Optional[Union[list, tuple]] = None,
1337+
read: Optional[bool] = None,
1338+
write: Optional[bool] = None,
1339+
upload: Optional[bool] = None,
1340+
banned: Optional[bool] = None,
1341+
moderator: Optional[bool] = None,
1342+
admin: Optional[bool] = None,
1343+
):
1344+
"""
1345+
Returns a list of rooms that the given user has matching permissions for.
1346+
1347+
Parameters:
1348+
user: the user object to query permissions for. May not be None.
1349+
tokens: if non-None then this specifies a list or tuple of room tokens to filter by. When
1350+
omitted, all rooms are returned. Note that rooms are returned sorted by token, *not* in
1351+
the order specified here; duplicates are not returned; nor are entries for non-existent
1352+
tokens.
1353+
read/write/upload/banned/moderator/admin:
1354+
Any of these that are specified as non-None must match the user's permissions for the room.
1355+
For example `read=True, write=False` would return all rooms where the user has read-only
1356+
access but not rooms in which the user has both or neither read and write permissions.
1357+
At least one of these arguments must be specified as non-None.
1358+
"""
1359+
if user is None:
1360+
raise RuntimeError("user is required for get_rooms_with_permission")
1361+
if not any(arg is not None for arg in (read, write, upload, banned, moderator, admin)):
1362+
raise RuntimeError("At least one of read/write/upload/banned/moderator/admin must be given")
1363+
if tokens and (
1364+
not (isinstance(tokens, list) or isinstance(tokens, tuple))
1365+
or any(not isinstance(t, str) for t in tokens)
1366+
):
1367+
raise RuntimeError("tokens= must be a list or tuple of room token names")
1368+
1369+
return [
1370+
Room(row)
1371+
for row in query(
1372+
f"""
1373+
SELECT rooms.* FROM user_permissions perm JOIN rooms ON rooms.id = room
1374+
WHERE "user" = :u {'AND token IN :tokens' if tokens else ''}
1375+
{'' if banned is None else ('AND' if banned else 'AND NOT') + ' perm.banned'}
1376+
{'' if read is None else ('AND' if read else 'AND NOT') + ' perm.read'}
1377+
{'' if write is None else ('AND' if write else 'AND NOT') + ' perm.write'}
1378+
{'' if upload is None else ('AND' if upload else 'AND NOT') + ' perm.upload'}
1379+
{'' if moderator is None else ('AND' if moderator else 'AND NOT') + ' perm.moderator'}
1380+
{'' if admin is None else ('AND' if admin else 'AND NOT') + ' perm.admin'}
1381+
ORDER BY token
1382+
""",
1383+
u=user.id,
1384+
tokens=tokens,
1385+
bind_expanding=['tokens'] if tokens else None,
1386+
)
1387+
]
1388+
1389+
13241390
def get_readable_rooms(user: Optional[User] = None):
13251391
"""
13261392
Get a list of rooms that a user can access; if user is None then return all publicly readable
@@ -1329,14 +1395,7 @@ def get_readable_rooms(user: Optional[User] = None):
13291395
if user is None:
13301396
result = query("SELECT * FROM rooms WHERE read ORDER BY token")
13311397
else:
1332-
result = query(
1333-
"""
1334-
SELECT rooms.* FROM user_permissions perm JOIN rooms ON rooms.id = room
1335-
WHERE "user" = :u AND perm.read AND NOT perm.banned
1336-
ORDER BY token
1337-
""",
1338-
u=user.id,
1339-
)
1398+
return get_rooms_with_permission(user, read=True, banned=False)
13401399
return [Room(row) for row in result]
13411400

13421401

sogs/model/user.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,23 @@ def __init__(self, row=None, *, id=None, session_id=None, autovivify=True, touch
3434
touch - if True (default is False) then update the last_activity time of this user before
3535
returning it.
3636
"""
37+
self._touched = False
38+
self._refresh(row=row, id=id, session_id=session_id)
3739

38-
if sum(x is not None for x in (row, session_id, id)) != 1:
40+
if touch:
41+
self._touch()
42+
43+
def _refresh(self, *, row=None, id=None, session_id=None, autovivify=True):
44+
"""
45+
Internal method to (re-)fetch details from the database; this is used during construction
46+
but also in the test suite to forcibly re-fetch details.
47+
"""
48+
n_args = sum(x is not None for x in (row, session_id, id))
49+
if n_args == 0 and hasattr(self, 'id'):
50+
id = self.id
51+
elif n_args != 1:
3952
raise ValueError("User() error: exactly one of row/session_id/id is required")
4053

41-
self._touched = False
4254
if session_id is not None:
4355
row = query("SELECT * FROM users WHERE session_id = :s", s=session_id).first()
4456

@@ -62,9 +74,6 @@ def __init__(self, row=None, *, id=None, session_id=None, autovivify=True, touch
6274
bool(row[c]) for c in ('banned', 'moderator', 'admin', 'visible_mod')
6375
)
6476

65-
if touch:
66-
self._touch()
67-
6877
def __str__(self):
6978
"""Returns string representation of a user: U[050123…cdef], the id prefixed with @ or % if
7079
the user is a global admin or moderator, respectively."""
@@ -113,6 +122,8 @@ def set_moderator(self, *, added_by: User, admin=False, visible=False):
113122
"""
114123
Make this user a global moderator or admin. If the user is already a global mod/admin then
115124
their status is updated according to the given arguments (that is, this can promote/demote).
125+
126+
If `admin` is None then the current admin status is left unchanged.
116127
"""
117128

118129
if not added_by.global_admin:
@@ -123,20 +134,21 @@ def set_moderator(self, *, added_by: User, admin=False, visible=False):
123134
raise BadPermission()
124135

125136
query(
126-
"""
137+
f"""
127138
UPDATE users
128-
SET moderator = TRUE, admin = :admin, visible_mod = :visible
139+
SET moderator = TRUE, visible_mod = :visible
140+
{', admin = :admin' if admin is not None else ''}
129141
WHERE id = :u
130142
""",
131-
admin=admin,
143+
admin=bool(admin),
132144
visible=visible,
133145
u=self.id,
134146
)
135147
self.global_admin = admin
136148
self.global_moderator = True
137149
self.visible_mod = visible
138150

139-
def remove_moderator(self, *, removed_by: User):
151+
def remove_moderator(self, *, removed_by: User, remove_admin_only: bool = False):
140152
"""Removes this user's global moderator/admin status, if set."""
141153

142154
if not removed_by.global_admin:
@@ -145,7 +157,14 @@ def remove_moderator(self, *, removed_by: User):
145157
)
146158
raise BadPermission()
147159

148-
query("UPDATE users SET moderator = FALSE, admin = FALSE WHERE id = :u", u=self.id)
160+
query(
161+
f"""
162+
UPDATE users
163+
SET admin = FALSE {', moderator = FALSE' if not remove_admin_only else ''}
164+
WHERE id = :u
165+
""",
166+
u=self.id,
167+
)
149168
self.global_admin = False
150169
self.global_moderator = False
151170

sogs/routes/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .general import general as general_endpoints
1010
from .onion_request import onion_request as onion_request_endpoints
1111
from .rooms import rooms as rooms_endpoints
12+
from .users import users as users_endpoints
1213

1314
from io import BytesIO
1415

@@ -21,6 +22,7 @@
2122
app.register_blueprint(general_endpoints)
2223
app.register_blueprint(onion_request_endpoints)
2324
app.register_blueprint(rooms_endpoints)
25+
app.register_blueprint(users_endpoints)
2426

2527

2628
@app.get("/")

sogs/routes/auth.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,10 @@ def handle_http_auth():
205205
)
206206

207207
user = User(session_id=pk, autovivify=True, touch=False)
208+
if user.banned:
209+
# If the user is banned don't even bother verifying the signature because we want to reject
210+
# the request whether or not the signature validation passes.
211+
abort_with_reason(http.FORBIDDEN, 'Banned', warn=False)
208212

209213
try:
210214
query('INSERT INTO user_request_nonces ("user", nonce) VALUES (:u, :n)', u=user.id, n=nonce)
@@ -240,8 +244,5 @@ def handle_http_auth():
240244
http.UNAUTHORIZED, "Invalid authentication: X-SOGS-Hash authentication failed"
241245
)
242246

243-
if user.banned:
244-
abort_with_reason(http.FORBIDDEN, 'Banned', warn=False)
245-
246247
user.touch()
247248
g.user = user

sogs/routes/rooms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from flask import abort, jsonify, g, Blueprint, request
66

7-
# General purpose routes for things like capability retrieval and batching
7+
# Room-related routes
88

99

1010
rooms = Blueprint('rooms', __name__)

sogs/routes/users.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from .. import db, http
2+
from ..model import room as mroom
3+
from ..model.user import User
4+
from ..web import app
5+
from . import auth
6+
7+
from flask import abort, jsonify, g, Blueprint, request
8+
9+
# User-related routes
10+
11+
12+
users = Blueprint('users', __name__)
13+
14+
15+
@users.post("/user/<SessionID:sid>/moderator")
16+
@auth.user_required
17+
def set_mod(sid):
18+
19+
user = User(session_id=sid)
20+
21+
req = request.json
22+
room_tokens, global_mod = req.get('rooms'), req.get('global', False)
23+
24+
if room_tokens and not isinstance(room_tokens, list):
25+
app.logger.warning("Invalid request: room_tokens must be a list")
26+
abort(http.BAD_REQUEST)
27+
28+
mod, admin, visible = (
29+
None if arg is None else bool(arg)
30+
for arg in (req.get('moderator'), req.get('admin'), req.get('visible'))
31+
)
32+
33+
# Filter out any invalid or redundant arguments:
34+
if (admin, mod) == (None, None):
35+
app.logger.warning(
36+
"Invalid moderator request: at least one of admin/moderator must be specified"
37+
)
38+
abort(http.BAD_REQUEST)
39+
elif (admin, mod) == (True, False):
40+
app.logger.warning("Invalid moderator call: admin=True, moderator=False is impossible")
41+
abort(http.BAD_REQUEST)
42+
elif (admin, mod) == (True, True):
43+
mod = None # admin already implies mod so we can ignore it
44+
elif (admin, mod) == (False, False):
45+
admin = None # ¬mod implies ¬admin so we can ignore it
46+
47+
# We now have one of these cases:
48+
# (True, None) -- adds admin
49+
# (None, True) -- adds mod
50+
# (None, False) -- removes mod/admin
51+
# (False, True) -- removes admin, adds mod
52+
# (False, None) -- removes admin
53+
54+
with db.transaction():
55+
if room_tokens:
56+
if visible is None:
57+
visible = True
58+
59+
if global_mod:
60+
app.logger.warning(
61+
"Invalid moderator request: cannot specify both 'rooms' and 'global'"
62+
)
63+
abort(http.BAD_REQUEST)
64+
65+
if len(room_tokens) > 1 and '*' in room_tokens:
66+
app.logger.warning(
67+
"Invalid moderator request: room '*' must be the only rooms value"
68+
)
69+
abort(http.BAD_REQUEST)
70+
71+
if room_tokens == ['*']:
72+
room_tokens = None
73+
74+
try:
75+
rooms = mroom.get_rooms_with_permission(g.user, tokens=room_tokens, admin=True)
76+
except Exception as e:
77+
# This is almost certainly a bad room token passed in:
78+
app.logger.warning(f"Cannot get rooms for adding a moderator: {e}")
79+
abort(http.BAD_REQUEST)
80+
81+
if room_tokens is not None:
82+
if len(rooms) != len(room_tokens):
83+
abort(http.FORBIDDEN)
84+
elif not rooms:
85+
abort(http.FORBIDDEN)
86+
87+
for room in rooms:
88+
if (admin, mod) in ((True, None), (None, True)):
89+
room.set_moderator(user, added_by=g.user, admin=admin, visible=visible)
90+
elif (admin, mod) == (None, False):
91+
room.remove_moderator(user, removed_by=g.user)
92+
elif (admin, mod) == (False, None):
93+
room.remove_moderator(user, removed_by=g.user, remove_admin_only=True)
94+
elif (admin, mod) == (False, True):
95+
room.remove_moderator(user, removed_by=g.user, remove_admin_only=True)
96+
room.set_moderator(user, added_by=g.user, admin=False, visible=visible)
97+
else:
98+
app.logger.error("Internal error: unhandled mod/admin room case")
99+
raise RuntimeError("Internal error: unhandled mod/admin room case")
100+
101+
else: # global mod
102+
if visible is None:
103+
visible = False
104+
105+
if (admin, mod) in ((True, None), (None, True)):
106+
user.set_moderator(added_by=g.user, admin=admin, visible=visible)
107+
elif (admin, mod) == (None, False):
108+
user.remove_moderator(removed_by=g.user)
109+
elif (admin, mod) == (False, None):
110+
user.remove_moderator(removed_by=g.user, remove_admin_only=True)
111+
elif (admin, mod) == (False, True):
112+
user.remove_moderator(removed_by=g.user, remove_admin_only=True)
113+
user.set_moderator(added_by=g.user, admin=bool(admin), visible=visible)
114+
115+
return jsonify({})

0 commit comments

Comments
 (0)