Skip to content

Commit 77d16e7

Browse files
committed
New endpoints: info, polling, fetching, permissions, pinning
Implements a bunch of new sogs endpoints, mostly as described by the api spec (with some changes applied to the spec, where needed): - `/rooms` -- returns list of readable rooms, including various room metadata plus derived permission of the user in the rooms - `/room/<token>` -- same as above, but for a single room - `/room/<token>/pollInfo/<id>` -- polls a room for any metadata updates applied since `<id>`. If the room's current metadata tracker (`info_updates`) is unchanged from `<id>` then this returns a light poll response with just active_users and current user permissions in the room; otherwise it includes that plus a `details` key containing the full room metadata (as would be returned by `/room/<token>`). Message fetching endpoints: - `/room/<token>/message/1234` retrieves a single message with the given id. - `/room/<token>/messages/recent` retrieves the most recent 100 messages in a room. - `/room/<token>/messages/before/123` retrieves the most recent 100 message events (i.e. post, edits, deletions) immediately preceding the *sequence value* 123. (This is not a message id, but rather the messages `seqno` value). - `/room/<token>/messages/since/456` retrieves the next 100 message events following `seqno=456`. - all of the above except for the single message endpoint support a `?limit=42` parameter that allows values up to 256. Message pinning (these all require *admin* privileges, not just moderator permissions): - POST `/room/<token>/pin/123` pins the message with id=123 to this room. (Note that this adds a pinned; if there are other pinned messages you end up with multiple pinned messages). - POST `/room/<token>/unpin/123` does what you'd expect. - POST `/room/<token>/unpin/all` does what you'd expect. - `/room/<token>/pollInfo/<id>` and `/room/<token>/messages/since/456` together replace legacy SOGS compact_poll *and* the need for periodic room info fetching. Ideally you'd poll everything in once by throwing these into a sequence request such as: [{'method': 'GET', 'path': '/room/room1/pollInfo/123'}, {'method': 'GET', 'path': '/room/room2/pollInfo/456'}, {'method': 'GET', 'path': '/room/room1/messages/since/789'}, {'method': 'GET', 'path': '/room/room2/messages/since/1011'}] The first two give you room information such as the number of active users and your current permissions, and when room details change, the full room metadata (name, description, pinned_messages, image, moderators list, etc.) The second two give you new post updates (i.e. new posts, edits, deletions) for the two rooms. (It may sometimes be necessary to submit extra fetch requests if the messages-since endpoints give back a full set of 100 message updates). Other notes: - both the polling and full version of room details include the current permission keys, `read`/`write`/`upload`/`moderator`/`admin`/`global_moderator`/`global_admin`, which clients can use to update the UI as appropriate. - Room "updates" and post "updated" are renamed to "message_sequence" and "seqno", respectively, to better describe what they are. That is, these define the sequence of updates that a client needs: a new post counts as an update, but so does an edit to and existing post and a deletion. The old name was rather confusing since there is also an `info_updates` on rooms which tracks changes to room details like name/image/etc. - Simplified the active_users to report a single value plus the cutoff for that value (which is sogs-admin configurable). Previously the API specified that it would return a default plus several different values, but that seems needlessly complex. The cutoff, however, might be useful for an advanced page to know the time frame beyond which users are no longer considered "active". - Added notes to file expiry that the short expiry until association with a post isn't implemented yet. - Fix the user_permissions view not returning true for read/write/upload for moderator permissions in rooms if the permissions were not specifically applied to the moderator. (Moderators/admins always have those permissions even if the defaults are false). - Split up the moderators/admins list in room info so that regular users can distinguish between room moderators and admins, and so that mods/admins can distinguish between hidden mods/admins. - Add extensive unit tests for everything above
1 parent 19dabaa commit 77d16e7

File tree

15 files changed

+1558
-343
lines changed

15 files changed

+1558
-343
lines changed

api.yaml

Lines changed: 203 additions & 69 deletions
Large diffs are not rendered by default.

sogs/db.py

Lines changed: 195 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ def database_init():
118118
update_message_views,
119119
create_message_details_deleter,
120120
check_for_hacks,
121+
seqno_etc_updates,
121122
):
122123
if migrate(conn):
123124
changes = True
@@ -168,6 +169,7 @@ def add_new_columns(conn):
168169
for table, cols in new_table_cols.items():
169170
for name, definition in cols.items():
170171
if name not in metadata.tables[table].c:
172+
logging.warning(f"DB migration: Adding new column {table}.{name}")
171173
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
172174
added = True
173175

@@ -177,6 +179,7 @@ def add_new_columns(conn):
177179
def add_new_tables(conn):
178180
added = False
179181
if 'user_request_nonces' not in metadata.tables:
182+
logging.warning("DB migration: Adding new table user_request_nonces")
180183
if engine.name == 'sqlite':
181184
conn.execute(
182185
"""
@@ -212,6 +215,7 @@ def add_new_tables(conn):
212215
def update_message_views(conn):
213216
if engine.name != "sqlite":
214217
if any(x not in metadata.tables['message_metadata'].c for x in ('whisper_to', 'filtered')):
218+
logging.warning("DB migration: replacing message_metadata/message_details views")
215219
conn.execute("DROP VIEW IF EXISTS message_metadata")
216220
conn.execute("DROP VIEW IF EXISTS message_details")
217221
conn.execute(
@@ -233,6 +237,7 @@ def update_message_views(conn):
233237
)
234238

235239
return True
240+
# else: don't worry about this for postgresql because initial pg support had the fix
236241

237242
return False
238243

@@ -271,8 +276,10 @@ def check_for_hacks(conn):
271276
# drop it.
272277
n_fid_hacks = conn.execute("SELECT COUNT(*) FROM file_id_hacks").first()[0]
273278
if n_fid_hacks == 0:
279+
logging.warning("Dropping file_id_hacks old sogs import table (no longer required)")
274280
metadata.tables['file_id_hacks'].drop(engine)
275281
else:
282+
logging.warning("Keeping file_id_hacks old sogs import table (still required)")
276283
global HAVE_FILE_ID_HACKS
277284
HAVE_FILE_ID_HACKS = True
278285

@@ -286,6 +293,187 @@ def check_for_hacks(conn):
286293
pass
287294

288295

296+
def seqno_etc_updates(conn):
297+
"""
298+
Rename rooms.updates/messages.updated to rooms.message_sequence/messages.seqno for better
299+
disambiguation with rooms.info_updates.
300+
301+
This also does various other changes/fixes that came at the same time as the column rename:
302+
303+
- remove "updated" from and add "pinned_by"/"pinned_at" to pinned_messages
304+
- recreate the pinned_messages table and triggers because we need several changes:
305+
- add trigger to unpin a message when the message is deleted
306+
- remove "updates" (now message_sequence) updates from room metadata update trigger
307+
- add AFTER UPDATE trigger to properly update room metadata counter when re-pinning an
308+
existing pinned message
309+
- fix user_permissions view to return true for read/write/upload to true for moderators
310+
"""
311+
312+
if 'seqno' in metadata.tables['messages'].c:
313+
return False
314+
315+
with transaction(dbconn=conn):
316+
logging.warning("Applying message_sequence renames")
317+
conn.execute("ALTER TABLE rooms RENAME COLUMN updates TO message_sequence")
318+
conn.execute("ALTER TABLE messages RENAME COLUMN updated TO seqno")
319+
320+
# We can't insert the required pinned_messages because we don't have the pinned_by user, but
321+
# that isn't a big deal since we didn't have any endpoints for pinned messsages before this
322+
# anyway, so we just recreate the whole thing (along with triggers which we also need to
323+
# update/fix)
324+
logging.warning("Recreating pinned_messages table")
325+
conn.execute("DROP TABLE pinned_messages")
326+
if engine.name == 'sqlite':
327+
conn.execute(
328+
"""
329+
CREATE TABLE pinned_messages (
330+
room INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
331+
message INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
332+
pinned_by INTEGER NOT NULL REFERENCES users(id),
333+
pinned_at FLOAT NOT NULL DEFAULT ((julianday('now') - 2440587.5)*86400.0), /* unix epoch when pinned */
334+
PRIMARY KEY(room, message)
335+
)
336+
""" # noqa: E501
337+
)
338+
conn.execute(
339+
"""
340+
CREATE TRIGGER messages_after_delete AFTER UPDATE OF data ON messages
341+
FOR EACH ROW WHEN NEW.data IS NULL AND OLD.data IS NOT NULL
342+
BEGIN
343+
-- Unpin if we deleted a pinned message:
344+
DELETE FROM pinned_messages WHERE message = OLD.id;
345+
END
346+
"""
347+
)
348+
conn.execute(
349+
"""
350+
CREATE TRIGGER room_metadata_pinned_add AFTER INSERT ON pinned_messages
351+
FOR EACH ROW
352+
BEGIN
353+
UPDATE rooms SET info_updates = info_updates + 1 WHERE id = NEW.room;
354+
END
355+
"""
356+
)
357+
conn.execute(
358+
"""
359+
CREATE TRIGGER room_metadata_pinned_update AFTER UPDATE ON pinned_messages
360+
FOR EACH ROW
361+
BEGIN
362+
UPDATE rooms SET info_updates = info_updates + 1 WHERE id = NEW.room;
363+
END
364+
"""
365+
)
366+
conn.execute(
367+
"""
368+
CREATE TRIGGER room_metadata_pinned_remove AFTER DELETE ON pinned_messages
369+
FOR EACH ROW
370+
BEGIN
371+
UPDATE rooms SET info_updates = info_updates + 1 WHERE id = OLD.room;
372+
END
373+
"""
374+
)
375+
376+
logging.warning("Fixing user_permissions view")
377+
conn.execute("DROP VIEW IF EXISTS user_permissions")
378+
conn.execute(
379+
"""
380+
CREATE VIEW user_permissions AS
381+
SELECT
382+
rooms.id AS room,
383+
users.id AS user,
384+
users.session_id,
385+
CASE WHEN users.banned THEN TRUE ELSE COALESCE(user_permission_overrides.banned, FALSE) END AS banned,
386+
CASE WHEN users.moderator THEN TRUE ELSE COALESCE(user_permission_overrides.read, rooms.read) END AS read,
387+
CASE WHEN users.moderator THEN TRUE ELSE COALESCE(user_permission_overrides.write, rooms.write) END AS write,
388+
CASE WHEN users.moderator THEN TRUE ELSE COALESCE(user_permission_overrides.upload, rooms.upload) END AS upload,
389+
CASE WHEN users.moderator THEN TRUE ELSE COALESCE(user_permission_overrides.moderator, FALSE) END AS moderator,
390+
CASE WHEN users.admin THEN TRUE ELSE COALESCE(user_permission_overrides.admin, FALSE) END AS admin,
391+
-- room_moderator will be TRUE if the user is specifically listed as a moderator of the room
392+
COALESCE(user_permission_overrides.moderator OR user_permission_overrides.admin, FALSE) AS room_moderator,
393+
-- global_moderator will be TRUE if the user is a global moderator/admin (note that this is
394+
-- *not* exclusive of room_moderator: a moderator/admin could be listed in both).
395+
COALESCE(users.moderator OR users.admin, FALSE) as global_moderator,
396+
-- visible_mod will be TRUE if this mod is a publicly viewable moderator of the room
397+
CASE
398+
WHEN user_permission_overrides.moderator OR user_permission_overrides.admin THEN user_permission_overrides.visible_mod
399+
WHEN users.moderator OR users.admin THEN users.visible_mod
400+
ELSE FALSE
401+
END AS visible_mod
402+
FROM
403+
users JOIN rooms LEFT OUTER JOIN user_permission_overrides ON
404+
users.id = user_permission_overrides.user AND rooms.id = user_permission_overrides.room
405+
""" # noqa: E501
406+
)
407+
408+
else: # postgresql
409+
logging.warning("Recreating pinned_messages table")
410+
conn.execute(
411+
"""
412+
CREATE TABLE pinned_messages (
413+
room BIGINT NOT NULL REFERENCES rooms ON DELETE CASCADE,
414+
message BIGINT NOT NULL REFERENCES rooms ON DELETE CASCADE,
415+
pinned_by BIGINT NOT NULL REFERENCES users,
416+
pinned_at FLOAT NOT NULL DEFAULT (extract(epoch from now())),
417+
PRIMARY KEY(room, message)
418+
);
419+
420+
421+
-- Trigger to handle required updates after a message gets deleted (in the SOGS context: that is,
422+
-- has data set to NULL)
423+
CREATE OR REPLACE FUNCTION trigger_messages_after_delete()
424+
RETURNS TRIGGER LANGUAGE PLPGSQL AS $$BEGIN
425+
-- Unpin if we deleted a pinned message:
426+
DELETE FROM pinned_messages WHERE message = OLD.id;
427+
RETURN NULL;
428+
END;$$;
429+
CREATE TRIGGER messages_insert_history AFTER UPDATE OF data ON messages
430+
FOR EACH ROW WHEN (NEW.data IS DISTINCT FROM OLD.data)
431+
EXECUTE PROCEDURE trigger_messages_insert_history();
432+
433+
CREATE TRIGGER room_metadata_pinned_add AFTER INSERT OR UPDATE ON pinned_messages
434+
FOR EACH ROW
435+
EXECUTE PROCEDURE trigger_room_metadata_info_update_new();
436+
437+
CREATE TRIGGER room_metadata_pinned_remove AFTER DELETE ON pinned_messages
438+
FOR EACH ROW
439+
EXECUTE PROCEDURE trigger_room_metadata_info_update_old();
440+
"""
441+
)
442+
443+
logging.warning("Fixing user_permissions view")
444+
conn.execute(
445+
"""
446+
CREATE OR REPLACE VIEW user_permissions AS
447+
SELECT
448+
rooms.id AS room,
449+
users.id AS "user",
450+
users.session_id,
451+
CASE WHEN users.banned THEN TRUE ELSE COALESCE(user_permission_overrides.banned, FALSE) END AS banned,
452+
CASE WHEN users.moderator THEN TRUE ELSE COALESCE(user_permission_overrides.read, rooms.read) END AS read,
453+
CASE WHEN users.moderator THEN TRUE ELSE COALESCE(user_permission_overrides.write, rooms.write) END AS write,
454+
CASE WHEN users.moderator THEN TRUE ELSE COALESCE(user_permission_overrides.upload, rooms.upload) END AS upload,
455+
CASE WHEN users.moderator THEN TRUE ELSE COALESCE(user_permission_overrides.moderator, FALSE) END AS moderator,
456+
CASE WHEN users.admin THEN TRUE ELSE COALESCE(user_permission_overrides.admin, FALSE) END AS admin,
457+
-- room_moderator will be TRUE if the user is specifically listed as a moderator of the room
458+
COALESCE(user_permission_overrides.moderator OR user_permission_overrides.admin, FALSE) AS room_moderator,
459+
-- global_moderator will be TRUE if the user is a global moderator/admin (note that this is
460+
-- *not* exclusive of room_moderator: a moderator/admin could be listed in both).
461+
COALESCE(users.moderator OR users.admin, FALSE) as global_moderator,
462+
-- visible_mod will be TRUE if this mod is a publicly viewable moderator of the room
463+
CASE
464+
WHEN user_permission_overrides.moderator OR user_permission_overrides.admin THEN user_permission_overrides.visible_mod
465+
WHEN users.moderator OR users.admin THEN users.visible_mod
466+
ELSE FALSE
467+
END AS visible_mod
468+
FROM
469+
users CROSS JOIN rooms LEFT OUTER JOIN user_permission_overrides ON
470+
(users.id = user_permission_overrides."user" AND rooms.id = user_permission_overrides.room);
471+
""" # noqa: E501
472+
)
473+
474+
return True
475+
476+
289477
def create_admin_user(dbconn):
290478
"""
291479
We create a dummy user (with id 0) for system tasks such as changing moderators from
@@ -305,7 +493,7 @@ def create_admin_user(dbconn):
305493

306494

307495
if config.DB_URL.startswith('postgresql'):
308-
# room.token is a 'citext' (case-insensitive text), which sqlalchemy doesn't recognize out of
496+
# rooms.token is a 'citext' (case-insensitive text), which sqlalchemy doesn't recognize out of
309497
# the box. Map it to a plain TEXT which is good enough for what we need (if we actually needed
310498
# to generate this wouldn't suffice: we'd have to use something like the sqlalchemy-citext
311499
# module).
@@ -342,6 +530,12 @@ def _init_engine(*args, **kwargs):
342530
if engine.name == "sqlite":
343531
import sqlite3
344532

533+
if sqlite3.sqlite_version_info < (3, 25, 0):
534+
raise RuntimeError(
535+
f"SQLite3 library version {'.'.join(sqlite3.sqlite_version_info)} "
536+
"is too old for pysogs (3.25.0+ required)!"
537+
)
538+
345539
have_returning = sqlite3.sqlite_version_info >= (3, 35, 0)
346540

347541
@sqlalchemy.event.listens_for(engine, "connect")

sogs/model/exc.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ def __init__(self, session_id):
3434
super().__init__(f"No such user: {session_id}")
3535

3636

37+
class NoSuchPost(NotFound):
38+
"""Thrown when attempting to retrieve or reference a post that doesn't exist"""
39+
40+
def __init__(self, id):
41+
self.id = id
42+
super().__init__(f"No such post: {id}")
43+
44+
3745
class AlreadyExists(RuntimeError):
3846
"""
3947
Thrown when attempting to create a record (e.g. a Room) that already exists.

0 commit comments

Comments
 (0)