From 57ef596898a5a3932db33389baa9fab3164d430a Mon Sep 17 00:00:00 2001 From: LemonInTheDark <58055496+LemonInTheDark@users.noreply.github.com> Date: Wed, 31 May 2023 15:45:32 -0700 Subject: [PATCH] Admin Library Moderation (in-game edition) (#75645) For the longest time, the only way admins could moderate the library was by using statbus's external tool. But a few months back statbus went down, and ever since then they've been sitting lost. Shit sucks. The whole external thing has been bugging me for a while, so let's fix all that yeah? This pr adds a new verb to the admin tab that allows admins to ban/restore books from the library. It includes expanded (ckey) search, faster response times, in tool book viewing with and without markdown rendering, and viewing of deleted books. This is accomplished with a special subtype of library consoles, stored on the admin datum. It shouldn't let you do anything without +BAN, rip my live debugging or whatever. I've also hooked into (and fixed) Ned's existing library actions log, and added viewing support to the ban/restore pages. This logs banning admin, ban time, ban reason, etc. As a part of this, I've fixed/expanded on the existing UIs. I've added ID search to all existing consoles, and fixed an existing bug with the visitor console not supporting category search (shows how many people actually use the thing) Changes to the library_action table were pretty minor. The ckey column was too small, so longer keys just caused it to fail on ban. Bad. That and the ip address column was signed, which wasted space and was non standard with other tables. --- SQL/database_changelog.md | 15 +- SQL/tgstation_schema.sql | 4 +- SQL/tgstation_schema_prefixed.sql | 4 +- code/__DEFINES/library.dm | 9 + code/__DEFINES/subsystems.dm | 2 +- code/modules/admin/admin_verbs.dm | 13 +- code/modules/admin/holder2.dm | 1 + code/modules/library/admin_only.dm | 367 +++++++++++++ code/modules/library/lib_machines.dm | 47 +- tgstation.dme | 2 + .../tgui/interfaces/AdminBookViewer.tsx | 25 + .../packages/tgui/interfaces/LibraryAdmin.tsx | 487 ++++++++++++++++++ .../tgui/interfaces/LibraryConsole.js | 16 +- .../tgui/interfaces/LibraryVisitor.js | 18 +- .../tgui/styles/interfaces/LibraryAdmin.scss | 20 + tgui/packages/tgui/styles/main.scss | 1 + tgui/packages/tgui/styles/themes/admin.scss | 7 + .../maplint/lints/admin_library_computer.yml | 3 + 18 files changed, 1002 insertions(+), 39 deletions(-) create mode 100644 code/__DEFINES/library.dm create mode 100644 code/modules/library/admin_only.dm create mode 100644 tgui/packages/tgui/interfaces/AdminBookViewer.tsx create mode 100644 tgui/packages/tgui/interfaces/LibraryAdmin.tsx create mode 100644 tgui/packages/tgui/styles/interfaces/LibraryAdmin.scss create mode 100644 tools/maplint/lints/admin_library_computer.yml diff --git a/SQL/database_changelog.md b/SQL/database_changelog.md index 0ef5b37670b..03f2363aae9 100644 --- a/SQL/database_changelog.md +++ b/SQL/database_changelog.md @@ -2,19 +2,28 @@ Any time you make a change to the schema files, remember to increment the databa Make sure to also update `DB_MAJOR_VERSION` and `DB_MINOR_VERSION`, which can be found in `code/__DEFINES/subsystem.dm`. -The latest database version is 5.23; The query to update the schema revision table is: +The latest database version is 5.24; The query to update the schema revision table is: ```sql -INSERT INTO `schema_revision` (`major`, `minor`) VALUES (5, 23); +INSERT INTO `schema_revision` (`major`, `minor`) VALUES (5, 24); ``` or ```sql -INSERT INTO `SS13_schema_revision` (`major`, `minor`) VALUES (5, 23); +INSERT INTO `SS13_schema_revision` (`major`, `minor`) VALUES (5, 24); ``` In any query remember to add a prefix to the table names if you use one. +----------------------------------------------------- +Version 5.24, 17 May 2023, by LemonInTheDark +Modified the library action table to fit ckeys properly, and to properly store ips. +Adds a new index to the library table to speed up admin searches +```sql + ALTER TABLE `library_action` MODIFY COLUMN `ckey` varchar(32) NOT NULL; + ALTER TABLE `library_action` MODIFY COLUMN `ip_addr` int(10) unsigned NOT NULL; +``` + ----------------------------------------------------- Version 5.23, 28 December 2022, by Mothblocks Added `tutorial_completions` to mark what ckeys have completed contextual tutorials. diff --git a/SQL/tgstation_schema.sql b/SQL/tgstation_schema.sql index f09b6f9a1e9..6a140dc16d0 100644 --- a/SQL/tgstation_schema.sql +++ b/SQL/tgstation_schema.sql @@ -270,10 +270,10 @@ CREATE TABLE `library_action` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `book` int(10) unsigned NOT NULL, `reason` longtext DEFAULT NULL, - `ckey` varchar(11) NOT NULL DEFAULT '', + `ckey` varchar(32) NOT NULL DEFAULT '', `datetime` datetime NOT NULL DEFAULT current_timestamp(), `action` varchar(11) NOT NULL DEFAULT '', - `ip_addr` int(11) NOT NULL, + `ip_addr` int(10) unsigned NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=27 DEFAULT CHARSET=utf8mb4; /*!40101 SET character_set_client = @saved_cs_client */; diff --git a/SQL/tgstation_schema_prefixed.sql b/SQL/tgstation_schema_prefixed.sql index 7fe538bc6f5..a700ae8da9f 100644 --- a/SQL/tgstation_schema_prefixed.sql +++ b/SQL/tgstation_schema_prefixed.sql @@ -270,10 +270,10 @@ CREATE TABLE `SS13_library_action` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `book` int(10) unsigned NOT NULL, `reason` longtext DEFAULT NULL, - `ckey` varchar(11) NOT NULL DEFAULT '', + `ckey` varchar(32) NOT NULL DEFAULT '', `datetime` datetime NOT NULL DEFAULT current_timestamp(), `action` varchar(11) NOT NULL DEFAULT '', - `ip_addr` int(11) NOT NULL, + `ip_addr` int(10) unsigned NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=27 DEFAULT CHARSET=utf8mb4; /*!40101 SET character_set_client = @saved_cs_client */; diff --git a/code/__DEFINES/library.dm b/code/__DEFINES/library.dm new file mode 100644 index 00000000000..0c84434578d --- /dev/null +++ b/code/__DEFINES/library.dm @@ -0,0 +1,9 @@ +#define DEFAULT_UPLOAD_CATAGORY "Fiction" +#define DEFAULT_SEARCH_CATAGORY "Any" + +///How many books should we load per page? +#define BOOKS_PER_PAGE 18 +///How many checkout records should we load per page? +#define CHECKOUTS_PER_PAGE 17 +///How many inventory items should we load per page? +#define INVENTORY_PER_PAGE 19 diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm index 8a8add2739d..33deb2df67c 100644 --- a/code/__DEFINES/subsystems.dm +++ b/code/__DEFINES/subsystems.dm @@ -20,7 +20,7 @@ * * make sure you add an update to the schema_version stable in the db changelog */ -#define DB_MINOR_VERSION 23 +#define DB_MINOR_VERSION 24 //! ## Timing subsystem diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index 657dabd0cdc..92d51100a5f 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -93,7 +93,7 @@ GLOBAL_PROTECT(admin_verbs_admin) /client/proc/cmd_admin_law_panel, /client/proc/log_viewer_new, ) -GLOBAL_LIST_INIT(admin_verbs_ban, list(/client/proc/unban_panel, /client/proc/ban_panel, /client/proc/stickybanpanel)) +GLOBAL_LIST_INIT(admin_verbs_ban, list(/client/proc/unban_panel, /client/proc/ban_panel, /client/proc/stickybanpanel, /client/proc/library_control)) GLOBAL_PROTECT(admin_verbs_ban) GLOBAL_LIST_INIT(admin_verbs_sounds, list(/client/proc/play_local_sound, /client/proc/play_direct_mob_sound, /client/proc/play_sound, /client/proc/set_round_end_sound)) GLOBAL_PROTECT(admin_verbs_sounds) @@ -1025,3 +1025,14 @@ GLOBAL_PROTECT(admin_verbs_poll) message_admins("[key_name_admin(usr)] has loaded lazy template '[choice]'") to_chat(usr, span_boldnicegreen("Template loaded, you have been moved to the bottom left of the reservation.")) + +/client/proc/library_control() + set name = "Library Management" + set category = "Admin" + if(!check_rights(R_BAN)) + return + + if(!holder.library_manager) + holder.library_manager = new() + holder.library_manager.ui_interact(usr) + SSblackbox.record_feedback("tally", "admin_verb", 1, "Library Management") // If you are copy-pasting this, ensure the 4th parameter is unique to the new proc! diff --git a/code/modules/admin/holder2.dm b/code/modules/admin/holder2.dm index bbdb6bfbe22..8cb57ff8f83 100644 --- a/code/modules/admin/holder2.dm +++ b/code/modules/admin/holder2.dm @@ -35,6 +35,7 @@ GLOBAL_PROTECT(href_token) var/datum/particle_editor/particle_test var/datum/colorblind_tester/color_test = new var/datum/plane_master_debug/plane_debug + var/obj/machinery/computer/libraryconsole/admin_only_do_not_map_in_you_fucker/library_manager /// Whether or not the user tried to connect, but was blocked by 2FA var/blocked_by_2fa = FALSE diff --git a/code/modules/library/admin_only.dm b/code/modules/library/admin_only.dm new file mode 100644 index 00000000000..3e10617d9fe --- /dev/null +++ b/code/modules/library/admin_only.dm @@ -0,0 +1,367 @@ +#define BOOK_ADMIN_DELETE "deleted" +#define BOOK_ADMIN_RESTORE "undeleted" +#define BOOK_ADMIN_REPORT "reported" + +/obj/machinery/computer/libraryconsole/admin_only_do_not_map_in_you_fucker + interface_type = "LibraryAdmin" + /// When a user clicks view, do we display the raw text, or process it with markdown + var/view_raw = FALSE + /// If we should show deleted entries or not + var/show_deleted = TRUE + /// The current ckey we're looking for + var/ckey = "" + /// List mapping requested book ids to a list of their edit logs + var/list/book_history = list() + + +/obj/machinery/computer/libraryconsole/admin_only_do_not_map_in_you_fucker/can_db_request() + if(sending_request) + return FALSE + return TRUE + +/obj/machinery/computer/libraryconsole/admin_only_do_not_map_in_you_fucker/hash_search_info() + . = ..() + return "[.]-[ckey]-[show_deleted]" + +/obj/machinery/computer/libraryconsole/admin_only_do_not_map_in_you_fucker/update_page_contents() + if(sending_request) //Final defense against nerds spamming db requests + return + sending_request = TRUE + search_page = clamp(search_page, 0, page_count) + var/datum/db_query/query_library_list_books = SSdbcore.NewQuery({" + SELECT id, author, title, category, ckey, deleted + FROM [format_table_name("library")] + [show_deleted ? "" : "WHERE deleted IS NULL"] + [show_deleted ? "WHERE" : "AND"] author LIKE CONCAT('%',:author,'%') + AND title LIKE CONCAT('%',:title,'%') + AND (:category = 'Any' OR category = :category) + [book_id ? "AND id LIKE CONCAT('%', :book_id, '%')" : ""] + AND ckey LIKE CONCAT('%',:ckey,'%') + ORDER BY id DESC + LIMIT :skip, :take + "}, list("author" = author, "title" = title, "book_id" = book_id, "category" = category, "ckey" = ckey, "skip" = BOOKS_PER_PAGE * search_page, "take" = BOOKS_PER_PAGE)) + + var/query_succeeded = query_library_list_books.Execute() + sending_request = FALSE + page_content.Cut() + if(!query_succeeded) + qdel(query_library_list_books) + return + while(query_library_list_books.NextRow()) + page_content += list(list( + "id" = query_library_list_books.item[1], + "author" = html_decode(query_library_list_books.item[2]), + "title" = html_decode(query_library_list_books.item[3]), + "category" = query_library_list_books.item[4], + "author_ckey" = query_library_list_books.item[5], + "deleted" = query_library_list_books.item[6], + )) + qdel(query_library_list_books) + +/obj/machinery/computer/libraryconsole/admin_only_do_not_map_in_you_fucker/update_page_count() + var/bookcount = 0 + var/datum/db_query/query_library_count_books = SSdbcore.NewQuery({" + SELECT COUNT(id) FROM [format_table_name("library")] + [show_deleted ? "" : "WHERE deleted IS NULL"] + [show_deleted ? "WHERE" : "AND"] author LIKE CONCAT('%',:author,'%') + AND title LIKE CONCAT('%',:title,'%') + AND (:category = 'Any' OR category = :category) + [book_id ? "AND id LIKE CONCAT('%', :book_id, '%')" : ""] + AND ckey LIKE CONCAT('%',:ckey,'%') + "}, list("author" = author, "title" = title, "book_id" = book_id, "category" = category, "ckey" = ckey)) + + if(!query_library_count_books.warn_execute()) + qdel(query_library_count_books) + return + if(query_library_count_books.NextRow()) + bookcount = text2num(query_library_count_books.item[1]) + qdel(query_library_count_books) + + page_count = round(max(bookcount - 1, 0) / BOOKS_PER_PAGE) //This is just floor() + search_page = clamp(search_page, 0, page_count) + +/obj/machinery/computer/libraryconsole/admin_only_do_not_map_in_you_fucker/ui_status(mob/user) + if(!check_rights_for(user.client, R_BAN)) + return UI_CLOSE + if(!SSdbcore.Connect()) + can_connect = FALSE + return UI_CLOSE + return UI_INTERACTIVE + +/obj/machinery/computer/libraryconsole/admin_only_do_not_map_in_you_fucker/ui_act(action, params, datum/tgui/ui) + . = ..() + if(.) + // We'll always trigger a search attempt if the parent does something, this ensures the ui is v fast to update + INVOKE_ASYNC(src, PROC_REF(update_db_info)) + return + switch(action) + if("set_search_ckey") + ckey = params["ckey"] + INVOKE_ASYNC(src, PROC_REF(update_db_info)) + return TRUE + if("refresh") + last_search_hash = "" + INVOKE_ASYNC(src, PROC_REF(update_db_info)) + return TRUE + if("hide_book") + var/reason = params["delete_reason"] + var/id = params["book_id"] + var/client/actor = ui.user?.client + if(!actor) + return + INVOKE_ASYNC(src, PROC_REF(hide_book), id, reason, actor) + return TRUE + if("unhide_book") + var/reason = params["free_reason"] + var/id = params["book_id"] + var/client/actor = ui.user?.client + if(!actor) + return + INVOKE_ASYNC(src, PROC_REF(unhide_book), id, reason, actor) + return TRUE + if("get_history") + var/id = params["book_id"] + book_history["[id]"] = get_book_history(id) + return TRUE + if("view_book") + var/id = params["book_id"] + view_book(id, ui.user) + return TRUE + if("toggle_raw") + view_raw = !view_raw + return TRUE + if("toggle_deleted") + show_deleted = !show_deleted + INVOKE_ASYNC(src, PROC_REF(update_db_info)) + return TRUE + +/obj/machinery/computer/libraryconsole/admin_only_do_not_map_in_you_fucker/ui_data(mob/user) + . = ..() + .["view_raw"] = view_raw + .["show_deleted"] = show_deleted + var/list/histories = list() + for(var/id as anything in book_history) + var/list/insert = list() + for(var/datum/book_history_entry/entry in book_history[id]) + insert += list(entry.serialize()) + histories[id] = insert + .["history"] = histories + +/obj/machinery/computer/libraryconsole/admin_only_do_not_map_in_you_fucker/proc/view_book(id, mob/show_to) + if (!SSdbcore.Connect()) + can_connect = FALSE + message_admins("Failed to establish database connection.") + return + + var/datum/db_query/query_library_view = SSdbcore.NewQuery( + "SELECT * FROM [format_table_name("library")] WHERE id=:id", + list("id" = id) + ) + if(!query_library_view.Execute()) + qdel(query_library_view) + return + + while(query_library_view.NextRow()) + var/datum/admin_book_viewer/viewer = new() + viewer.set_owner(src) + viewer.id = query_library_view.item[1] + viewer.author = query_library_view.item[2] + viewer.title = query_library_view.item[3] + viewer.content = query_library_view.item[4] + viewer.category = query_library_view.item[5] + viewer.author_ckey = query_library_view.item[6] + viewer.creation_time = query_library_view.item[7] + viewer.deleted = query_library_view.item[8] + viewer.creation_round = query_library_view.item[9] + viewer.history = get_book_history(id) + viewer.ui_interact(show_to) + break + qdel(query_library_view) + +/obj/machinery/computer/libraryconsole/admin_only_do_not_map_in_you_fucker/proc/get_book_history(id) + var/datum/db_query/query_book_history = SSdbcore.NewQuery({" + SELECT id, book, reason, ckey, datetime, action, INET_NTOA(ip_addr) + FROM [format_table_name("library_action")] WHERE book=:id + "}, + list("id" = id) + ) + if(!query_book_history.Execute()) + qdel(query_book_history) + return list() + + var/list/full_history = list() + while(query_book_history.NextRow()) + var/datum/book_history_entry/history = new() + history.id = query_book_history.item[1] + history.book = query_book_history.item[2] + history.reason = query_book_history.item[3] + history.ckey = query_book_history.item[4] + history.datetime = query_book_history.item[5] + history.action = query_book_history.item[6] + history.ip_addr = query_book_history.item[7] + full_history += history + qdel(query_book_history) + return full_history + +/obj/machinery/computer/libraryconsole/admin_only_do_not_map_in_you_fucker/proc/hide_book(id, reason, client/admin) + if(!SSdbcore.Connect()) + can_connect = FALSE + to_chat(admin, span_danger("Failed to establish database connection.")) + return + if(!check_rights_for(admin, R_BAN)) + log_admin_private("[admin.ckey] tried to hide a book without the required perms") + message_admins("[admin.ckey] tried to hide a book without the required perms") + return + + var/datum/db_query/query_hide_book = SSdbcore.NewQuery({" + UPDATE [format_table_name("library")] + SET deleted = 1 + WHERE id = :id + "}, list("id" = id)) + if(!query_hide_book.warn_execute()) + qdel(query_hide_book) + return + qdel(query_hide_book) + + + var/datum/db_query/query_update_log = SSdbcore.NewQuery({" + INSERT INTO [format_table_name("library_action")] (book, reason, ckey, datetime, action, ip_addr) + VALUES (:book, :reason, :ckey, Now(), :action, INET_ATON(:ip_addr)) + "}, list("book" = id, "reason" = reason, "ckey" = admin.ckey, "action" = BOOK_ADMIN_DELETE, "ip_addr" = admin.address)) + if(!query_update_log.warn_execute()) + qdel(query_update_log) + return + qdel(query_update_log) + + var/log_reason = "([admin.ckey]) hid book #[id][reason ? ": \"[reason]\"" : ""]" + log_admin_private(log_reason) + library_updated() + update_db_info() + +/obj/machinery/computer/libraryconsole/admin_only_do_not_map_in_you_fucker/proc/unhide_book(id, reason, client/admin) + if(!SSdbcore.Connect()) + can_connect = FALSE + to_chat(admin, span_danger("Failed to establish database connection.")) + return + if(!check_rights_for(admin, R_BAN)) + log_admin_private("[admin.ckey] tried to unhide a book without the required perms") + message_admins("[admin.ckey] tried to unhide a book without the required perms") + return + + var/datum/db_query/query_unhide_book = SSdbcore.NewQuery({" + UPDATE [format_table_name("library")] + SET deleted = NULL + WHERE id = :id + "}, list("id" = id)) + + if(!query_unhide_book.warn_execute()) + qdel(query_unhide_book) + return + qdel(query_unhide_book) + + var/datum/db_query/query_update_log = SSdbcore.NewQuery({" + INSERT INTO [format_table_name("library_action")] (book, reason, ckey, datetime, action, ip_addr) + VALUES (:book, :reason, :ckey, Now(), :action, INET_ATON(:ip_addr)) + "}, list("book" = id, "reason" = reason, "ckey" = admin.ckey, "action" = BOOK_ADMIN_RESTORE, "ip_addr" = admin.address)) + if(!query_update_log.warn_execute()) + qdel(query_update_log) + return + qdel(query_update_log) + + log_admin_private("([admin.ckey]) unhid book #[id]") + library_updated() + update_db_info() + +/// This mostly exists to document the form of the library_action table, since it doesn't do that good a job on its own +/datum/book_history_entry + /// The id of this logged action + var/id + /// The book id this log applies to + var/book + /// The reason this action was enacted + var/reason + /// The admin who performed the action + var/ckey + /// The time of the action being performed + var/datetime + /// The action that occured (BOOK_ADMIN_DELETE, BOOK_ADMIN_RESTORE, and legacy BOOK_ADMIN_REPORT) + var/action + /// The ip address of the admin who performed the action + var/ip_addr + +/datum/book_history_entry/proc/serialize() + var/list/data = list() + data["id"] = id + data["book"] = book + data["reason"] = reason + data["ckey"] = ckey + data["datetime"] = datetime + data["action"] = action + data["address"] = ip_addr + return data + +/// Weaps around a book's sql data, feeds it into a ui that allows us to at base view the contents of the book +/datum/admin_book_viewer + /// Weakref to the /obj/machinery/computer/libraryconsole/admin_only_do_not_map_in_you_fucker that spawned us + var/datum/weakref/owner_ref + /// If we're displaying raw data or rendered markdown + var/view_raw = FALSE + /// The book id. Incremental, goes up over time + var/id + /// The display name for the book, taken from the player's character + var/author + /// Title of the book + var/title + /// The full text of the book, stored raw + var/content + /// Category the book falls into, see SSlibrary.search_categories + var/category + /// The ckey of the user who triggered the upload request + var/author_ckey + /// The time of day at which the book was uploaded + var/creation_time + /// Boolean, flips to true to "hide" a book from public viewing. Defaults to null + var/deleted + /// The round id the book was uploaded in + var/creation_round + /// Represents the full admin record of this book, as of the view request. Datumized to make it easier to deal with. + var/list/datum/book_history_entry/history + +/datum/admin_book_viewer/proc/set_owner(obj/machinery/computer/libraryconsole/admin_only_do_not_map_in_you_fucker/owner) + owner_ref = WEAKREF(owner) + view_raw = owner.view_raw + +/datum/admin_book_viewer/ui_interact(mob/user, datum/tgui/ui) + . = ..() + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "AdminBookViewer") + ui.set_autoupdate(FALSE) // Nothing is changing here brother + ui.open() + +/datum/admin_book_viewer/ui_status(mob/user) + if(!check_rights_for(user.client, R_BAN)) + return UI_CLOSE + return UI_INTERACTIVE + +/datum/admin_book_viewer/ui_data(mob/user) + var/list/data = list() + data["view_raw"] = view_raw + data["id"] = id + data["author"] = author + data["title"] = title + data["content"] = content + data["category"] = category + data["author_ckey"] = author_ckey + data["creation_time"] = creation_time + data["deleted"] = deleted + data["creation_round"] = creation_round + data["history"] = list() + for(var/datum/book_history_entry/entry as anything in history) + data["history"] += list(entry.serialize()) + + return data + +#undef BOOK_ADMIN_DELETE +#undef BOOK_ADMIN_RESTORE +#undef BOOK_ADMIN_REPORT diff --git a/code/modules/library/lib_machines.dm b/code/modules/library/lib_machines.dm index 316213dc66d..3adf652eb13 100644 --- a/code/modules/library/lib_machines.dm +++ b/code/modules/library/lib_machines.dm @@ -9,15 +9,12 @@ * Book Binder */ -#define DEFAULT_UPLOAD_CATAGORY "Fiction" -#define DEFAULT_SEARCH_CATAGORY "Any" +GLOBAL_VAR_INIT(library_table_modified, 0) + +/// Increments every time WE update the library db table, causes all existing consoles to repull when they next check +/proc/library_updated() + GLOB.library_table_modified = (GLOB.library_table_modified + 1) % (SHORT_REAL_LIMIT - 1) -///How many books should we load per page? -#define BOOKS_PER_PAGE 18 -///How many checkout records should we load per page? -#define CHECKOUTS_PER_PAGE 17 -///How many inventory items should we load per page? -#define INVENTORY_PER_PAGE 19 /* * Library Public Computer */ @@ -29,6 +26,8 @@ circuit = /obj/item/circuitboard/computer/libraryconsole desc = "Checked out books MUST be returned on time." anchored_tabletop_offset = 8 + ///The current book id we're searching for + var/book_id = null ///The current title we're searching for var/title = "" ///The category we're searching for @@ -73,6 +72,7 @@ data["category"] = category data["author"] = author data["title"] = title + data["book_id"] = book_id data["page_count"] = page_count + 1 //Increase these by one so it looks like we're not indexing at 0 data["our_page"] = search_page + 1 data["pages"] = page_content @@ -85,6 +85,12 @@ if(.) return switch(action) + if("set_search_id") + var/newid = text2num(params["id"]) + if(newid != book_id) + params_changed = TRUE + book_id = newid + return TRUE if("set_search_title") var/newtitle = params["title"] if(newtitle != title) @@ -184,7 +190,7 @@ return TRUE /obj/machinery/computer/libraryconsole/proc/hash_search_info() - return "[title]-[author]-[category]-[search_page]-[page_count]" + return "[GLOB.library_table_modified]-[book_id]-[title]-[author]-[category]-[search_page]-[page_count]" /obj/machinery/computer/libraryconsole/proc/update_page_contents() if(sending_request) //Final defense against nerds spamming db requests @@ -198,9 +204,10 @@ AND author LIKE CONCAT('%',:author,'%') AND title LIKE CONCAT('%',:title,'%') AND (:category = 'Any' OR category = :category) + [book_id ? "AND id LIKE CONCAT('%', :book_id, '%')" : ""] ORDER BY id DESC LIMIT :skip, :take - "}, list("author" = author, "title" = title, "category" = category, "skip" = BOOKS_PER_PAGE * search_page, "take" = BOOKS_PER_PAGE)) + "}, list("author" = author, "title" = title, "book_id" = book_id, "category" = category, "skip" = BOOKS_PER_PAGE * search_page, "take" = BOOKS_PER_PAGE)) var/query_succeeded = query_library_list_books.Execute() sending_request = FALSE @@ -225,7 +232,8 @@ AND author LIKE CONCAT('%',:author,'%') AND title LIKE CONCAT('%',:title,'%') AND (:category = 'Any' OR category = :category) - "}, list("author" = author, "title" = title, "category" = category)) + [book_id ? "AND id LIKE CONCAT('%', :book_id, '%')" : ""] + "}, list("author" = author, "title" = title, "book_id" = book_id, "category" = category)) if(!query_library_count_books.warn_execute()) qdel(query_library_count_books) @@ -301,8 +309,6 @@ var/inventory_page = 0 ///Should we load our inventory from the bookselves in our area? var/dynamic_inv_load = FALSE - ///Toggled if some bit of code wants to override hashing and allow for page updates - var/ignore_hash = FALSE ///Book scanner that will be used when uploading books to the Archive var/datum/weakref/scanner ///Our cooldown on using the printer @@ -567,14 +573,6 @@ return obj_flags |= EMAGGED -/obj/machinery/computer/libraryconsole/bookmanagement/has_anything_changed() - if(..()) - return TRUE - if(!ignore_hash) - return FALSE - ignore_hash = FALSE - return TRUE - /obj/machinery/computer/libraryconsole/bookmanagement/proc/set_screen_state(new_state) screen_state = clamp(new_state, MIN_LIBRARY, MAX_LIBRARY) @@ -631,8 +629,8 @@ return usr.log_message(msg, LOG_GAME) qdel(query_library_upload) + library_updated() say("Upload Complete. Uploaded title will be available for printing in a moment") - ignore_hash = TRUE update_db_info() /// Call this proc to attempt a print. It will return false if the print failed, true otherwise, longside some ux @@ -842,11 +840,6 @@ qdel(draw_from) -#undef BOOKS_PER_PAGE -#undef CHECKOUTS_PER_PAGE -#undef DEFAULT_SEARCH_CATAGORY -#undef DEFAULT_UPLOAD_CATAGORY -#undef INVENTORY_PER_PAGE #undef LIBRARY_ARCHIVE #undef LIBRARY_CHECKOUT #undef LIBRARY_INVENTORY diff --git a/tgstation.dme b/tgstation.dme index a7041016f8e..bf40e6e085d 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -125,6 +125,7 @@ #include "code\__DEFINES\language.dm" #include "code\__DEFINES\layers.dm" #include "code\__DEFINES\lazy_templates.dm" +#include "code\__DEFINES\library.dm" #include "code\__DEFINES\lighting.dm" #include "code\__DEFINES\lights.dm" #include "code\__DEFINES\living.dm" @@ -3717,6 +3718,7 @@ #include "code\modules\language\uncommon.dm" #include "code\modules\language\voltaic.dm" #include "code\modules\language\xenocommon.dm" +#include "code\modules\library\admin_only.dm" #include "code\modules\library\barcode_scanner.dm" #include "code\modules\library\bibles.dm" #include "code\modules\library\book.dm" diff --git a/tgui/packages/tgui/interfaces/AdminBookViewer.tsx b/tgui/packages/tgui/interfaces/AdminBookViewer.tsx new file mode 100644 index 00000000000..5996b3937d7 --- /dev/null +++ b/tgui/packages/tgui/interfaces/AdminBookViewer.tsx @@ -0,0 +1,25 @@ +import { useBackend } from '../backend'; +import { Window } from '../layouts'; +import { MarkdownRenderer } from './MarkdownViewer'; + +type ViewerData = { + title: string; + content: string; + author: string; + view_raw: boolean; +}; + +export const AdminBookViewer = (_: any, context: any) => { + const { data } = useBackend(context); + return ( + + + {data.view_raw ? ( + data.content + ) : ( + + )} + + + ); +}; diff --git a/tgui/packages/tgui/interfaces/LibraryAdmin.tsx b/tgui/packages/tgui/interfaces/LibraryAdmin.tsx new file mode 100644 index 00000000000..d7720db19db --- /dev/null +++ b/tgui/packages/tgui/interfaces/LibraryAdmin.tsx @@ -0,0 +1,487 @@ +import { map, sortBy } from 'common/collections'; +import { flow } from 'common/fp'; +import { capitalize } from 'common/string'; +import { useBackend, useLocalState } from '../backend'; +import { Box, Button, Dropdown, Input, NoticeBox, Section, Stack, Table, TextArea } from '../components'; +import { Window } from '../layouts'; +import { PageSelect } from './LibraryConsole'; + +export const LibraryAdmin = (props, context) => { + const [modifyMethod, setModifyMethod] = useLocalState( + context, + 'ModifyMethod', + null + ); + return ( + + {modifyMethod ? : } + + ); +}; + +type ListingData = { + can_connect: boolean; + can_db_request: boolean; + our_page: number; + page_count: number; +}; + +const BookListing = (props, context) => { + const { act, data } = useBackend(context); + const { can_connect, can_db_request, our_page, page_count } = data; + if (!can_connect) { + return ( + + Unable to retrieve book listings. Please contact your system + administrator for assistance. + + ); + } + return ( + + + + + + + + + + + act('switch_page', { + page: value, + }) + } + /> + + + ); +}; + +type Book = { + author: string; + category: string; + title: string; + id: number; + deleted: boolean; +}; + +type DisplayBook = Book & { + key: number; +}; + +type DisplayData = { + can_db_request: boolean; + search_categories: string[]; + book_id: number; + title: string; + category: string; + author: string; + author_ckey: string; + params_changed: boolean; + view_raw: boolean; + show_deleted: boolean; + history: HistoryArray; + pages: Book[]; +}; + +const SearchAndDisplay = (props, context) => { + const { act, data } = useBackend(context); + const [modifyMethod, setModifyMethod] = useLocalState( + context, + 'ModifyMethod', + '' + ); + const [modifyTarget, setModifyTarget] = useLocalState( + context, + 'ModifyTarget', + 0 + ); + const { + can_db_request, + search_categories = [], + book_id, + title, + category, + author, + author_ckey, + pages, + params_changed, + view_raw, + show_deleted, + } = data; + const books = flow([ + map((book, i) => ({ + ...book, + // Generate a unique id + key: i, + })), + sortBy((book) => book.key), + ])(pages); + return ( +
+ + + + + + act('set_search_id', { + id: value, + }) + } + /> + + + + act('set_search_category', { + category: value, + }) + } + /> + + + + act('set_search_title', { + title: value, + }) + } + /> + + + + act('set_search_author', { + author: value, + }) + } + /> + + + + act('set_search_ckey', { + ckey: value, + }) + } + /> + + + + + + + + + + +
+ ); +}; + +const ModifyTypes = { + Delete: 'delete', + Restore: 'restore', +}; + +type HistoryEntry = { + // The id of this logged action + id: number; + // The book id this log applies to + book: number; + // The reason this action was enacted + reason: string; + // The admin who performed the action + ckey: string; + // The time of the action being performed + datetime: string; + // The action that ocurred + action: string; + // The ip address of the admin who performed the action + ip_addr: string; +}; + +type HistoryArray = { + [key: string]: HistoryEntry[]; +}; + +type ModalData = { + can_db_request: boolean; + view_raw: boolean; + history: HistoryArray; +}; + +const ModifyPage = (props, context) => { + const { act, data } = useBackend(context); + + const { can_db_request, view_raw, history } = data; + const [modifyMethod, setModifyMethod] = useLocalState( + context, + 'ModifyMethod', + '' + ); + const [modifyTarget, setModifyTarget] = useLocalState( + context, + 'ModifyTarget', + 0 + ); + const [reason, setReason] = useLocalState(context, 'Reason', 'null'); + + const entries = history[modifyTarget.toString()] + ? history[modifyTarget.toString()].sort((a, b) => b.id - a.id) + : []; + + return ( + + + Heads Up! We do not allow you to fully delete books in game +
+ What you're doing here is a "don't show this to + anyone" button +
+ If you for whatever reason need to fully wipe a book, please speak to + your database administrator +
+ + + Why do you want to {modifyMethod} this book? + + + + + +