From: TheSaminator Date: Fri, 8 Apr 2022 22:51:16 +0000 (-0400) Subject: Add GDPR private info page X-Git-Url: https://gitweb.starshipfights.net/?a=commitdiff_plain;h=1a13b3aaa419adad57e5369ae80105ac1072d227;p=starship-fights Add GDPR private info page --- diff --git a/src/jvmMain/kotlin/starshipfights/auth/providers.kt b/src/jvmMain/kotlin/starshipfights/auth/providers.kt index 5af6ace..96b4c89 100644 --- a/src/jvmMain/kotlin/starshipfights/auth/providers.kt +++ b/src/jvmMain/kotlin/starshipfights/auth/providers.kt @@ -95,6 +95,14 @@ interface AuthProvider { call.respondHtml(HttpStatusCode.OK, call.manageUserPage()) } + get("/me/private-info") { + call.respondHtml(HttpStatusCode.OK, call.privateInfoPage()) + } + + get("/me/private-info/txt") { + call.respondText(ContentType.Text.Plain, HttpStatusCode.OK) { call.privateInfo() } + } + post("/me/manage") { val form = call.receiveValidatedParameters() val currentUser = call.getUser() ?: redirect("/login") diff --git a/src/jvmMain/kotlin/starshipfights/data/admiralty/admirals.kt b/src/jvmMain/kotlin/starshipfights/data/admiralty/admirals.kt index ca797d5..dc80e9e 100644 --- a/src/jvmMain/kotlin/starshipfights/data/admiralty/admirals.kt +++ b/src/jvmMain/kotlin/starshipfights/data/admiralty/admirals.kt @@ -61,6 +61,9 @@ data class ShipInDrydock( val shipData: Ship get() = Ship(id.reinterpret(), name, shipType) + val fullName: String + get() = shipData.fullName + companion object Table : DocumentTable by DocumentTable.create({ index(ShipInDrydock::owningAdmiral) }) diff --git a/src/jvmMain/kotlin/starshipfights/info/views_gdpr.kt b/src/jvmMain/kotlin/starshipfights/info/views_gdpr.kt new file mode 100644 index 0000000..ac68710 --- /dev/null +++ b/src/jvmMain/kotlin/starshipfights/info/views_gdpr.kt @@ -0,0 +1,172 @@ +package starshipfights.info + +import io.ktor.application.* +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.toList +import kotlinx.html.HTML +import kotlinx.html.h1 +import kotlinx.html.iframe +import kotlinx.html.style +import org.litote.kmongo.eq +import org.litote.kmongo.or +import starshipfights.auth.getUser +import starshipfights.auth.getUserSession +import starshipfights.data.admiralty.Admiral +import starshipfights.data.admiralty.BattleRecord +import starshipfights.data.admiralty.ShipInDrydock +import starshipfights.data.auth.User +import starshipfights.data.auth.UserSession +import starshipfights.game.GlobalSide +import starshipfights.redirect +import java.time.Instant + +suspend fun ApplicationCall.privateInfo(): String { + val currentSession = getUserSession() ?: redirect("/login") + val userId = currentSession.user + val (user, userData) = coroutineScope { + val getUser = async { User.get(userId) } + val getAdmirals = async { Admiral.filter(Admiral::owningUser eq userId).toList() } + val getSessions = async { UserSession.filter(UserSession::user eq userId).toList() } + val getBattles = async { + BattleRecord.filter( + or( + BattleRecord::hostUser eq userId, + BattleRecord::guestUser eq userId + ) + ).toList() + } + + getUser.await() to Triple(getAdmirals.await(), getSessions.await(), getBattles.await()) + } + + val now = Instant.now() + val (userAdmirals, userSessions, userBattles) = userData + user ?: redirect("/login") + + val battleEndings = userBattles.associate { record -> + record.id to when (record.winner) { + GlobalSide.HOST -> record.hostUser == userId + GlobalSide.GUEST -> record.guestUser == userId + null -> null + } + } + + val (admiralShips, battleAdmirals, battleOpponents) = coroutineScope { + val getShips = userAdmirals.associate { admiral -> + admiral.id to async { ShipInDrydock.filter(ShipInDrydock::owningAdmiral eq admiral.id).toList() } + } + val getAdmirals = userBattles.associate { record -> + val admiralId = if (record.hostUser == userId) record.hostAdmiral else record.guestAdmiral + record.id to async { Admiral.get(admiralId) } + } + val getOpponents = userBattles.associate { record -> + val (opponentId, opponentAdmiralId) = if (record.hostUser == userId) record.guestUser to record.guestAdmiral else record.hostUser to record.hostAdmiral + + record.id to (async { User.get(opponentId) } to async { Admiral.get(opponentAdmiralId) }) + } + + Triple( + getShips.mapValues { (_, deferred) -> deferred.await() }, + getAdmirals.mapValues { (_, deferred) -> deferred.await() }, + getOpponents.mapValues { (_, deferred) -> deferred.let { (u, a) -> u.await() to a.await() } } + ) + } + + return buildString { + appendLine("# Private data of user https://starshipfights.net/user/$userId\n") + appendLine("Profile name: ${user.profileName}") + appendLine("Profile bio: \"\"\"") + appendLine(user.profileBio) + appendLine("\"\"\"") + appendLine("") + appendLine("## Activity data") + appendLine("Registered at: ${user.registeredAt}") + appendLine("Last activity: ${user.lastActivity}") + appendLine("Online status: ${if (user.showUserStatus) "shown" else "hidden"}") + appendLine("") + appendLine("## Discord login data") + appendLine("Discord ID: ${user.discordId}") + appendLine("Discord name: ${user.discordName}") + appendLine("Discord discriminator: ${user.discordDiscriminator}") + appendLine(user.discordAvatar?.let { "Discord avatar: $it" } ?: "Discord avatar absent") + appendLine("Discord profile: ${if (user.showDiscordName) "shown" else "hidden"}") + appendLine("") + appendLine("## Session data") + appendLine("IP addresses are ${if (user.logIpAddresses) "stored" else "ignored"}") + userSessions.sortedByDescending { it.expiration }.forEach { session -> + appendLine("") + appendLine("### Session ${session.id}") + appendLine("Browser User-Agent: ${session.userAgent}") + appendLine("Client addresses${if (session.clientAddresses.isEmpty()) " are not stored" else ":"}") + session.clientAddresses.forEach { addr -> appendLine("* $addr") } + appendLine("${if (session.expiration > now) "Will expire" else "Has expired"} at: ${session.expiration}") + } + appendLine("") + appendLine("## Battle-record data") + userBattles.sortedBy { it.whenEnded }.forEach { record -> + appendLine("") + appendLine("### Battle record ${record.id}") + appendLine("Battle size: ${record.battleInfo.size.displayName} (${record.battleInfo.size.numPoints})") + appendLine("Battle background: ${record.battleInfo.bg.displayName}") + appendLine("Battle started at: ${record.whenStarted}") + appendLine("Battle completed at: ${record.whenEnded}") + appendLine("Battle was fought by ${battleAdmirals[record.id]?.let { "${it.fullName} (https://starshipfights.net/admiral/${it.id})" } ?: "{deleted admiral}"}") + appendLine("Battle was fought against ${battleOpponents[record.id]?.second?.let { "${it.fullName} (https://starshipfights.net/admiral/${it.id})" } ?: "{deleted admiral}"}") + appendLine(" => ${battleOpponents[record.id]?.first?.let { "${it.profileName} (https://starshipfights.net/user/${it.id})" } ?: "{deleted user}"}") + when (battleEndings[record.id]) { + true -> appendLine("Battle ended in victory") + false -> appendLine("Battle ended in defeat") + null -> appendLine("Battle ended in stalemate") + } + appendLine(" => \"${record.winMessage}\"") + } + appendLine("") + appendLine("## Admiral data") + userAdmirals.forEach { admiral -> + appendLine("") + appendLine("### ${admiral.fullName} (https://starshipfights.net/admiral/${admiral.id})") + appendLine("Admiral is ${if (admiral.isFemale) "female" else "male"}") + appendLine("Admiral serves the ${admiral.faction.navyName}") + appendLine("Admiral's experience is ${admiral.acumen} acumen") + appendLine("Admiral's monetary wealth is ${admiral.money} ${admiral.faction.currencyName}") + appendLine("Admiral can command ships as big as a ${admiral.rank.maxShipWeightClass.displayName}") + val ships = admiralShips[admiral.id].orEmpty() + appendLine("Admiral has ${ships.size} ships:") + ships.forEach { ship -> + appendLine("") + appendLine("#### ${ship.fullName} (${ship.id})") + appendLine("Ship is a ${ship.shipType.fullerDisplayName}") + appendLine("Ship ${if (ship.readyAt > now) "will be ready at" else "has been ready since"} ${ship.readyAt}") + } + appendLine("") + appendLine("# More information") + appendLine("This document contains the totality of your private data as stored by Starship Fights") + appendLine("This page can be accessed at https://starshipfights.net/me/private-info") + appendLine("All private info can be downloaded at https://starshipfights.net/me/private-info/txt") + appendLine("The privacy policy can be reviewed at https://starshipfights.net/about/pp") + } + } +} + +suspend fun ApplicationCall.privateInfoPage(): HTML.() -> Unit { + if (getUser() == null) redirect("/login") + + return page( + null, standardNavBar(), PageNavSidebar( + listOf( + NavLink("/me/manage", "Back to Preferences"), + NavLink("/about/pp", "Review Privacy Policy"), + ) + ) + ) { + section { + h1 { +"Your Private Info" } + + iframe { + style = "width:100%;height:25em" + src = "/me/private-info/txt" + } + } + } +} diff --git a/src/jvmMain/kotlin/starshipfights/info/views_main.kt b/src/jvmMain/kotlin/starshipfights/info/views_main.kt index 2858631..782148b 100644 --- a/src/jvmMain/kotlin/starshipfights/info/views_main.kt +++ b/src/jvmMain/kotlin/starshipfights/info/views_main.kt @@ -120,6 +120,11 @@ suspend fun ApplicationCall.privacyPolicyPage(): HTML.() -> Unit { p { +"Privacy policies are nice and all, but they're only as strong as the staff that implements them. I have no interest in abusing others, just as I have no interest in doxing or otherwise revealing what locations people log in from. Nor have I any interest in being worshipped as some kind of programmer-god messiah. I am impervious to such corrupting ambitions." } + p { + +"All of the private data we collect can be viewed at your " + a(href = "/me/private-info") { +"Private Info" } + +" page." + } h2 { +"Who Can't See It" } p { +"We protect your data by a combination of requiring TLS-secured HTTP connections, and keeping the database's port only open on 127.0.0.1, i.e. no one outside of the server's local machine can even connect to the database, much less access the data stored inside of it." @@ -130,6 +135,8 @@ suspend fun ApplicationCall.privacyPolicyPage(): HTML.() -> Unit { dd { +"Initial writing" } dt { +"February 15, 2022" } dd { +"Indicate that IP storage is an opt-in-only feature" } + dt { +"April 08, 2022" } + dd { +"Add link to Private Info page" } } } } diff --git a/src/jvmMain/kotlin/starshipfights/info/views_user.kt b/src/jvmMain/kotlin/starshipfights/info/views_user.kt index 7b5e1aa..2c90631 100644 --- a/src/jvmMain/kotlin/starshipfights/info/views_user.kt +++ b/src/jvmMain/kotlin/starshipfights/info/views_user.kt @@ -210,6 +210,11 @@ suspend fun ApplicationCall.manageUserPage(): HTML.() -> Unit { +Entities.nbsp +"Log Session IP Addresses" } + p { + +"Your private info can be viewed at the " + a(href = "/me/private-info") {+"Private Info"} + +" page." + } request.queryParameters["error"]?.let { errorMsg -> p { style = "color:#d22"