Merge pull request #6679 from VOREStation/upstream-merge-6719

[MIRROR] Adds an In-game Feedback System
This commit is contained in:
Novacat
2020-02-24 21:13:33 -05:00
committed by GitHub
17 changed files with 780 additions and 3 deletions

View File

@@ -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;\
}

View File

@@ -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.

View File

@@ -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"

View File

@@ -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

View File

@@ -1577,3 +1577,11 @@ var/mob/dview/dview_mob = new
else
return "\[[url_encode(thing.tag)]\]"
return "\ref[input]"
// Painlessly creates an <a href=...> 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 "<a href='?src=\ref[href_src];[list2params(href_params)]'>[href_text]</a>"

View File

@@ -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

View File

@@ -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"

View File

@@ -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()

View File

@@ -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("<html><body>")
dat += "<center>"
dat += "<font size='2'>"
dat += "Here, you can write some feedback for the server.<br>"
dat += "Note that HTML is NOT supported!<br>"
dat += "Click the edit button to begin writing.<br>"
dat += "Your feedback is currently [length(feedback_body)]/[MAX_FEEDBACK_LENGTH] letters long."
dat += "</font>"
dat += "<hr>"
dat += "<h2>Preview</h2></center>"
dat += "Author: "
if(can_be_private())
if(!feedback_hide_author)
dat += "[my_client.ckey] "
dat += span("linkOn", "<b>Visible</b>")
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", "<b>Hashed</b>")
else
dat += my_client.ckey
dat += "<br>"
if(config.sqlite_feedback_topics.len > 1)
dat += "Topic: [href(src, list("feedback_choose_topic" = 1), feedback_topic)]<br>"
else
dat += "Topic: [config.sqlite_feedback_topics[1]]<br>"
dat += "<br>"
if(feedback_body)
dat += replacetext(feedback_body, "\n", "<br>") // So newlines will look like they work in the preview.
else
dat += "<i>\[Feedback goes here...\]</i>"
dat += "<br>"
dat += href(src, list("feedback_edit_body" = 1), "Edit")
dat += "<hr>"
if(config.sqlite_feedback_cooldown)
dat += "<i>Please note that you will have to wait [config.sqlite_feedback_cooldown] day\s before \
being able to write more feedback after submitting.</i><br>"
dat += href(src, list("feedback_submit" = 1), "Submit")
dat += "</body></html>"
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)

View File

@@ -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("<html><body>")
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 += "<table border='1' style='width:100%'>"
dat += "<tr>"
dat += "<th>[href(src, list("filter_id" = 1), "ID")]</th>"
dat += "<th>[href(src, list("filter_topic" = 1), "Topic")]</th>"
dat += "<th>[href(src, list("filter_author" = 1), "Author")]</th>"
dat += "<th>[href(src, list("filter_content" = 1), "Content")]</th>"
dat += "<th>[href(src, list("filter_datetime" = 1), "Datetime")]</th>"
dat += "</tr>"
while(last_query.NextRow())
var/list/row_data = last_query.GetRowData()
dat += "<tr>"
dat += "<td>[row_data[SQLITE_FEEDBACK_COLUMN_ID]]</td>"
dat += "<td>[row_data[SQLITE_FEEDBACK_COLUMN_TOPIC]]</td>"
dat += "<td>[row_data[SQLITE_FEEDBACK_COLUMN_AUTHOR]]</td>" // 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", "<br>")
dat += "<td>[text]</td>"
dat += "<td>[row_data[SQLITE_FEEDBACK_COLUMN_DATETIME]]</td>"
dat += "</tr>"
dat += "</table>"
dat += "</body></html>"
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("<html><body>")
dat += replacetext(text, "\n", "<br>")
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()

View File

@@ -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(

View File

@@ -66,9 +66,11 @@
else
output += "<p><a href='byond://?src=\ref[src];shownews=1'>Show News</A></p>"
if(SSsqlite.can_submit_feedback(client))
output += "<p>[href(src, list("give_feedback" = 1), "Give Feedback")]</p>"
output += "</div>"
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()
@@ -306,6 +308,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 +336,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

View File

@@ -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

View File

@@ -485,4 +485,41 @@ IPR_BLOCK_BAD_IPS
IPR_ALLOW_EXISTING
# And what that age is (number)
IPR_MINIMUM_AGE 5
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

View File

@@ -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.

View File

@@ -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."

View File

@@ -28,6 +28,7 @@
#include "code\__defines\_lists.dm"
#include "code\__defines\_planes+layers.dm"
#include "code\__defines\_planes+layers_vr.dm"
#include "code\__defines\_protect.dm"
#include "code\__defines\_tick.dm"
#include "code\__defines\admin.dm"
#include "code\__defines\admin_vr.dm"
@@ -69,6 +70,7 @@
#include "code\__defines\sound.dm"
#include "code\__defines\species_languages.dm"
#include "code\__defines\species_languages_vr.dm"
#include "code\__defines\sqlite_defines.dm"
#include "code\__defines\stat_tracking.dm"
#include "code\__defines\subsystems.dm"
#include "code\__defines\subsystems_vr.dm"
@@ -246,6 +248,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 +323,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"
@@ -3374,6 +3380,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"