From 772a95c68eaf843ad98785cd4bd976d3dac9987f Mon Sep 17 00:00:00 2001 From: Atermonera Date: Mon, 24 Feb 2020 13:34:27 -0800 Subject: [PATCH] Adds an In-game Feedback System --- code/__defines/_protect.dm | 11 ++ code/__defines/misc.dm | 1 + code/__defines/sqlite_defines.dm | 7 + code/__defines/subsystems.dm | 1 + code/_helpers/unsorted.dm | 8 + code/controllers/configuration.dm | 28 +++ code/controllers/subsystems/sqlite.dm | 187 ++++++++++++++++++ .../managed_browsers/_managed_browser.dm | 52 +++++ code/datums/managed_browsers/feedback_form.dm | 147 ++++++++++++++ .../managed_browsers/feedback_viewer.dm | 162 +++++++++++++++ code/modules/admin/admin_verbs.dm | 3 +- code/modules/mob/new_player/new_player.dm | 17 +- code/unit_tests/sqlite_tests.dm | 69 +++++++ config/example/config.txt | 39 +++- config/example/sqlite_feedback_pepper.txt | 11 ++ html/changelogs/neerti-feedback_system.yml | 36 ++++ vorestation.dme | 13 ++ 17 files changed, 789 insertions(+), 3 deletions(-) create mode 100644 code/__defines/_protect.dm create mode 100644 code/__defines/sqlite_defines.dm create mode 100644 code/controllers/subsystems/sqlite.dm create mode 100644 code/datums/managed_browsers/_managed_browser.dm create mode 100644 code/datums/managed_browsers/feedback_form.dm create mode 100644 code/datums/managed_browsers/feedback_viewer.dm create mode 100644 code/unit_tests/sqlite_tests.dm create mode 100644 config/example/sqlite_feedback_pepper.txt create mode 100644 html/changelogs/neerti-feedback_system.yml diff --git a/code/__defines/_protect.dm b/code/__defines/_protect.dm new file mode 100644 index 0000000000..b10a6264bd --- /dev/null +++ b/code/__defines/_protect.dm @@ -0,0 +1,11 @@ +///Protects a datum from being VV'd +#define GENERAL_PROTECT_DATUM(Path)\ +##Path/can_vv_get(var_name){\ + return FALSE;\ +}\ +##Path/vv_edit_var(var_name, var_value){\ + return FALSE;\ +}\ +##Path/CanProcCall(procname){\ + return FALSE;\ +} \ No newline at end of file diff --git a/code/__defines/misc.dm b/code/__defines/misc.dm index e8242af113..8fcabca714 100644 --- a/code/__defines/misc.dm +++ b/code/__defines/misc.dm @@ -113,6 +113,7 @@ #define MAX_RECORD_LENGTH 24576 #define MAX_LNAME_LEN 64 #define MAX_NAME_LEN 52 +#define MAX_FEEDBACK_LENGTH 4096 #define MAX_TEXTFILE_LENGTH 128000 // 512GQ file // Event defines. diff --git a/code/__defines/sqlite_defines.dm b/code/__defines/sqlite_defines.dm new file mode 100644 index 0000000000..8cda3b62a0 --- /dev/null +++ b/code/__defines/sqlite_defines.dm @@ -0,0 +1,7 @@ +#define SQLITE_TABLE_FEEDBACK "feedback" + +#define SQLITE_FEEDBACK_COLUMN_ID "id" +#define SQLITE_FEEDBACK_COLUMN_AUTHOR "author" +#define SQLITE_FEEDBACK_COLUMN_TOPIC "topic" +#define SQLITE_FEEDBACK_COLUMN_CONTENT "content" +#define SQLITE_FEEDBACK_COLUMN_DATETIME "datetime" \ No newline at end of file diff --git a/code/__defines/subsystems.dm b/code/__defines/subsystems.dm index fe01b602fc..58552c6905 100644 --- a/code/__defines/subsystems.dm +++ b/code/__defines/subsystems.dm @@ -52,6 +52,7 @@ var/global/list/runlevel_flags = list(RUNLEVEL_LOBBY, RUNLEVEL_SETUP, RUNLEVEL_G // Subsystem init_order, from highest priority to lowest priority // Subsystems shutdown in the reverse of the order they initialize in // The numbers just define the ordering, they are meaningless otherwise. +#define INIT_ORDER_SQLITE 19 #define INIT_ORDER_CHEMISTRY 18 #define INIT_ORDER_MAPPING 17 #define INIT_ORDER_DECALS 16 diff --git a/code/_helpers/unsorted.dm b/code/_helpers/unsorted.dm index a716f3c7b4..607c52e166 100644 --- a/code/_helpers/unsorted.dm +++ b/code/_helpers/unsorted.dm @@ -1577,3 +1577,11 @@ var/mob/dview/dview_mob = new else return "\[[url_encode(thing.tag)]\]" return "\ref[input]" + +// Painlessly creates an element. +// First argument is where to send the Topic call to when clicked. Should be a reference to an object. This is generally src, but not always. +// Second one is for all the params that will be sent. Uses an assoc list (e.g. "value" = "5"). +// Note that object refs will be converted to text, as if \ref[thing] was done. To get the ref back on Topic() side, you will need to use locate(). +// Third one is the text that will be clickable. +/proc/href(href_src, list/href_params, href_text) + return "[href_text]" \ No newline at end of file diff --git a/code/controllers/configuration.dm b/code/controllers/configuration.dm index 36b676b3a0..2af8467b98 100644 --- a/code/controllers/configuration.dm +++ b/code/controllers/configuration.dm @@ -252,6 +252,17 @@ var/list/gamemode_cache = list() var/random_submap_orientation = FALSE // If true, submaps loaded automatically can be rotated. var/autostart_solars = FALSE // If true, specifically mapped in solar control computers will set themselves up when the round starts. + // New shiny SQLite stuff. + // The basics. + var/sqlite_enabled = FALSE // If it should even be active. SQLite can be ran alongside other databases but you should not have them do the same functions. + + // In-Game Feedback. + var/sqlite_feedback = FALSE // Feedback cannot be submitted if this is false. + var/list/sqlite_feedback_topics = list("General") // A list of 'topics' that feedback can be catagorized under by the submitter. + var/sqlite_feedback_privacy = FALSE // If true, feedback submitted can have its author name be obfuscated. This is not 100% foolproof (it's md5 ffs) but can stop casual snooping. + var/sqlite_feedback_cooldown = 0 // How long one must wait, in days, to submit another feedback form. Used to help prevent spam, especially with privacy active. 0 = No limit. + var/sqlite_feedback_min_age = 0 // Used to block new people from giving feedback. This metric is very bad but it can help slow down spammers. + /datum/configuration/New() var/list/L = typesof(/datum/game_mode) - /datum/game_mode for (var/T in L) @@ -841,6 +852,23 @@ var/list/gamemode_cache = list() if("autostart_solars") config.autostart_solars = TRUE + if("sqlite_enabled") + config.sqlite_enabled = TRUE + + if("sqlite_feedback") + config.sqlite_feedback = TRUE + + if("sqlite_feedback_topics") + config.sqlite_feedback_topics = splittext(value, ";") + if(!config.sqlite_feedback_topics.len) + config.sqlite_feedback_topics += "General" + + if("sqlite_feedback_privacy") + config.sqlite_feedback_privacy = TRUE + + if("sqlite_feedback_cooldown") + config.sqlite_feedback_cooldown = text2num(value) + else diff --git a/code/controllers/subsystems/sqlite.dm b/code/controllers/subsystems/sqlite.dm new file mode 100644 index 0000000000..3d18ec4a7a --- /dev/null +++ b/code/controllers/subsystems/sqlite.dm @@ -0,0 +1,187 @@ +// This holds all the code needed to manage and use a SQLite database. +// It is merely a file sitting inside the data directory, as opposed to a full fledged DB service, +// however this makes it a lot easier to test, and it is natively supported by BYOND. +SUBSYSTEM_DEF(sqlite) + name = "SQLite" + init_order = INIT_ORDER_SQLITE + flags = SS_NO_FIRE + var/database/sqlite_db = null + +/datum/controller/subsystem/sqlite/Initialize(timeofday) + connect() + if(sqlite_db) + init_schema(sqlite_db) + return ..() + +/datum/controller/subsystem/sqlite/proc/connect() + if(!config.sqlite_enabled) + return + + if(!sqlite_db) + sqlite_db = new("data/sqlite/sqlite.db") // The path has to be hardcoded or BYOND silently fails. + + + if(!sqlite_db) + to_world_log("Failed to load or create a SQLite database.") + log_debug("ERROR: SQLite database is active in config but failed to load.") + else + to_world_log("Sqlite database connected.") + +// Makes the tables, if they do not already exist in the sqlite file. +/datum/controller/subsystem/sqlite/proc/init_schema(database/sqlite_object) + // Feedback table. + // Note that this is for direct feedback from players using the in-game feedback system and NOT for stat tracking. + // Player ckeys are not stored in this table as a unique key due to a config option to hash the keys to encourage more honest feedback. + /* + * id - Primary unique key to ID a specific piece of feedback. + NOT used to id people submitting feedback. + * author - The person who submitted it. Will be the ckey, or a hash of the ckey, + if both the config supports it, and the user wants it. + * topic - A specific category to organize feedback under. Options are defined in the config file. + * content - What the author decided to write to the staff. Limited to MAX_FEEDBACK_LENGTH. + * datetime - When the author submitted their feedback, acts as a timestamp. + */ + var/database/query/init_schema = new( + {" + CREATE TABLE IF NOT EXISTS [SQLITE_TABLE_FEEDBACK] + ( + `[SQLITE_FEEDBACK_COLUMN_ID]` INTEGER NOT NULL UNIQUE, + `[SQLITE_FEEDBACK_COLUMN_AUTHOR]` TEXT NOT NULL, + `[SQLITE_FEEDBACK_COLUMN_TOPIC]` TEXT NOT NULL, + `[SQLITE_FEEDBACK_COLUMN_CONTENT]` TEXT NOT NULL, + `[SQLITE_FEEDBACK_COLUMN_DATETIME]` TEXT NOT NULL, + PRIMARY KEY(`[SQLITE_FEEDBACK_COLUMN_ID]`) + ); + "} + ) + init_schema.Execute(sqlite_object) + sqlite_check_for_errors(init_schema, "Feedback table creation") + + // Add more schemas below this if the SQLite DB gets expanded for things like persistant news, polls, bans, deaths, etc. + +// General error checking for SQLite. +// Returns true if something went wrong. Also writes a log. +// The desc parameter should be unique for each call, to make it easier to track down where the error occured. +/datum/controller/subsystem/sqlite/proc/sqlite_check_for_errors(var/database/query/query_used, var/desc) + if(query_used && query_used.ErrorMsg()) + log_debug("SQLite Error: [desc] : [query_used.ErrorMsg()]") + return TRUE + return FALSE + + +/************ + * Feedback * + ************/ + +// Inserts data into the feedback table in a painless manner. +// Returns TRUE if no issues happened, FALSE otherwise. +/datum/controller/subsystem/sqlite/proc/insert_feedback(author, topic, content, database/sqlite_object) + if(!author || !topic || !content) + CRASH("One or more parameters was invalid.") + + // Sanitize everything to avoid sneaky stuff. + var/sqlite_author = sql_sanitize_text(ckey(lowertext(author))) + var/sqlite_content = sql_sanitize_text(content) + var/sqlite_topic = sql_sanitize_text(topic) + + var/database/query/query = new( + "INSERT INTO [SQLITE_TABLE_FEEDBACK] (\ + [SQLITE_FEEDBACK_COLUMN_AUTHOR], \ + [SQLITE_FEEDBACK_COLUMN_TOPIC], \ + [SQLITE_FEEDBACK_COLUMN_CONTENT], \ + [SQLITE_FEEDBACK_COLUMN_DATETIME]) \ + \ + VALUES (\ + ?,\ + ?,\ + ?,\ + datetime('now'))", + sqlite_author, + sqlite_topic, + sqlite_content + ) + query.Execute(sqlite_object) + return !sqlite_check_for_errors(query, "Insert Feedback") + +/datum/controller/subsystem/sqlite/proc/can_submit_feedback(client/C) + if(!config.sqlite_enabled) + return FALSE + if(config.sqlite_feedback_min_age && !is_old_enough(C)) + return FALSE + if(config.sqlite_feedback_cooldown > 0 && get_feedback_cooldown(C.key, config.sqlite_feedback_cooldown, sqlite_db) > 0) + return FALSE + return TRUE + +// Returns TRUE if the player is 'old' enough, according to the config. +/datum/controller/subsystem/sqlite/proc/is_old_enough(client/C) + if(get_player_age(C.key) < config.sqlite_feedback_min_age) + return FALSE + return TRUE + + +// Returns how many days someone has to wait, to submit more feedback, or 0 if they can do so right now. +/datum/controller/subsystem/sqlite/proc/get_feedback_cooldown(player_ckey, cooldown, database/sqlite_object) + player_ckey = sql_sanitize_text(ckey(lowertext(player_ckey))) + var/potential_hashed_ckey = sql_sanitize_text(md5(player_ckey + SSsqlite.get_feedback_pepper())) + + // First query is to get the most recent time the player has submitted feedback. + var/database/query/query = new({" + SELECT [SQLITE_FEEDBACK_COLUMN_DATETIME] + FROM [SQLITE_TABLE_FEEDBACK] + WHERE [SQLITE_FEEDBACK_COLUMN_AUTHOR] == ? OR [SQLITE_FEEDBACK_COLUMN_AUTHOR] == ? + ORDER BY [SQLITE_FEEDBACK_COLUMN_DATETIME] + DESC LIMIT 1; + "}, + player_ckey, + potential_hashed_ckey + ) + query.Execute(sqlite_object) + sqlite_check_for_errors(query, "Rate Limited Check 1") + + // It is possible this is their first time, so there won't be a next row. + if(query.NextRow()) // If this is true, the user has submitted feedback at least once. + var/list/row_data = query.GetRowData() + var/last_submission_datetime = row_data[SQLITE_FEEDBACK_COLUMN_DATETIME] + + // Now we have the datetime, we need to do something to compare it. + // Second query is to calculate the difference between two datetimes. + // This is done on the SQLite side because parsing datetimes with BYOND is probably a bad idea. + query = new( + "SELECT julianday('now') - julianday(?) \ + AS 'datediff';", + last_submission_datetime + ) + query.Execute(sqlite_object) + sqlite_check_for_errors(query, "Rate Limited Check 2") + + query.NextRow() + row_data = query.GetRowData() + var/date_diff = row_data["datediff"] + + // Now check if it's too soon to give more feedback. + if(text2num(date_diff) < cooldown) // Too soon. + return round(cooldown - date_diff, 0.1) + return 0.0 + + +// A Pepper is like a Salt but only one exists and is supposed to be outside of a database. +// If the file is properly protected, it can only be viewed/copied by sys-admins generating a log, which is much more conspicious than accessing/copying a DB. +// This stops mods/admins/etc from guessing the author by shoving names in an MD5 hasher until they pick the right one. +// Don't use this for things needing actual security. +/datum/controller/subsystem/sqlite/proc/get_feedback_pepper() + var/pepper_file = file2list("config/sqlite_feedback_pepper.txt") + var/pepper = null + for(var/line in pepper_file) + if(!line) + continue + if(length(line) == 0) + continue + else if(copytext(line, 1, 2) == "#") + continue + else + pepper = line + break + return pepper + +/datum/controller/subsystem/sqlite/CanProcCall(procname) + return procname != "get_feedback_pepper" diff --git a/code/datums/managed_browsers/_managed_browser.dm b/code/datums/managed_browsers/_managed_browser.dm new file mode 100644 index 0000000000..c915c7e86a --- /dev/null +++ b/code/datums/managed_browsers/_managed_browser.dm @@ -0,0 +1,52 @@ +GLOBAL_VAR(managed_browser_id_ticker) + +// This holds information on managing a /datum/browser object. +// Managing can include things like persisting the state of specific information inside of this object, receiving Topic() calls, or deleting itself when the window is closed. +// This is useful for browser windows to be able to stand 'on their own' instead of being tied to something in the game world, like an object or mob. +/datum/managed_browser + var/client/my_client = null + var/browser_id = null + var/base_browser_id = null + + var/title = null + var/size_x = 200 + var/size_y = 400 + + var/display_when_created = TRUE + +/datum/managed_browser/New(client/new_client) + if(!new_client) + crash_with("Managed browser object was not given a client.") + return + if(!base_browser_id) + crash_with("Managed browser object does not have a base browser id defined in its type.") + return + + my_client = new_client + browser_id = "[base_browser_id]-[GLOB.managed_browser_id_ticker++]" + + if(display_when_created) + display() + +/datum/managed_browser/Destroy() + my_client = null + return ..() + +// Override if you want to have the browser title change conditionally. +// Otherwise it's easier to just change the title variable directly. +/datum/managed_browser/proc/get_title() + return title + +// Override to display the html information. +// It is suggested to build it with a list, and use list.Join() at the end. +// This helps prevent excessive concatination, which helps preserves BYOND's string tree from becoming a laggy mess. +/datum/managed_browser/proc/get_html() + return + +/datum/managed_browser/proc/display() + interact(get_html(), get_title(), my_client) + +/datum/managed_browser/proc/interact(html, title, client/C) + var/datum/browser/popup = new(C.mob, browser_id, title, size_x, size_y, src) + popup.set_content(html) + popup.open() \ No newline at end of file diff --git a/code/datums/managed_browsers/feedback_form.dm b/code/datums/managed_browsers/feedback_form.dm new file mode 100644 index 0000000000..bc5c456aec --- /dev/null +++ b/code/datums/managed_browsers/feedback_form.dm @@ -0,0 +1,147 @@ +/client + var/datum/managed_browser/feedback_form/feedback_form = null + +/client/can_vv_get(var_name) + return var_name != NAMEOF(src, feedback_form) // No snooping. + +GENERAL_PROTECT_DATUM(datum/managed_browser/feedback_form) + +// A fairly simple object to hold information about a player's feedback as it's being written. +// Having this be it's own object instead of being baked into /mob/new_player allows for it to be used +// from other places than just the lobby, and makes it a lot harder for people with dev powers to be naughty with it using VV/proccall. +/datum/managed_browser/feedback_form + base_browser_id = "feedback_form" + title = "Server Feedback" + size_x = 480 + size_y = 520 + var/feedback_topic = null + var/feedback_body = null + var/feedback_hide_author = FALSE + +/datum/managed_browser/feedback_form/New(client/new_client) + feedback_topic = config.sqlite_feedback_topics[1] + ..(new_client) + +/datum/managed_browser/feedback_form/Destroy() + if(my_client) + my_client.feedback_form = null + return ..() + +// Privacy option is allowed if both the config allows it, and the pepper file exists and isn't blank. +/datum/managed_browser/feedback_form/proc/can_be_private() + return config.sqlite_feedback_privacy && SSsqlite.get_feedback_pepper() + +/datum/managed_browser/feedback_form/display() + if(!my_client) + return + if(!SSsqlite.can_submit_feedback(my_client)) + return + ..() + +// Builds the window for players to review their feedback. +/datum/managed_browser/feedback_form/get_html() + var/list/dat = list("") + dat += "
" + dat += "" + dat += "Here, you can write some feedback for the server.
" + dat += "Note that HTML is NOT supported!
" + dat += "Click the edit button to begin writing.
" + + dat += "Your feedback is currently [length(feedback_body)]/[MAX_FEEDBACK_LENGTH] letters long." + dat += "
" + dat += "
" + + dat += "

Preview

" + + dat += "Author: " + + if(can_be_private()) + if(!feedback_hide_author) + dat += "[my_client.ckey] " + dat += span("linkOn", "Visible") + dat += " | " + dat += href(src, list("feedback_hide_author" = 1), "Hashed") + else + dat += "[md5(ckey(lowertext(my_client.ckey + SSsqlite.get_feedback_pepper())))] " + dat += href(src, list("feedback_hide_author" = 0), "Visible") + dat += " | " + dat += span("linkOn", "Hashed") + else + dat += my_client.ckey + dat += "
" + + if(config.sqlite_feedback_topics.len > 1) + dat += "Topic: [href(src, list("feedback_choose_topic" = 1), feedback_topic)]
" + else + dat += "Topic: [config.sqlite_feedback_topics[1]]
" + + dat += "
" + if(feedback_body) + dat += replacetext(feedback_body, "\n", "
") // So newlines will look like they work in the preview. + else + dat += "\[Feedback goes here...\]" + dat += "
" + dat += href(src, list("feedback_edit_body" = 1), "Edit") + dat += "
" + + if(config.sqlite_feedback_cooldown) + dat += "Please note that you will have to wait [config.sqlite_feedback_cooldown] day\s before \ + being able to write more feedback after submitting.
" + + dat += href(src, list("feedback_submit" = 1), "Submit") + dat += "" + return dat.Join() + +/datum/managed_browser/feedback_form/Topic(href, href_list[]) + if(!my_client) + return FALSE + + if(href_list["feedback_edit_body"]) + // This is deliberately not sanitized here, and is instead checked when hitting the submission button, + // as we want to give the user a chance to fix it without needing to rewrite the whole thing. + feedback_body = input(my_client, "Please write your feedback here.", "Feedback Body", feedback_body) as null|message + display() // Refresh the window with new information. + return + + if(href_list["feedback_hide_author"]) + if(!can_be_private()) + feedback_hide_author = FALSE + else + feedback_hide_author = text2num(href_list["feedback_hide_author"]) + display() + return + + if(href_list["feedback_choose_topic"]) + feedback_topic = input(my_client, "Choose the topic you want to submit your feedback under.", "Feedback Topic", feedback_topic) in config.sqlite_feedback_topics + display() + return + + if(href_list["feedback_submit"]) + // Do some last minute validation, and tell the user if something goes wrong, + // so we don't wipe out their ten thousand page essay due to having a few too many characters. + if(length(feedback_body) > MAX_FEEDBACK_LENGTH) + to_chat(my_client, span("warning", "Your feedback is too long, at [length(feedback_body)] characters, where as the \ + limit is [MAX_FEEDBACK_LENGTH]. Please shorten it and try again.")) + return + + var/text = sanitize(feedback_body, max_length = 0, encode = TRUE, trim = FALSE, extra = FALSE) + if(!text) // No text, or it was super invalid. + to_chat(my_client, span("warning", "It appears you didn't write anything, or it was invalid.")) + return + + if(alert(my_client, "Are you sure you want to submit your feedback?", "Confirm Submission", "No", "Yes") == "Yes") + var/author_text = my_client.ckey + if(can_be_private() && feedback_hide_author) + author_text = md5(my_client.ckey + SSsqlite.get_feedback_pepper()) + + var/success = SSsqlite.insert_feedback(author = author_text, topic = feedback_topic, content = feedback_body, sqlite_object = SSsqlite.sqlite_db) + if(!success) + to_chat(my_client, span("warning", "Something went wrong while inserting your feedback into the database. Please try again. \ + If this happens again, you should contact a developer.")) + return + + my_client.mob << browse(null, "window=[browser_id]") // Closes the window. + if(istype(my_client.mob, /mob/new_player)) + var/mob/new_player/NP = my_client.mob + NP.new_player_panel_proc() // So the feedback button goes away, if the user gets put on cooldown. + qdel(src) \ No newline at end of file diff --git a/code/datums/managed_browsers/feedback_viewer.dm b/code/datums/managed_browsers/feedback_viewer.dm new file mode 100644 index 0000000000..9128efb90e --- /dev/null +++ b/code/datums/managed_browsers/feedback_viewer.dm @@ -0,0 +1,162 @@ +/client + var/datum/managed_browser/feedback_viewer/feedback_viewer = null + +/datum/admins/proc/view_feedback() + set category = "Admin" + set name = "View Feedback" + set desc = "Open the Feedback Viewer" + + if(!check_rights(R_ADMIN)) + return + + if(usr.client.feedback_viewer) + usr.client.feedback_viewer.display() + else + usr.client.feedback_viewer = new(usr.client) + +// This object holds the code to run the admin feedback viewer. +/datum/managed_browser/feedback_viewer + base_browser_id = "feedback_viewer" + title = "Submitted Feedback" + size_x = 900 + size_y = 500 + var/database/query/last_query = null + +/datum/managed_browser/feedback_viewer/New(client/new_client) + if(!check_rights(R_ADMIN, new_client)) // Just in case someone figures out a way to spawn this as non-staff. + message_admins("[new_client] tried to view feedback with insufficent permissions.") + qdel(src) + + ..() + +/datum/managed_browser/feedback_viewer/Destroy() + if(my_client) + my_client.feedback_viewer = null + return ..() + +/datum/managed_browser/feedback_viewer/proc/feedback_filter(row_name, thing_to_find, exact = FALSE) + var/database/query/query = null + if(exact) // Useful for ID searches, so searching for 'id 10' doesn't also get 'id 101'. + query = new({" + SELECT * + FROM [SQLITE_TABLE_FEEDBACK] + WHERE [row_name] == ? + ORDER BY [SQLITE_FEEDBACK_COLUMN_ID] + DESC LIMIT 50; + "}, + thing_to_find + ) + + else + // Wrap the thing in %s so LIKE will work. + thing_to_find = "%[thing_to_find]%" + query = new({" + SELECT * + FROM [SQLITE_TABLE_FEEDBACK] + WHERE [row_name] LIKE ? + ORDER BY [SQLITE_FEEDBACK_COLUMN_ID] + DESC LIMIT 50; + "}, + thing_to_find + ) + query.Execute(SSsqlite.sqlite_db) + SSsqlite.sqlite_check_for_errors(query, "Admin Feedback Viewer - Filter by [row_name] to find [thing_to_find]") + return query + +// Builds the window for players to review their feedback. +/datum/managed_browser/feedback_viewer/get_html() + var/list/dat = list("") + if(!last_query) // If no query was done before, just show the most recent feedbacks. + var/database/query/query = new({" + SELECT * + FROM [SQLITE_TABLE_FEEDBACK] + ORDER BY [SQLITE_FEEDBACK_COLUMN_ID] + DESC LIMIT 50; + "} + ) + query.Execute(SSsqlite.sqlite_db) + SSsqlite.sqlite_check_for_errors(query, "Admin Feedback Viewer") + last_query = query + + dat += "" + dat += "" + dat += "" + dat += "" + dat += "" + dat += "" + dat += "" + dat += "" + + while(last_query.NextRow()) + var/list/row_data = last_query.GetRowData() + dat += "" + dat += "" + dat += "" + dat += "" // TODO: Color this to make hashed keys more distinguishable. + var/text = row_data[SQLITE_FEEDBACK_COLUMN_CONTENT] + if(length(text) > 512) + text = href(src, list( + "show_full_feedback" = 1, + "feedback_author" = row_data[SQLITE_FEEDBACK_COLUMN_AUTHOR], + "feedback_content" = row_data[SQLITE_FEEDBACK_COLUMN_CONTENT] + ), "[copytext(text, 1, 64)]... ([length(text)])") + else + text = replacetext(text, "\n", "
") + dat += "" + dat += "" + dat += "" + dat += "
[href(src, list("filter_id" = 1), "ID")][href(src, list("filter_topic" = 1), "Topic")][href(src, list("filter_author" = 1), "Author")][href(src, list("filter_content" = 1), "Content")][href(src, list("filter_datetime" = 1), "Datetime")]
[row_data[SQLITE_FEEDBACK_COLUMN_ID]][row_data[SQLITE_FEEDBACK_COLUMN_TOPIC]][row_data[SQLITE_FEEDBACK_COLUMN_AUTHOR]][text][row_data[SQLITE_FEEDBACK_COLUMN_DATETIME]]
" + + dat += "" + return dat.Join() + +// Used to show the full version of feedback in a seperate window. +/datum/managed_browser/feedback_viewer/proc/display_big_feedback(author, text) + var/list/dat = list("") + dat += replacetext(text, "\n", "
") + + var/datum/browser/popup = new(my_client.mob, "feedback_big", "[author]'s Feedback", 480, 520, src) + popup.set_content(dat.Join()) + popup.open() + + +/datum/managed_browser/feedback_viewer/Topic(href, href_list[]) + if(!my_client) + return FALSE + + if(href_list["close"]) // To avoid refreshing. + return + + if(href_list["show_full_feedback"]) + display_big_feedback(href_list["feedback_author"], href_list["feedback_content"]) + return + + if(href_list["filter_id"]) + var/id_to_search = input(my_client, "Write feedback ID here.", "Filter by ID", null) as null|num + if(id_to_search) + last_query = feedback_filter(SQLITE_FEEDBACK_COLUMN_ID, id_to_search, TRUE) + + if(href_list["filter_author"]) + var/author_to_search = input(my_client, "Write desired key or hash here. Partial keys/hashes are allowed.", "Filter by Author", null) as null|text + if(author_to_search) + last_query = feedback_filter(SQLITE_FEEDBACK_COLUMN_AUTHOR, author_to_search) + + if(href_list["filter_topic"]) + var/topic_to_search = input(my_client, "Write desired topic here. Partial topics are allowed. \ + \nThe current topics in the config are [english_list(config.sqlite_feedback_topics)].", "Filter by Topic", null) as null|text + if(topic_to_search) + last_query = feedback_filter(SQLITE_FEEDBACK_COLUMN_TOPIC, topic_to_search) + + if(href_list["filter_content"]) + var/content_to_search = input(my_client, "Write desired content to find here. Partial matches are allowed.", "Filter by Content", null) as null|message + if(content_to_search) + last_query = feedback_filter(SQLITE_FEEDBACK_COLUMN_CONTENT, content_to_search) + + if(href_list["filter_datetime"]) + var/datetime_to_search = input(my_client, "Write desired datetime. Partial matches are allowed.\n\ + Format is 'YYYY-MM-DD HH:MM:SS'.", "Filter by Datetime", null) as null|text + if(datetime_to_search) + last_query = feedback_filter(SQLITE_FEEDBACK_COLUMN_DATETIME, datetime_to_search) + + // Refresh. + display() \ No newline at end of file diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index be492854fd..017ce7923a 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -107,7 +107,8 @@ var/list/admin_verbs_admin = list( /client/proc/fixatmos, /datum/admins/proc/quick_nif, //VOREStation Add, /datum/admins/proc/sendFax, - /client/proc/despawn_player + /client/proc/despawn_player, + /datum/admins/proc/view_feedback ) var/list/admin_verbs_ban = list( diff --git a/code/modules/mob/new_player/new_player.dm b/code/modules/mob/new_player/new_player.dm index f3c5f88433..a019109e44 100644 --- a/code/modules/mob/new_player/new_player.dm +++ b/code/modules/mob/new_player/new_player.dm @@ -66,9 +66,11 @@ else output += "

Show News

" + if(SSsqlite.can_submit_feedback(client)) + output += "

[href(src, list("give_feedback" = 1), "Give Feedback")]

" output += "" - panel = new(src, "Welcome","Welcome", 210, 280, src) + panel = new(src, "Welcome","Welcome", 210, 300, src) panel.set_window_options("can_close=0") panel.set_content(output) panel.open() @@ -170,7 +172,10 @@ if(href_list["SelectedJob"]) +<<<<<<< HEAD /* Vorestation Removal Start +======= +>>>>>>> 97f8c1d... Merge pull request #6719 from Neerti/feedback_system //Prevents people rejoining as same character. for (var/mob/living/carbon/human/C in mob_list) var/char_name = client.prefs.real_name @@ -306,6 +311,15 @@ show_hidden_jobs = !show_hidden_jobs LateChoices() + if(href_list["give_feedback"]) + if(!SSsqlite.can_submit_feedback(my_client)) + return + + if(client.feedback_form) + client.feedback_form.display() // In case they closed the form early. + else + client.feedback_form = new(client) + /mob/new_player/proc/handle_server_news() if(!client) return @@ -325,6 +339,7 @@ popup.set_content(dat) popup.open() + /mob/new_player/proc/IsJobAvailable(rank) var/datum/job/job = job_master.GetJob(rank) if(!job) return 0 diff --git a/code/unit_tests/sqlite_tests.dm b/code/unit_tests/sqlite_tests.dm new file mode 100644 index 0000000000..8263f58064 --- /dev/null +++ b/code/unit_tests/sqlite_tests.dm @@ -0,0 +1,69 @@ +/datum/unit_test/sqlite + name = "SQLite template" // Template has to be in the name or this test will be ran, and fail. + var/database/stub_sqlite_db = null + +/datum/unit_test/sqlite/proc/setup_stub_db() + fdel("data/sqlite/testing_[name].db") // In case any remain from a previous local test, so we can have a clean new database. + stub_sqlite_db = new("data/sqlite/testing_[name].db") // Unfortunately, byond doesn't like having working sqlite stuff w/o a file existing. + SSsqlite.init_schema(stub_sqlite_db) + +// Feedback table tests. +/datum/unit_test/sqlite/feedback/insert + name = "SQLITE FEEDBACK: Insert and Retrieve Data" + +/datum/unit_test/sqlite/feedback/insert/start_test() + // Arrange. + setup_stub_db() + var/test_author = "alice" + var/test_topic = "Test" + var/test_content = "Bob is lame." + + // Act. + SSsqlite.insert_feedback(author = test_author, topic = test_topic, content = test_content, sqlite_object = stub_sqlite_db) + var/database/query/Q = new("SELECT * FROM [SQLITE_TABLE_FEEDBACK]") + Q.Execute(stub_sqlite_db) + SSsqlite.sqlite_check_for_errors(Q, "Sqlite Insert Unit Test") + Q.NextRow() + + // Assert. + var/list/row_data = Q.GetRowData() + if(row_data[SQLITE_FEEDBACK_COLUMN_AUTHOR] == test_author && row_data[SQLITE_FEEDBACK_COLUMN_TOPIC] == test_topic && row_data[SQLITE_FEEDBACK_COLUMN_CONTENT] == test_content) + pass("No issues found.") + else + fail("Data insert and loading failed to have matching information.") + return TRUE + + +/datum/unit_test/sqlite/feedback/cooldown + name = "SQLITE FEEDBACK: Cooldown" + +/datum/unit_test/sqlite/feedback/cooldown/start_test() + // Arrange. + setup_stub_db() + var/days_to_wait = 1 + var/issues = 0 + + // Act. + SSsqlite.insert_feedback(author = "Alice", topic = "Testing", content = "This is a test.", sqlite_object = stub_sqlite_db) + + var/alice_cooldown_block = SSsqlite.get_feedback_cooldown("Alice", days_to_wait, stub_sqlite_db) + var/bob_cooldown = SSsqlite.get_feedback_cooldown("Bob", days_to_wait, stub_sqlite_db) + days_to_wait = 0 + var/alice_cooldown_allow = SSsqlite.get_feedback_cooldown("Alice", days_to_wait, stub_sqlite_db) + + // Assert. + if(alice_cooldown_block <= 0) + issues++ + log_unit_test("User 'Alice' did not receive a cooldown, when they were supposed to.") + if(bob_cooldown > 0) + issues++ + log_unit_test("User 'Bob' did receive a cooldown, when they did not do anything.") + if(alice_cooldown_allow > 0) + issues++ + log_unit_test("User 'Alice' did receive a cooldown, when no cooldown is supposed to be enforced.") + + if(issues) + fail("[issues] issues were found.") + else + pass("No issues found.") + return TRUE diff --git a/config/example/config.txt b/config/example/config.txt index 8853f337cc..f9be5fc872 100644 --- a/config/example/config.txt +++ b/config/example/config.txt @@ -485,4 +485,41 @@ IPR_BLOCK_BAD_IPS IPR_ALLOW_EXISTING # And what that age is (number) -IPR_MINIMUM_AGE 5 \ No newline at end of file +IPR_MINIMUM_AGE 5 + + +## -SQLite Options- +## Uncomment to enable the use of SQLite. This does nothing by itself but other features that require SQLite will need this to be on. +## This can safely run alongside a MySQL/MariaDB database if they are powering seperate features. +# SQLITE_ENABLED + +## Uncomment to enable a SQLite-powered in-game feedback system. +## SQLite must be enabled for this to function. +## It offers a means for players to be able to give feedback about the server. +## The benefit of doing so in-game is that the quality of feedback received will likely be superior, as it self-selects for people who care enough to join the game. +# SQLITE_FEEDBACK + +## A list of 'topics' that can be used to categorize feedback submitted, chosen +## by the user. Have each topic seperated by a ';', as seen below. +## The first one in the list will be the default one used, if the user does not change it. +# SQLITE_FEEDBACK_TOPICS General; Suggestion; Complaint + +## Uncomment to add a layer of privacy to player feedback, by hashing their key, if the user wants to. +## This is intended to encourage more honest feedback, while still allowing the ability to determine +## if its just one person submitting everything. +## A 'pepper.txt' containing a secret string must exist in the /config folder. +## If this is turned off, users won't have the option to obfuscate their key. +## Note that changing this does not retroactively change past submissions. +# SQLITE_FEEDBACK_PRIVACY + +## Determines the 'cooldown' inbetween submissions, in days. +## This is recommended if privacy is active, to prevent spam floods. +## Less needed if feedback is not anonymous, since you can just ban spammers. +## Setting to zero means no rate limiting. +SQLITE_FEEDBACK_COOLDOWN 0 + +## Determines if feedback should be restricted based on how recently someone first joined. +## This is very unreliable due to how the age system works in general, but it might still be helpful. +## Set this to how many days you want someone to have to wait when they first join. +## Setting to zero will disable this restriction. +SQLITE_FEEDBACK_MIN_AGE 7 diff --git a/config/example/sqlite_feedback_pepper.txt b/config/example/sqlite_feedback_pepper.txt new file mode 100644 index 0000000000..8986d2714e --- /dev/null +++ b/config/example/sqlite_feedback_pepper.txt @@ -0,0 +1,11 @@ +# This is used to alter the hash for in-game feedback authors. +# The reason for this is that doing this stops admins trying to +# guess who submitted a specific piece of feedback by feeding +# a list of ckeys into an MD5 hasher. +# In order for this to work, this file must contain a string, to be +# added onto the ckey before hashing, and ideally as few people as +# possible should know the string. It is also recommended to utilize +# file permissions (e.g. chown) to only allow the game to access +# this file, and forbid people with access to the server directly +# from just opening the file (even if they use sudo, it'll make a log). +# Make sure the string doesn't start with a #, or it'll be commented out. diff --git a/html/changelogs/neerti-feedback_system.yml b/html/changelogs/neerti-feedback_system.yml new file mode 100644 index 0000000000..1dd7a2b3a3 --- /dev/null +++ b/html/changelogs/neerti-feedback_system.yml @@ -0,0 +1,36 @@ +################################ +# Example Changelog File +# +# Note: This file, and files beginning with ".", and files that don't end in ".yml" will not be read. If you change this file, you will look really dumb. +# +# Your changelog will be merged with a master changelog. (New stuff added only, and only on the date entry for the day it was merged.) +# When it is, any changes listed below will disappear. +# +# Valid Prefixes: +# bugfix +# wip (For works in progress) +# tweak +# soundadd +# sounddel +# rscadd (general adding of nice things) +# rscdel (general deleting of nice things) +# imageadd +# imagedel +# maptweak +# spellcheck (typo fixes) +# experiment +################################# + +# Your name. +author: Neerti + +# Optional: Remove this file after generating master changelog. Useful for PR changelogs that won't get used again. +delete-after: True + +# Any changes you've made. See valid prefix list above. +# INDENT WITH TWO SPACES. NOT TABS. SPACES. +# SCREW THIS UP AND IT WON'T WORK. +# Also, all entries are changed into a single [] after a master changelog generation. Just remove the brackets when you add new entries. +# Please surround your changes in double quotes ("), as certain characters otherwise screws up compiling. The quotes will not show up in the changelog. +changes: + - rscadd: "Adds an in-game feedback system that can be activated in the server configuration. Can be accessed from the lobby, with a button next to the other lobby buttons. Additional features and restrictions on usage are controlled by the server configuration." diff --git a/vorestation.dme b/vorestation.dme index 366937ae38..cd503b76bd 100644 --- a/vorestation.dme +++ b/vorestation.dme @@ -27,7 +27,11 @@ #include "code\__defines\_compile_options.dm" #include "code\__defines\_lists.dm" #include "code\__defines\_planes+layers.dm" +<<<<<<< HEAD:vorestation.dme #include "code\__defines\_planes+layers_vr.dm" +======= +#include "code\__defines\_protect.dm" +>>>>>>> 97f8c1d... Merge pull request #6719 from Neerti/feedback_system:polaris.dme #include "code\__defines\_tick.dm" #include "code\__defines\admin.dm" #include "code\__defines\admin_vr.dm" @@ -68,7 +72,11 @@ #include "code\__defines\roguemining_vr.dm" #include "code\__defines\sound.dm" #include "code\__defines\species_languages.dm" +<<<<<<< HEAD:vorestation.dme #include "code\__defines\species_languages_vr.dm" +======= +#include "code\__defines\sqlite_defines.dm" +>>>>>>> 97f8c1d... Merge pull request #6719 from Neerti/feedback_system:polaris.dme #include "code\__defines\stat_tracking.dm" #include "code\__defines\subsystems.dm" #include "code\__defines\subsystems_vr.dm" @@ -246,6 +254,7 @@ #include "code\controllers\subsystems\planets.dm" #include "code\controllers\subsystems\radiation.dm" #include "code\controllers\subsystems\shuttles.dm" +#include "code\controllers\subsystems\sqlite.dm" #include "code\controllers\subsystems\sun.dm" #include "code\controllers\subsystems\time_track.dm" #include "code\controllers\subsystems\timer.dm" @@ -320,6 +329,9 @@ #include "code\datums\looping_sounds\machinery_sounds.dm" #include "code\datums\looping_sounds\sequence.dm" #include "code\datums\looping_sounds\weather_sounds.dm" +#include "code\datums\managed_browsers\_managed_browser.dm" +#include "code\datums\managed_browsers\feedback_form.dm" +#include "code\datums\managed_browsers\feedback_viewer.dm" #include "code\datums\observation\_debug.dm" #include "code\datums\observation\_defines.dm" #include "code\datums\observation\destroyed.dm" @@ -3373,6 +3385,7 @@ #include "code\unit_tests\map_tests.dm" #include "code\unit_tests\mob_tests.dm" #include "code\unit_tests\research_tests.dm" +#include "code\unit_tests\sqlite_tests.dm" #include "code\unit_tests\unit_test.dm" #include "code\unit_tests\unit_test_vr.dm" #include "code\unit_tests\vore_tests_vr.dm"