diff --git a/.gitignore b/.gitignore index 75c221a..1e3ef35 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ serverkt/build serverkt/out serverkt/.kotlin serverkt/*.jar +serverkt/blocklist.txt diff --git a/.idea/deployment.xml b/.idea/deployment.xml index 79fc0e8..5e17475 100644 --- a/.idea/deployment.xml +++ b/.idea/deployment.xml @@ -1,45 +1,13 @@ - + - `).join(""); + + `).join(""); document.getElementById("model-details").innerHTML = window.models.map(i => ` - - `).join(""); + + `).join(""); refreshModel(); @@ -81,7 +81,7 @@ if (!data['error']) { document.getElementById("input").value = ""; - fetch(window.SERVER + "/api/v2/history?amount=30").then((res) => { + fetch(window.SERVER + "/api/v2/history").then((res) => { res.json().then((data) => { window.listData = data['output']['history']; refreshList(); @@ -175,7 +175,7 @@ fetch(window.SERVER + "/api/v2/history/" + id, { method: "DELETE" }).then(() => { - fetch(window.SERVER + "/api/v2/history?amount=30").then((res) => { + fetch(window.SERVER + "/api/v2/history").then((res) => { res.json().then((data) => { window.listData = data['output']['history']; window.lastList = data['output']['history']; @@ -192,7 +192,7 @@ window.playerDownload = (id, name) => { let element = document.createElement("a"); element.setAttribute("download", name); - element.setAttribute("href", "https://cdn.equestria.dev/sunnystarbot/content/" + id + "/audio.wav"); + element.setAttribute("href", "https://cdn.floo.fi/voice-generator/content/" + id + "/audio.wav"); element.click(); } @@ -205,7 +205,7 @@ document.getElementById("list-message").style.display = "none"; document.getElementById("list").style.display = ""; - for (let id of Array.from(document.getElementById("list").children).map(i => i.id.split("-")[1])) { + for (let id of Array.from(document.getElementById("list").children).map(i => i.id.split("-").slice(1).join("-"))) { if (!data.map(i => i.id).includes(id)) { document.getElementById("history-" + id).outerHTML = ""; } @@ -348,11 +348,11 @@ if (item.status === "processed") { document.querySelector("#history-" + item.id + " .history-player").style.display = ""; document.querySelector("#history-" + item.id + " .history-spectrogram").style.display = "flex"; - document.querySelector("#history-" + item.id + " .history-spectrogram-img").src = "https://cdn.equestria.dev/sunnystarbot/content/" + item.id + "/figure.png"; + document.querySelector("#history-" + item.id + " .history-spectrogram-img").src = "https://cdn.floo.fi/voice-generator/" + item.id + "/figure.png"; if (document.querySelector("#history-" + item.id + " .history-loading")) document.querySelector("#history-" + item.id + " .history-loading").style.display = "none"; if (document.querySelector("#history-" + item.id + " .history-player audio").src.trim() === "") { - document.querySelector("#history-" + item.id + " .history-player audio").src = "https://cdn.equestria.dev/sunnystarbot/content/" + item.id + "/audio.wav"; + document.querySelector("#history-" + item.id + " .history-player audio").src = "https://cdn.floo.fi/voice-generator/" + item.id + "/audio.wav"; } } else { document.querySelector("#history-" + item.id + " .history-player").style.display = "none"; @@ -387,7 +387,7 @@ window.configureRefresh = () => { function refresh() { - fetch(window.SERVER + "/api/v2/history?amount=30").then((res) => { + fetch(window.SERVER + "/api/v2/history").then((res) => { res.json().then((data) => { window.listData = data['output']['history']; refreshList(); diff --git a/client/assets/src/navbar.js b/client/assets/src/navbar.js index 75e4aa4..7a5d922 100644 --- a/client/assets/src/navbar.js +++ b/client/assets/src/navbar.js @@ -1,79 +1,3 @@ -document.getElementById("navbar-placeholder").outerHTML = ` -
- -
- -`; - window.onscroll = () => { updateScroll(); } @@ -87,7 +11,94 @@ function updateScroll() { } async function prepareNavbar() { - document.getElementById("navbar-version").innerText = (await (await fetch("/version")).text()).trim(); + let isAdministrator = (await (await fetch(window.SERVER + "/api/v2/admin/available", { + credentials: "include", + headers: { + "Authorization": localStorage.getItem('token') ? "PrivateToken " + localStorage.getItem('token') : '' + } + })).json()).output['available']; + + document.getElementById("navbar-placeholder").outerHTML = ` +
+ +
+ + `; + loadNavigation(); } diff --git a/client/version b/client/version index a95f288..fae6e3d 100644 --- a/client/version +++ b/client/version @@ -1 +1 @@ -4.1.4 +4.2.1 diff --git a/server/includes/updates.php b/server/includes/updates.php index d8dffa0..afa4aa6 100644 --- a/server/includes/updates.php +++ b/server/includes/updates.php @@ -102,32 +102,31 @@ function getPossible(): bool { } function getFilterCode($original) { - $text = strtolower(trim($original ?? "")); - - $list = $list2 = explode("\n", trim(strtolower(file_get_contents($_SERVER['DOCUMENT_ROOT'] . "/includes/list.txt")))); - - if ($text === "") { - die("false"); - } - + // Use some code thing? $code = 0; - $ptext = " " . preg_replace("/ +/", " ", preg_replace("/[^a-z]/", " ", $text)) . " "; - + // For each item in the list foreach ($list as $item) { + // Trim that item (already done in Kotlin) $item = trim($item); + // If the processed text contains this item if (str_contains($ptext, $item)) { + // And the item is longer than 4 characters, or it is an entire word if (strlen($item) > 4 || str_contains($ptext, " " . $item . " ")) { - $code = 2; + $code = 2; // Code 2? + // Or if it is (shorter than) 4 characters and is an entire word } else if (str_contains($ptext, " " . $item . " ")) { - $code = 1; + $code = 1; // Code 1? } } } + // If the code is still 0? if ($code === 0) { + // Process the list again?? foreach ($list2 as $item) { + // This does literally the same stuff as above $item = trim($item); if (str_contains($ptext, $item)) { @@ -138,13 +137,16 @@ function getFilterCode($original) { } } + // I think this was meant to block like "Ahhhh~" if (str_contains($text, "~") || trim($ptext) === "ah") { $code = 2; } + // This uses a completely unneeded online profanity check service if ($code !== 2 && file_get_contents("https://www.purgomalum.com/service/containsprofanity?text=" . rawurlencode($text)) === "true") { - $code = 3; + $code = 3; // Code 3? } + // And now we return this code, in the Kotlin version we want a boolean instead return $code; } diff --git a/serverkt/.idea/forwardedPorts.xml b/serverkt/.idea/forwardedPorts.xml new file mode 100644 index 0000000..db5d876 --- /dev/null +++ b/serverkt/.idea/forwardedPorts.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/serverkt/.idea/inspectionProfiles/Project_Default.xml b/serverkt/.idea/inspectionProfiles/Project_Default.xml index 5b619e9..ce9a5fd 100644 --- a/serverkt/.idea/inspectionProfiles/Project_Default.xml +++ b/serverkt/.idea/inspectionProfiles/Project_Default.xml @@ -1,6 +1,7 @@
\ No newline at end of file diff --git a/serverkt/.idea/serverkt.iml b/serverkt/.idea/serverkt.iml new file mode 100644 index 0000000..1d86ec5 --- /dev/null +++ b/serverkt/.idea/serverkt.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/serverkt/build.gradle.kts b/serverkt/build.gradle.kts index 4d6fef3..ec6f82d 100644 --- a/serverkt/build.gradle.kts +++ b/serverkt/build.gradle.kts @@ -11,7 +11,7 @@ plugins { } group = "fi.floo.voice" -version = "0.1.0" +version = "1.0.0" application { mainClass.set("fi.floo.voice.ApplicationKt") diff --git a/serverkt/src/main/kotlin/fi/floo/voice/Application.kt b/serverkt/src/main/kotlin/fi/floo/voice/Application.kt index 1ba7814..47d8685 100644 --- a/serverkt/src/main/kotlin/fi/floo/voice/Application.kt +++ b/serverkt/src/main/kotlin/fi/floo/voice/Application.kt @@ -2,9 +2,10 @@ package fi.floo.voice import fi.floo.voice.server.getServer import fi.floo.voice.server.loadConfig -import fi.floo.voice.types.Config +import fi.floo.voice.types.BlockList -var config: Config = loadConfig() +val config = loadConfig() +val blockList = BlockList() fun main() { getServer().start(wait = true) diff --git a/serverkt/src/main/kotlin/fi/floo/voice/Utility.kt b/serverkt/src/main/kotlin/fi/floo/voice/Utility.kt index b0ca1fb..1c1e565 100644 --- a/serverkt/src/main/kotlin/fi/floo/voice/Utility.kt +++ b/serverkt/src/main/kotlin/fi/floo/voice/Utility.kt @@ -4,7 +4,6 @@ import fi.floo.voice.types.* import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.response.* -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.File import java.security.SecureRandom @@ -22,17 +21,19 @@ fun generateToken(size: Long): String { return list.joinToString("") } -fun generateAPIKey(): String { - val chrs = "0123456789abcdefghijklmnopqrstuvwxyz-_ABCDEFGHIJKLMNOPQRSTUVWXYZ" - - val secureRandom = SecureRandom.getInstanceStrong() +fun generateAPIKey(): String = generateToken(48) +fun generateJobID(): String = generateToken(64) - val list = secureRandom - .ints(48, 0, chrs.length) - .mapToObj { i -> chrs[i] } - .toList() +fun getUserFromID(id: String): UserData? { + val file = File("data/users/$id") + val laxJson = Json { ignoreUnknownKeys = true } - return list.joinToString("") + return if (file.exists()) { + val dataString = file.readText() + laxJson.decodeFromString(dataString) + } else { + null + } } fun getSession(call: ApplicationCall): UserData? { @@ -87,9 +88,7 @@ fun getAPIKey(id: String): String { val file = File("data/keys/$id") if (!file.exists()) { - file.writer().use { f -> - f.write(generateAPIKey()) - } + file.writeText(generateAPIKey()) } return file.readText().trim() @@ -127,12 +126,18 @@ suspend fun getAuthenticationData(call: ApplicationCall, mode: AuthenticationMod if (session == null) { if (mode == AuthenticationMode.Enforced) { - call.respondText(text = Json.encodeToString(httpCodeToError(HttpStatusCode.Unauthorized)), - status = HttpStatusCode.Unauthorized, contentType = ContentType.Application.Json) + call.respond(HttpStatusCode.Unauthorized, httpCodeToError(HttpStatusCode.Unauthorized)) } + AuthenticationData(false, null, null) } else { val apiKey = getAPIKey(session.id) + + if (config.banned.contains(session.id)) { + call.respond(HttpStatusCode.Forbidden, httpCodeToError(HttpStatusCode.Forbidden)) + return AuthenticationData(false, null, null) + } + AuthenticationData(true, session, apiKey) } } diff --git a/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/admin/available.kt b/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/admin/available.kt new file mode 100644 index 0000000..a7862c5 --- /dev/null +++ b/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/admin/available.kt @@ -0,0 +1,18 @@ +package fi.floo.voice.routing.api.v2.admin + +import fi.floo.voice.config +import fi.floo.voice.getAuthenticationData +import fi.floo.voice.types.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* + +suspend fun apiV2AdminAvailable(call: ApplicationCall) { + val auth = getAuthenticationData(call, AuthenticationMode.Enforced) + if (!auth.authenticated || auth.userData == null) return + + call.respond(HttpStatusCode.OK, APIResponse( + error = null, + output = APIResponseAvailable(config.admin == auth.userData.id) + )) +} diff --git a/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/admin/history.kt b/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/admin/history.kt new file mode 100644 index 0000000..c26db26 --- /dev/null +++ b/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/admin/history.kt @@ -0,0 +1,35 @@ +package fi.floo.voice.routing.api.v2.admin + +import fi.floo.voice.config +import fi.floo.voice.getAuthenticationData +import fi.floo.voice.httpCodeToError +import fi.floo.voice.types.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import java.io.File +import java.lang.Integer.min + +suspend fun apiV2AdminHistory(call: ApplicationCall) { + val auth = getAuthenticationData(call, AuthenticationMode.Enforced) + if (!auth.authenticated || auth.userData == null) return + + if (config.admin != auth.userData.id) { + call.respond(HttpStatusCode.Forbidden, httpCodeToError(HttpStatusCode.Forbidden)) + return + } + + val list: GenerationList = Generation.getAll() + list.inner = list.inner + .filter { !File("data/generations/${it.data.id}/reviewed.txt").exists() } + .toMutableList() + + val amount = call.parameters["amount"] ?: "30" + val amountInt = amount.toInt() + list.inner = list.inner.subList(0, min(amountInt + 1, list.inner.size)) + + call.respond(HttpStatusCode.OK, APIResponse( + error = null, + output = APIResponseHistory(list.inner.size, list.flatten()) + )) +} diff --git a/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/admin/historyDelete.kt b/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/admin/historyDelete.kt new file mode 100644 index 0000000..e726f72 --- /dev/null +++ b/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/admin/historyDelete.kt @@ -0,0 +1,40 @@ +package fi.floo.voice.routing.api.v2.admin + +import fi.floo.voice.config +import fi.floo.voice.getAuthenticationData +import fi.floo.voice.httpCodeToError +import fi.floo.voice.types.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import java.io.File + +suspend fun apiV2AdminHistoryDelete(call: ApplicationCall) { + val auth = getAuthenticationData(call, AuthenticationMode.Enforced) + if (!auth.authenticated || auth.userData == null) return + + if (config.admin != auth.userData.id) { + call.respond(HttpStatusCode.Forbidden, httpCodeToError(HttpStatusCode.Forbidden)) + return + } + + val id = call.parameters["id"] + + if (id == null) { + call.respondRedirect("/auth/init") + return + } + + if (id.length > 96) { + call.respond(HttpStatusCode.PayloadTooLarge, httpCodeToError(HttpStatusCode.PayloadTooLarge)) + return + } + + Generation.fromId(id)?.let { + File("data/generations/${it.data.id}/reviewed.txt").createNewFile() + call.respond(HttpStatusCode.OK, APIResponse( + error = null, + output = it.data + )) + } +} diff --git a/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/available.kt b/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/available.kt index 424c752..fcf8ebe 100644 --- a/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/available.kt +++ b/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/available.kt @@ -8,14 +8,9 @@ import io.ktor.server.response.* suspend fun apiV2Available(call: ApplicationCall) { val auth = getAuthenticationData(call, AuthenticationMode.Enforced) - if (!auth.authenticated || auth.userData == null) return - - val list: GenerationList = Generation.forUser(auth.userData) - val items = list.inner - .map { it.data.status == "generating" || it.data.status == "queued" } call.respond(HttpStatusCode.OK, APIResponse( error = null, - output = APIResponseAvailable(!items.contains(true)) + output = APIResponseAvailable(auth.canEnqueueGeneration()) )) } diff --git a/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/generate.kt b/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/generate.kt new file mode 100644 index 0000000..228103e --- /dev/null +++ b/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/generate.kt @@ -0,0 +1,59 @@ +package fi.floo.voice.routing.api.v2 + +import fi.floo.voice.* +import fi.floo.voice.types.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import java.io.File + +val preprocessRegex = Regex("""[^a-zA-Z':\d()\[].,?! ~]""") + +suspend fun apiV2Generate(call: ApplicationCall) { + val generateRequestData = call.receive() + val auth = getAuthenticationData(call, AuthenticationMode.Enforced) + if (!auth.authenticated || auth.userData == null) return + + val id = generateJobID() + + if (generateRequestData.model.length > 20 || generateRequestData.input.length > 160) { + call.respond(HttpStatusCode.PayloadTooLarge, httpCodeToError(HttpStatusCode.PayloadTooLarge)) + return + } + + if (!auth.canEnqueueGeneration()) { + call.respond(HttpStatusCode.TooManyRequests, httpCodeToError(HttpStatusCode.TooManyRequests)) + return + } + + val model = config.models[generateRequestData.model] + if (model == null || !model.enabled) { + call.respond(HttpStatusCode.NotImplemented, httpCodeToError(HttpStatusCode.NotImplemented)) + return + } + + val processedText = generateRequestData.input + .trim() + .replace(preprocessRegex, "") + + File("data/generations/$id").mkdir() + File("data/generations/$id/model.txt").writeText(generateRequestData.model) + File("data/generations/$id/author.txt").writeText(auth.userData.id) + File("data/generations/$id/timestamp.txt").writeText((System.currentTimeMillis() / 1000).toString()) + File("data/generations/$id/input_orig.txt").writeText(generateRequestData.input) + File("data/generations/$id/version.txt").writeText(model.version) + + if (!blockList.isStringFriendly(generateRequestData.input)) { + File("data/generations/$id/held.txt").createNewFile() + File("data/generations/$id/blocked.txt").createNewFile() + call.respond(HttpStatusCode.BadRequest, httpCodeToError(HttpStatusCode.BadRequest)) + return + } + + File("data/generations/$id/input.txt").writeText(processedText) + call.respond(HttpStatusCode.OK, APIResponse( + error = null, + output = APIResponseGenerate(id) + )) +} diff --git a/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/history.kt b/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/history.kt index 805a276..2a6bc63 100644 --- a/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/history.kt +++ b/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/history.kt @@ -5,6 +5,7 @@ import fi.floo.voice.types.* import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.response.* +import java.lang.Integer.min suspend fun apiV2History(call: ApplicationCall) { val auth = getAuthenticationData(call, AuthenticationMode.Enforced) @@ -16,12 +17,9 @@ suspend fun apiV2History(call: ApplicationCall) { .filter { it.data.status != "removed" } .toMutableList() - val amount = call.parameters["amount"] - - if (amount != null) { - val amountInt = amount.toInt() - list.inner = list.inner.subList(0, amountInt) - } + val amount = call.parameters["amount"] ?: "30" + val amountInt = amount.toInt() + list.inner = list.inner.subList(0, min(amountInt + 1, list.inner.size)) call.respond(HttpStatusCode.OK, APIResponse( error = null, diff --git a/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/historyDelete.kt b/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/historyDelete.kt index d62e3b2..b3e3764 100644 --- a/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/historyDelete.kt +++ b/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/historyDelete.kt @@ -25,7 +25,7 @@ suspend fun apiV2HistoryDelete(call: ApplicationCall) { } Generation.fromId(id)?.let { - if (it.data.status == "blocked" || it.data.author != auth.userData.id) { + if (it.data.status == "blocked" || it.data.authorId != auth.userData.id) { call.respond(HttpStatusCode.NotFound, httpCodeToError(HttpStatusCode.NotFound)) } else if (it.data.status == "removed") { call.respond(HttpStatusCode.Conflict, httpCodeToError(HttpStatusCode.Conflict)) diff --git a/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/historyId.kt b/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/historyId.kt index 42584f4..75e5e28 100644 --- a/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/historyId.kt +++ b/serverkt/src/main/kotlin/fi/floo/voice/routing/api/v2/historyId.kt @@ -24,7 +24,7 @@ suspend fun apiV2HistoryId(call: ApplicationCall) { } Generation.fromId(id)?.let { - if (it.data.status == "blocked" || it.data.author != auth.userData.id) { + if (it.data.status == "blocked" || it.data.authorId != auth.userData.id) { call.respond(HttpStatusCode.NotFound, httpCodeToError(HttpStatusCode.NotFound)) } else { call.respond(HttpStatusCode.OK, APIResponse( diff --git a/serverkt/src/main/kotlin/fi/floo/voice/routing/auth/callback.kt b/serverkt/src/main/kotlin/fi/floo/voice/routing/auth/callback.kt index ae801c6..546b834 100644 --- a/serverkt/src/main/kotlin/fi/floo/voice/routing/auth/callback.kt +++ b/serverkt/src/main/kotlin/fi/floo/voice/routing/auth/callback.kt @@ -75,13 +75,8 @@ suspend fun authCallback(call: ApplicationCall) { val userDataString = response.body() val sessionToken = generateToken(96) - File("data/session/$sessionToken").writer().use { f -> - f.write(userDataString) - } - - File("data/users/${userData.id}").writer().use { f -> - f.write(userDataString) - } + File("data/session/$sessionToken").writeText(userDataString) + File("data/users/${userData.id}").writeText(userDataString) call.response.headers.append( HttpHeaders.SetCookie, @@ -91,9 +86,7 @@ suspend fun authCallback(call: ApplicationCall) { if (config.development) { val handoffToken = generateToken(32) val handoffData = HandoffData(sessionToken, Instant.now().toEpochMilli()) - File("data/handoff/$handoffToken").writer().use { f -> - f.write(Json.encodeToString(handoffData)) - } + File("data/handoff/$handoffToken").writeText(Json.encodeToString(handoffData)) call.respondRedirect("http://localhost:3000/handoff#$handoffToken") } else { diff --git a/serverkt/src/main/kotlin/fi/floo/voice/routing/docs/v1.kt b/serverkt/src/main/kotlin/fi/floo/voice/routing/docs/v1.kt index a84293c..64d9598 100644 --- a/serverkt/src/main/kotlin/fi/floo/voice/routing/docs/v1.kt +++ b/serverkt/src/main/kotlin/fi/floo/voice/routing/docs/v1.kt @@ -13,6 +13,6 @@ suspend fun docsV1(call: ApplicationCall) { call.respondRedirect("/auth/init") } else { val apiKey = getAPIKey(session.id) - call.respond(VelocityContent("resources/views/docs-v1.vl", mapOf("apiKey" to apiKey))) + call.respond(VelocityContent("views/docs-v1.vl", mapOf("apiKey" to apiKey))) } } diff --git a/serverkt/src/main/kotlin/fi/floo/voice/routing/docs/v2.kt b/serverkt/src/main/kotlin/fi/floo/voice/routing/docs/v2.kt index 698015b..5f61556 100644 --- a/serverkt/src/main/kotlin/fi/floo/voice/routing/docs/v2.kt +++ b/serverkt/src/main/kotlin/fi/floo/voice/routing/docs/v2.kt @@ -13,6 +13,6 @@ suspend fun docsV2(call: ApplicationCall) { call.respondRedirect("/auth/init") } else { val apiKey = getAPIKey(session.id) - call.respond(VelocityContent("resources/views/docs-v2.vl", mapOf("apiKey" to apiKey))) + call.respond(VelocityContent("views/docs-v2.vl", mapOf("apiKey" to apiKey))) } } diff --git a/serverkt/src/main/kotlin/fi/floo/voice/routing/favicon.kt b/serverkt/src/main/kotlin/fi/floo/voice/routing/favicon.kt new file mode 100644 index 0000000..95e6461 --- /dev/null +++ b/serverkt/src/main/kotlin/fi/floo/voice/routing/favicon.kt @@ -0,0 +1,8 @@ +package fi.floo.voice.routing + +import io.ktor.server.application.* +import io.ktor.server.response.* + +suspend fun favicon(call: ApplicationCall) { + call.respondRedirect("/assets/favicon.ico") +} diff --git a/serverkt/src/main/kotlin/fi/floo/voice/routing/index.kt b/serverkt/src/main/kotlin/fi/floo/voice/routing/index.kt index 1677f3f..d669a89 100644 --- a/serverkt/src/main/kotlin/fi/floo/voice/routing/index.kt +++ b/serverkt/src/main/kotlin/fi/floo/voice/routing/index.kt @@ -4,5 +4,5 @@ import io.ktor.server.application.* import io.ktor.server.response.* suspend fun index(call: ApplicationCall) { - call.respondText("Floofi Voice Generator Server - Written in Kotlin with Ktor\n") + call.respondRedirect("/docs/v2") } diff --git a/serverkt/src/main/kotlin/fi/floo/voice/server/Config.kt b/serverkt/src/main/kotlin/fi/floo/voice/server/Config.kt index 03ce196..c411838 100644 --- a/serverkt/src/main/kotlin/fi/floo/voice/server/Config.kt +++ b/serverkt/src/main/kotlin/fi/floo/voice/server/Config.kt @@ -3,16 +3,14 @@ package fi.floo.voice.server import fi.floo.voice.types.Config import kotlinx.serialization.json.Json import java.io.File -import java.nio.file.Files -import java.nio.file.Paths fun loadConfig(): Config { - Files.createDirectories(Paths.get("data")) - Files.createDirectories(Paths.get("data/generations")) - Files.createDirectories(Paths.get("data/handoff")) - Files.createDirectories(Paths.get("data/session")) - Files.createDirectories(Paths.get("data/users")) - Files.createDirectories(Paths.get("data/keys")) + File("data").mkdir() + File("data/generations").mkdir() + File("data/handoff").mkdir() + File("data/session").mkdir() + File("data/users").mkdir() + File("data/keys").mkdir() val file = File("config.json") return Json.decodeFromString(file.readText()) diff --git a/serverkt/src/main/kotlin/fi/floo/voice/server/EmbeddedServer.kt b/serverkt/src/main/kotlin/fi/floo/voice/server/EmbeddedServer.kt index 812863c..1b72865 100644 --- a/serverkt/src/main/kotlin/fi/floo/voice/server/EmbeddedServer.kt +++ b/serverkt/src/main/kotlin/fi/floo/voice/server/EmbeddedServer.kt @@ -12,6 +12,8 @@ import io.ktor.server.plugins.cors.routing.* import io.ktor.server.plugins.statuspages.* import io.ktor.server.response.* import io.ktor.server.velocity.* +import org.apache.velocity.runtime.RuntimeConstants +import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader fun getServer(): NettyApplicationEngine { return embeddedServer(Netty, port = 8080, host = "0.0.0.0") { @@ -48,7 +50,10 @@ fun getServer(): NettyApplicationEngine { } } - install(Velocity) + install(Velocity) { + setProperty(RuntimeConstants.RESOURCE_LOADER, "classpath") + setProperty("classpath.resource.loader.class", ClasspathResourceLoader::class.java.name) + } configureRouting() } diff --git a/serverkt/src/main/kotlin/fi/floo/voice/server/Routing.kt b/serverkt/src/main/kotlin/fi/floo/voice/server/Routing.kt index 689e57c..2cde4f8 100644 --- a/serverkt/src/main/kotlin/fi/floo/voice/server/Routing.kt +++ b/serverkt/src/main/kotlin/fi/floo/voice/server/Routing.kt @@ -4,17 +4,17 @@ import fi.floo.voice.routing.auth.* import fi.floo.voice.routing.docs.* import fi.floo.voice.routing.* import fi.floo.voice.routing.api.v2.* +import fi.floo.voice.routing.api.v2.admin.* import io.ktor.server.application.* import io.ktor.server.http.content.* import io.ktor.server.routing.* -import java.io.File fun Application.configureRouting() { routing { - staticFiles("/assets", File("resources/static")) - staticFiles("/favicon.ico", File("resources/static/favicon.ico")) + staticResources("/assets", "static") get("/") { index(call) } + get("/favicon.ico") { favicon(call) } get("/auth/") { auth(call) } get("/auth/init") { authInit(call) } get("/auth/callback") { authCallback(call) } @@ -31,9 +31,11 @@ fun Application.configureRouting() { get("/api/v2/history") { apiV2History(call) } get("/api/v2/history/{id}") { apiV2HistoryId(call) } delete("/api/v2/history/{id}") { apiV2HistoryDelete(call) } - // TODO: POST generate + post("/api/v2/generate") { apiV2Generate(call) } post("/api/v2/handoff") { apiV2Handoff(call) } - // TODO: admin? + get("/api/v2/admin/available") { apiV2AdminAvailable(call) } + get("/api/v2/admin/history") { apiV2AdminHistory(call) } + delete("/api/v2/admin/history/{id}") { apiV2AdminHistoryDelete(call) } } } diff --git a/serverkt/src/main/kotlin/fi/floo/voice/types/APIResponseGenerate.kt b/serverkt/src/main/kotlin/fi/floo/voice/types/APIResponseGenerate.kt new file mode 100644 index 0000000..5922772 --- /dev/null +++ b/serverkt/src/main/kotlin/fi/floo/voice/types/APIResponseGenerate.kt @@ -0,0 +1,8 @@ +package fi.floo.voice.types + +import kotlinx.serialization.Serializable + +@Serializable +data class APIResponseGenerate( + val id: String +) diff --git a/serverkt/src/main/kotlin/fi/floo/voice/types/AuthenticationData.kt b/serverkt/src/main/kotlin/fi/floo/voice/types/AuthenticationData.kt index 2a6402d..376b6ad 100644 --- a/serverkt/src/main/kotlin/fi/floo/voice/types/AuthenticationData.kt +++ b/serverkt/src/main/kotlin/fi/floo/voice/types/AuthenticationData.kt @@ -1,7 +1,17 @@ package fi.floo.voice.types -data class AuthenticationData( +class AuthenticationData( val authenticated: Boolean, val userData: UserData?, val apiKey: String? -) +) { + fun canEnqueueGeneration(): Boolean { + if (!authenticated || userData == null) return false + + val list: GenerationList = Generation.forUser(userData) + val isBusy = list.inner + .any { it.data.status == "generating" || it.data.status == "queued" } + + return !isBusy + } +} diff --git a/serverkt/src/main/kotlin/fi/floo/voice/types/BlockList.kt b/serverkt/src/main/kotlin/fi/floo/voice/types/BlockList.kt new file mode 100644 index 0000000..dffcce7 --- /dev/null +++ b/serverkt/src/main/kotlin/fi/floo/voice/types/BlockList.kt @@ -0,0 +1,31 @@ +package fi.floo.voice.types + +import java.io.File + +class BlockList { + private val blockListSpaceRegex = Regex(""" +""") + private val blockListAlphabeticalRegex = Regex("""[^a-z\d]""") + + private val file = File("blocklist.txt") + private val inner = file.readLines() + .filter { it.isNotEmpty() } + .map { it.trim().lowercase() } + + private fun containsBlockedWord(string: String, word: String): Boolean = + (string.contains(word) && word.length > 4) || (string.contains(" $word ")) + + fun isStringFriendly(string: String): Boolean { + if (string.isEmpty()) return true + val processed = " ${string.trim().lowercase() + .replace(blockListAlphabeticalRegex, " ") + .replace(blockListSpaceRegex, " ")} " + + for (item in inner) { + if (containsBlockedWord(processed, item)) { + return false + } + } + + return true + } +} diff --git a/serverkt/src/main/kotlin/fi/floo/voice/types/GenerateRequestData.kt b/serverkt/src/main/kotlin/fi/floo/voice/types/GenerateRequestData.kt new file mode 100644 index 0000000..5b136c9 --- /dev/null +++ b/serverkt/src/main/kotlin/fi/floo/voice/types/GenerateRequestData.kt @@ -0,0 +1,9 @@ +package fi.floo.voice.types + +import kotlinx.serialization.Serializable + +@Serializable +data class GenerateRequestData( + val input: String, + val model: String +) diff --git a/serverkt/src/main/kotlin/fi/floo/voice/types/Generation.kt b/serverkt/src/main/kotlin/fi/floo/voice/types/Generation.kt index bdcba40..59c5627 100644 --- a/serverkt/src/main/kotlin/fi/floo/voice/types/Generation.kt +++ b/serverkt/src/main/kotlin/fi/floo/voice/types/Generation.kt @@ -1,5 +1,7 @@ package fi.floo.voice.types +import fi.floo.voice.getUserFromID +import fi.floo.voice.routing.auth.auth import java.io.File import java.time.Instant import java.time.format.DateTimeFormatter @@ -21,7 +23,26 @@ class Generation(val data: GenerationData) { list.inner = list.inner .filter { it.data.version != "0" } .filter { it.data.model != "" } - .filter { it.data.author == user.id } + .filter { it.data.authorId == user.id } + .toMutableList() + } + } + + return list + } + + fun getAll(): GenerationList { + val list = GenerationList(mutableListOf()) + val directory = File("data/generations") + val files = directory.listFiles()?.filter { it.isDirectory } + + if (files != null) { + for (file in files) { + list.inner.add(fromDirectory(file)) + list.inner.sortByDescending { it.time } + list.inner = list.inner + .filter { it.data.version != "0" } + .filter { it.data.model != "" } .toMutableList() } } @@ -72,7 +93,8 @@ class Generation(val data: GenerationData) { id = directory.name, model = model, version = version, - author = author, + authorId = author, + authorName = getUserFromID(author)?.name ?: author, time = dateIso, timeTs = timeTs, audioUrl = "https://cdn.floo.fi/voice-generator/${directory.name}/audio.wav", diff --git a/serverkt/src/main/kotlin/fi/floo/voice/types/GenerationData.kt b/serverkt/src/main/kotlin/fi/floo/voice/types/GenerationData.kt index 035da1f..4aca613 100644 --- a/serverkt/src/main/kotlin/fi/floo/voice/types/GenerationData.kt +++ b/serverkt/src/main/kotlin/fi/floo/voice/types/GenerationData.kt @@ -7,7 +7,8 @@ data class GenerationData( val id: String, val model: String, val version: String, - val author: String, + val authorId: String, + val authorName: String, val time: String, val timeTs: Long, val audioUrl: String, diff --git a/serverkt/src/main/kotlin/fi/floo/voice/types/UserData.kt b/serverkt/src/main/kotlin/fi/floo/voice/types/UserData.kt index 11fbfc5..6a0bac4 100644 --- a/serverkt/src/main/kotlin/fi/floo/voice/types/UserData.kt +++ b/serverkt/src/main/kotlin/fi/floo/voice/types/UserData.kt @@ -4,5 +4,6 @@ import kotlinx.serialization.Serializable @Serializable data class UserData( - val id: String + val id: String, + val name: String ) diff --git a/serverkt/resources/static/bootstrap/bootstrap.min.css b/serverkt/src/main/resources/static/bootstrap/bootstrap.min.css similarity index 100% rename from serverkt/resources/static/bootstrap/bootstrap.min.css rename to serverkt/src/main/resources/static/bootstrap/bootstrap.min.css diff --git a/serverkt/resources/static/bootstrap/bootstrap.min.js b/serverkt/src/main/resources/static/bootstrap/bootstrap.min.js similarity index 100% rename from serverkt/resources/static/bootstrap/bootstrap.min.js rename to serverkt/src/main/resources/static/bootstrap/bootstrap.min.js diff --git a/serverkt/resources/static/favicon.ico b/serverkt/src/main/resources/static/favicon.ico similarity index 100% rename from serverkt/resources/static/favicon.ico rename to serverkt/src/main/resources/static/favicon.ico diff --git a/serverkt/resources/views/docs-v1.vl b/serverkt/src/main/resources/views/docs-v1.vl similarity index 100% rename from serverkt/resources/views/docs-v1.vl rename to serverkt/src/main/resources/views/docs-v1.vl diff --git a/serverkt/resources/views/docs-v2.vl b/serverkt/src/main/resources/views/docs-v2.vl similarity index 78% rename from serverkt/resources/views/docs-v2.vl rename to serverkt/src/main/resources/views/docs-v2.vl index 3c984f1..4d98335 100644 --- a/serverkt/resources/views/docs-v2.vl +++ b/serverkt/src/main/resources/views/docs-v2.vl @@ -67,7 +67,7 @@ output: any|null 400 Bad Request - A required argument for this endpoint is missing or incorrectly formatted. + A required argument for this endpoint is missing or incorrectly formatted, or the request goes against Voice Generator's content filters and/or applicable use policy. 401 @@ -77,7 +77,7 @@ output: any|null 403 Forbidden - You should normally have access to this endpoint, but your account has been blocked by the administrators. + You should normally have access to this endpoint, but your account has been blocked by the administrators, or you have tried to access an administration endpoint without being an administrator. 404 @@ -89,11 +89,6 @@ output: any|null Method Not Allowed The HTTP method used at this endpoint is not the one that is expected. - - 409 - Conflict - You have tried to change the state of an item to a state it is already in. - 413 Payload Too Large @@ -104,11 +99,6 @@ output: any|null Too Many Requests This API key has exceeded the number of allowed requests in a certain timespan. - - 451 - Unavailable For Legal Reasons - The request goes against Voice Generator's content filters and/or applicable use policy. - 500 Internal Server Error @@ -117,7 +107,7 @@ output: any|null 501 Not Implemented - The AI model you are trying to use does not exist. + The AI model you are trying to use does not exist or is not yet enabled. @@ -137,7 +127,7 @@ output: any|null GET - v2/models
Kotlin + v2/models - Get the available models.
{
@@ -154,23 +144,16 @@ output: any|null
         
         
             GET
-            v2/status
Kotlin + v2/status - Get system information.
{
-    "user": string
+    "user": string
 }
GET - v2/available
Kotlin + v2/available - Check if enqueing a new generation is possible.
{
@@ -179,8 +162,8 @@ output: any|null
         
         
             GET
-            v2/history
Kotlin - URL: amount: number (optional) + v2/history + URL: amount: number (optional, default 30) Get previous generations associated with this account.
{
     "count": number,
@@ -189,7 +172,8 @@ output: any|null
             "id": string,
             "model": string,
             "version": string,
-            "author": string,
+            "authorId": string,
+            "authorName": string,
             "time": string,
             "timeTs": number,
             "audioUrl": string?,
@@ -202,14 +186,15 @@ output: any|null
         
         
             GET
-            v2/history/:id
Kotlin + v2/history/:id - Get a specific generation from its ID.
{
     "id": string,
     "model": string,
     "version": string,
-    "author": string,
+    "authorId": string,
+    "authorName": string,
     "time": string,
     "timeTs": number,
     "audioUrl": string?,
@@ -220,14 +205,15 @@ output: any|null
         
         
             DELETE
-            v2/history/:id
Kotlin + v2/history/:id - Remove a specific generation from your history using its ID.
{
     "id": string,
     "model": string,
     "version": string,
-    "author": string,
+    "authorId": string,
+    "authorName": string,
     "time": string,
     "timeTs": number,
     "audioUrl": string?,
@@ -247,11 +233,63 @@ output: any|null
         
         
             POST
-            v2/handoff
Kotlin + v2/handoff JSON token: string Get an interactive access token from a client handoff token. Used only during development.
{
     "token": string
+}
+ + + GET + v2/admin/available + - + Check if this account has access to administrator tools. +
{
+    "available": boolean
+}
+ + + GET + v2/admin/history + URL: amount: number (optional, default 30) + Get all previous unreviewed generations. +
{
+    "count": number,
+    "history": [
+        {
+            "id": string,
+            "model": string,
+            "version": string,
+            "authorId": string,
+            "authorName": string,
+            "time": string,
+            "timeTs": number,
+            "audioUrl": string?,
+            "graphUrl": string?,
+            "input": string,
+            "status": generation-status,
+        }
+    ]
+}
+ + + DELETE + v2/admin/history/:id + - + Mark a generation as reviewed using its ID. +
{
+    "id": string,
+    "model": string,
+    "version": string,
+    "authorId": string,
+    "authorName": string,
+    "time": string,
+    "timeTs": number,
+    "audioUrl": string?,
+    "graphUrl": string?,
+    "input": string,
+    "status": generation-status,
 }