From 2025e3e5e56ca22a23f47005175dc8ce254024d6 Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Mon, 29 Dec 2025 11:15:58 -0800 Subject: implement basic webui --- README.md | 16 + src/main/java/com/pinapelz/Database.java | 159 +++ src/main/java/com/pinapelz/FileSystem.java | 49 +- src/main/java/com/pinapelz/MessageListener.java | 57 +- src/main/java/com/pinapelz/Retriever.java | 4 +- src/main/java/com/pinapelz/frontend/App.kt | 1243 ++++++++++++++++++++++- src/main/resources/templates/index.html | 573 +++++++++++ 7 files changed, 2089 insertions(+), 12 deletions(-) create mode 100644 README.md create mode 100644 src/main/resources/templates/index.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..878ef44 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Nitro-FS (WIP) +> [!CAUTION] +> Do **NOT** use Discord as a substitute for cloud storage. All uploaded files are public and are at the mercy of Discord. +> Keep backups! +> +> Only use this for files that you are OK with losing. + +An alternative approach to storing files on Discord, specifically targeted at those with a Nitro membership. + +Nitro-FS assumes that you have increased upload limit to Discord and thus does not chunk your file into parts. +This removes the hassle of splitting and merging lots of smaller files, or data loss if one particular chunk is lost. + +- Uses a Bot to watch for attachments on a given server + - Allows you to upload from any device that can access to Discord, no need to connect to Web/UI for this +- Postgres as the index + - Doesn't store metadata in Discord messages to allow for faster retrieval \ No newline at end of file diff --git a/src/main/java/com/pinapelz/Database.java b/src/main/java/com/pinapelz/Database.java index 7116328..b52b45e 100644 --- a/src/main/java/com/pinapelz/Database.java +++ b/src/main/java/com/pinapelz/Database.java @@ -88,4 +88,163 @@ public class Database { } } + public ResultSet getFilesByDirectoryId(int directoryId, String search, String mimeTypeFilter, String sortBy) { + StringBuilder sql = new StringBuilder(""" + SELECT + file_id, + file_name, + file_description, + size, + mime_type, + created_at + FROM files + WHERE directory_id = ? + """); + + if (search != null && !search.trim().isEmpty()) { + sql.append(" AND (LOWER(file_name) LIKE ? OR LOWER(file_description) LIKE ?)"); + } + + if (mimeTypeFilter != null && !mimeTypeFilter.trim().isEmpty()) { + sql.append(" AND mime_type LIKE ?"); + } + + switch (sortBy) { + case "file_name": + sql.append(" ORDER BY file_name ASC"); + break; + case "size": + sql.append(" ORDER BY size DESC"); + break; + default: + sql.append(" ORDER BY created_at DESC"); + break; + } + + try { + PreparedStatement ps = conn.prepareStatement(sql.toString()); + int paramIndex = 1; + + ps.setInt(paramIndex++, directoryId); + + if (search != null && !search.trim().isEmpty()) { + String searchPattern = "%" + search.toLowerCase() + "%"; + ps.setString(paramIndex++, searchPattern); + ps.setString(paramIndex++, searchPattern); + } + + if (mimeTypeFilter != null && !mimeTypeFilter.trim().isEmpty()) { + ps.setString(paramIndex++, mimeTypeFilter + "%"); + } + + return ps.executeQuery(); + } catch (SQLException e) { + throw new RuntimeException("Failed to fetch filtered files for directory", e); + } + } + + public ResultSet getAllDirectories() { + String sql = """ + SELECT + directory_id, + path, + created_at, + (SELECT COUNT(*) FROM files WHERE directory_id = directories.directory_id) as file_count + FROM directories + ORDER BY path ASC + """; + + try { + PreparedStatement ps = conn.prepareStatement(sql); + return ps.executeQuery(); + } catch (SQLException e) { + throw new RuntimeException("Failed to fetch directories", e); + } + } + + public ResultSet getDirectoryById(int directoryId) { + String sql = """ + SELECT + directory_id, + path, + created_at, + (SELECT COUNT(*) FROM files WHERE directory_id = directories.directory_id) as file_count + FROM directories + WHERE directory_id = ? + """; + + try { + PreparedStatement ps = conn.prepareStatement(sql); + ps.setInt(1, directoryId); + return ps.executeQuery(); + } catch (SQLException e) { + throw new RuntimeException("Failed to fetch directory", e); + } + } + + public int createDirectory(String path) throws SQLException { + String sql = """ + INSERT INTO directories (path) + VALUES (?) + ON CONFLICT (path) DO UPDATE SET path = EXCLUDED.path + RETURNING directory_id + """; + + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, path); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + return rs.getInt("directory_id"); + } + throw new SQLException("Failed to get directory ID"); + } + } + } + + public boolean deleteFile(int fileId) throws SQLException { + String sql = """ + DELETE FROM files + WHERE file_id = ? + """; + + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setInt(1, fileId); + int rowsAffected = ps.executeUpdate(); + return rowsAffected > 0; + } + } + + public boolean deleteDirectory(int directoryId) throws SQLException { + // Check if directory has files + String checkSql = """ + SELECT COUNT(*) as file_count + FROM files + WHERE directory_id = ? + """; + + try (PreparedStatement checkPs = conn.prepareStatement(checkSql)) { + checkPs.setInt(1, directoryId); + try (ResultSet rs = checkPs.executeQuery()) { + if (rs.next() && rs.getInt("file_count") > 0) { + throw new SQLException("Cannot delete directory: contains files"); + } + } + } + + if (directoryId == 1) { + throw new SQLException("Cannot delete root directory"); + } + + String deleteSql = """ + DELETE FROM directories + WHERE directory_id = ? + """; + + try (PreparedStatement deletePs = conn.prepareStatement(deleteSql)) { + deletePs.setInt(1, directoryId); + int rowsAffected = deletePs.executeUpdate(); + return rowsAffected > 0; + } + } + } diff --git a/src/main/java/com/pinapelz/FileSystem.java b/src/main/java/com/pinapelz/FileSystem.java index 1f252a2..d073691 100644 --- a/src/main/java/com/pinapelz/FileSystem.java +++ b/src/main/java/com/pinapelz/FileSystem.java @@ -2,6 +2,7 @@ package com.pinapelz; import net.dv8tion.jda.api.entities.Message; +import java.sql.ResultSet; import java.sql.SQLException; public class FileSystem { @@ -14,14 +15,58 @@ public class FileSystem { return database.getFileById(fileId); } - public void createNewFile(String channelId, String messageId, String description, Message.Attachment attachment){ + public void createNewFile(String channelId, String messageId, int directoryId, String description, Message.Attachment attachment){ int fileSize = attachment.getSize(); String filename = attachment.getFileName(); String mimeType = attachment.getContentType(); try { - database.recordFileMetadata(channelId, messageId, 1, filename, description, fileSize, mimeType ); + database.recordFileMetadata(channelId, messageId, directoryId, filename, description, fileSize, mimeType ); } catch (SQLException e) { throw new RuntimeException(e); } } + + // Backward compatibility - defaults to root directory (ID 1) + public void createNewFile(String channelId, String messageId, String description, Message.Attachment attachment){ + createNewFile(channelId, messageId, 1, description, attachment); + } + public ResultSet getFilesByDirectoryIdFiltered(int directoryId, String search, String mimeTypeFilter, String sortBy) { + return database.getFilesByDirectoryId(directoryId, search, mimeTypeFilter, sortBy); + } + + public int findOrCreateDirectory(String path) throws SQLException { + // Try to find existing directory + ResultSet rs = getAllDirectories(); + while (rs.next()) { + if (path.equals(rs.getString("path"))) { + int id = rs.getInt("directory_id"); + rs.close(); + return id; + } + } + rs.close(); + + // Create new directory if not found + return createDirectory(path); + } + + public ResultSet getAllDirectories() { + return database.getAllDirectories(); + } + + public ResultSet getDirectoryById(int directoryId) { + return database.getDirectoryById(directoryId); + } + + public int createDirectory(String path) throws SQLException { + return database.createDirectory(path); + } + + public boolean deleteFile(int fileId) throws SQLException { + return database.deleteFile(fileId); + } + + public boolean deleteDirectory(int directoryId) throws SQLException { + return database.deleteDirectory(directoryId); + } } diff --git a/src/main/java/com/pinapelz/MessageListener.java b/src/main/java/com/pinapelz/MessageListener.java index 3922c51..e4d435f 100644 --- a/src/main/java/com/pinapelz/MessageListener.java +++ b/src/main/java/com/pinapelz/MessageListener.java @@ -2,8 +2,10 @@ package com.pinapelz; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.entities.emoji.Emoji; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; +import java.sql.SQLException; public class MessageListener extends ListenerAdapter { @@ -21,18 +23,65 @@ public class MessageListener extends ListenerAdapter { Message message = event.getMessage(); String content = message.getContentRaw(); - System.out.println(message.getAttachments().get(0).getUrl()); if(!message.getAttachments().isEmpty()){ - System.out.println("Attachment Received! Filing this away now..."); + DirectoryInfo dirInfo = parseDirectoryFromMessage(content); + for(Message.Attachment attachment : message.getAttachments()){ - fileSystem.createNewFile(message.getChannelId(), message.getId(), content, attachment); + try { + fileSystem.createNewFile( + message.getChannelId(), + message.getId(), + dirInfo.directoryId, + dirInfo.description, + attachment + ); + message.addReaction(Emoji.fromUnicode("✅")).queue(); + System.out.println("File uploaded to directory: " + dirInfo.path + " (" + attachment.getFileName() + ")"); + + } catch (Exception e) { + message.addReaction(Emoji.fromUnicode("❌")).queue(); + System.err.println("Upload failed for " + attachment.getFileName() + ": " + e.getMessage()); + } } } if (content.equals("!ping")) { MessageChannel channel = event.getChannel(); - channel.sendMessage("Pong!").queue(); // Important to call .queue() on the RestAction returned by sendMessage(...) + channel.sendMessage("Pong!").queue(); + } + } + + private DirectoryInfo parseDirectoryFromMessage(String message) { + if (message.contains(":")) { + String[] parts = message.split(":", 2); + String dirPath = parts[0].trim(); + String description = parts.length > 1 ? parts[1].trim() : ""; + int directoryId = findOrCreateDirectory(dirPath); + return new DirectoryInfo(directoryId, description, dirPath); + } + + return new DirectoryInfo(1, message, "root"); + } + + private int findOrCreateDirectory(String path) { + try { + return fileSystem.findOrCreateDirectory(path); + } catch (SQLException e) { + System.err.println("Directory creation failed for '" + path + "', using root: " + e.getMessage()); + return 1; + } + } + + private static class DirectoryInfo { + int directoryId; + String description; + String path; + + DirectoryInfo(int directoryId, String description, String path) { + this.directoryId = directoryId; + this.description = description; + this.path = path; } } diff --git a/src/main/java/com/pinapelz/Retriever.java b/src/main/java/com/pinapelz/Retriever.java index 1d78785..856dab1 100644 --- a/src/main/java/com/pinapelz/Retriever.java +++ b/src/main/java/com/pinapelz/Retriever.java @@ -4,8 +4,6 @@ import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import java.util.concurrent.CompletableFuture; - public class Retriever { private final JDA jda; @@ -25,7 +23,7 @@ public class Retriever { for (Message.Attachment file : message.getAttachments()) { if (file.getFileName().equals(fileName)) { - return file.getUrl(); + return file.getProxyUrl(); } } diff --git a/src/main/java/com/pinapelz/frontend/App.kt b/src/main/java/com/pinapelz/frontend/App.kt index ab176da..66b19a4 100644 --- a/src/main/java/com/pinapelz/frontend/App.kt +++ b/src/main/java/com/pinapelz/frontend/App.kt @@ -4,18 +4,1255 @@ import io.javalin.Javalin import io.javalin.http.staticfiles.Location import com.pinapelz.Retriever import com.pinapelz.FileSystem +import java.sql.ResultSet +import java.text.SimpleDateFormat fun startFrontend(retriever: Retriever, fileSystem: FileSystem) { val app = Javalin.create { it.staticFiles.add("/public", Location.CLASSPATH) } + + app.get("/") { ctx -> + val directoryId = ctx.queryParam("dir")?.toIntOrNull() ?: 1 + ctx.html(generateMainHtml(directoryId)) + } + + app.get("/api/directories") { ctx -> + val directories = mutableListOf>() + val rs = fileSystem.getAllDirectories() + + while (rs.next()) { + directories.add(mapOf( + "id" to rs.getInt("directory_id"), + "path" to rs.getString("path"), + "fileCount" to rs.getInt("file_count"), + "created" to rs.getTimestamp("created_at").toString() + )) + } + rs.close() + + ctx.json(directories) + } + + app.get("/api/directory/{id}") { ctx -> + val directoryId = ctx.pathParam("id").toInt() + val rs = fileSystem.getDirectoryById(directoryId) + + if (rs.next()) { + val directory = mapOf( + "id" to rs.getInt("directory_id"), + "path" to rs.getString("path"), + "fileCount" to rs.getInt("file_count"), + "created" to rs.getTimestamp("created_at").toString() + ) + rs.close() + ctx.json(directory) + } else { + rs.close() + ctx.status(404).result("Directory not found") + } + } + + app.get("/api/files") { ctx -> + val directoryId = ctx.queryParam("dir")?.toIntOrNull() ?: 1 + val search = ctx.queryParam("search") ?: "" + val mimeTypeFilter = ctx.queryParam("mimeType") ?: "" + val sortBy = ctx.queryParam("sortBy") ?: "created_at" + + val files = mutableListOf>() + val rs: ResultSet = fileSystem.getFilesByDirectoryIdFiltered(directoryId, search, mimeTypeFilter, sortBy) + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") + + while (rs.next()) { + files.add(mapOf( + "id" to rs.getInt("file_id"), + "name" to rs.getString("file_name"), + "description" to (rs.getString("file_description") ?: ""), + "size" to formatFileSize(rs.getLong("size")), + "mimeType" to (rs.getString("mime_type") ?: "unknown"), + "created" to dateFormat.format(rs.getTimestamp("created_at")) + )) + } + rs.close() + + val html = generateFileTableHtml(files, search, mimeTypeFilter) + ctx.html(html) + ctx.header("HX-Trigger", "updateFileCount") + ctx.header("X-File-Count", files.size.toString()) + } + + app.get("/api/directories-html") { ctx -> + val directories = mutableListOf>() + val rs = fileSystem.getAllDirectories() + + while (rs.next()) { + directories.add(mapOf( + "id" to rs.getInt("directory_id"), + "path" to rs.getString("path"), + "fileCount" to rs.getInt("file_count"), + "created" to rs.getTimestamp("created_at").toString() + )) + } + rs.close() + + val html = generateDirectoryListHtml(directories) + ctx.html(html) + } + + app.post("/api/directories") { ctx -> + val path = ctx.formParam("path") + if (path.isNullOrBlank()) { + ctx.status(400).json(mapOf( + "success" to false, + "message" to "Directory path is required" + )) + return@post + } + + val trimmedPath = path.trim() + + // Server-side validation + val validationError = validateDirectoryName(trimmedPath) + if (validationError != null) { + ctx.status(400).json(mapOf( + "success" to false, + "message" to validationError + )) + return@post + } + + try { + val directoryId = fileSystem.createDirectory(trimmedPath) + ctx.json(mapOf( + "success" to true, + "id" to directoryId, + "path" to trimmedPath, + "message" to "Directory created successfully" + )) + } catch (e: Exception) { + ctx.status(500).json(mapOf( + "success" to false, + "message" to "Failed to create directory: ${e.message}" + )) + } + } + + app.delete("/api/files/{id}") { ctx -> + val fileId = ctx.pathParam("id").toIntOrNull() + if (fileId == null) { + ctx.status(400).json(mapOf( + "success" to false, + "message" to "Invalid file ID" + )) + return@delete + } + + try { + val deleted = fileSystem.deleteFile(fileId) + if (deleted) { + ctx.json(mapOf( + "success" to true, + "message" to "File deleted successfully" + )) + } else { + ctx.status(404).json(mapOf( + "success" to false, + "message" to "File not found" + )) + } + } catch (e: Exception) { + ctx.status(500).json(mapOf( + "success" to false, + "message" to "Failed to delete file: ${e.message}" + )) + } + } + + app.delete("/api/directories/{id}") { ctx -> + val directoryId = ctx.pathParam("id").toIntOrNull() + if (directoryId == null) { + ctx.status(400).json(mapOf( + "success" to false, + "message" to "Invalid directory ID" + )) + return@delete + } + + try { + val deleted = fileSystem.deleteDirectory(directoryId) + if (deleted) { + ctx.json(mapOf( + "success" to true, + "message" to "Directory deleted successfully" + )) + } else { + ctx.status(404).json(mapOf( + "success" to false, + "message" to "Directory not found" + )) + } + } catch (e: Exception) { + ctx.status(500).json(mapOf( + "success" to false, + "message" to "Failed to delete directory: ${e.message}" + )) + } + } + app.get("/fetch") { ctx -> val fileId = ctx.queryParam("fileId") val fileMetadata = fileSystem.getFileById(Integer.parseInt(fileId)); print(fileMetadata[1]) - ctx.html(retriever.getFileUrl(fileMetadata[0], fileMetadata[1], fileMetadata[2])); + ctx.redirect(retriever.getFileUrl(fileMetadata[0], fileMetadata[1], fileMetadata[2])); + } + app.start(7070) +} +fun validateDirectoryName(path: String): String? { + if (path.length < 1 || path.length > 100) { + return "Directory name must be 1-100 characters long" + } + val invalidChars = Regex("[<>:\"/\\\\|?*\\x00-\\x1f]") + if (invalidChars.containsMatchIn(path)) { + return "Directory name contains invalid characters" + } + if (path == "." || path == "..") { + return "Invalid directory name" } - app.start(7070) -} \ No newline at end of file + if (path.startsWith(" ") || path.endsWith(" ") || path.endsWith(".")) { + return "Directory name cannot start/end with spaces or end with dots" + } + + return null +} + +fun generateMainHtml(directoryId: Int): String { + return """ + + + + + + nitro-fs + + + + + +
+
+
+
+ + nitro-fs +
+
# loading...
+
+
+ + +
+
+ +
+ + + + +
+
+ + + + + loading + + files +
+
+ +
+
+
+ +
+

loading files...

+

fetching your files from discord storage

+
+
+
+ + + + + + + + + """.trimIndent() +} + +fun generateDirectoryListHtml(directories: List>): String { + if (directories.isEmpty()) { + return """ +
+
+ +
+

no directories found

+
+ """.trimIndent() + } + + val directoryItems = directories.joinToString("") { dir -> + val path = dir["path"] as String + val displayName = if (path.isEmpty()) "root" else path + val fileCount = dir["fileCount"] as Int + val countDisplay = if (fileCount == 0) "" else " (${fileCount})" + + """ +
+
+
+ +
+
+
$displayName
+
+ $fileCount files +
+
+
+ +
+
+ ${if (!path.isEmpty()) """ +
+ +
+ """ else ""} +
+ """.trimIndent() + } + + return directoryItems +} + +fun formatFileSize(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + val kb = bytes / 1024.0 + if (kb < 1024) return "%.1f KB".format(kb) + val mb = kb / 1024.0 + if (mb < 1024) return "%.1f MB".format(mb) + val gb = mb / 1024.0 + return "%.1f GB".format(gb) +} + +fun generateFileTableHtml(files: List>, search: String = "", mimeTypeFilter: String = ""): String { + fun getFileIcon(mimeType: String?): String { + if (mimeType == null) return "fas fa-file" + + return when { + mimeType.startsWith("image/") -> "fas fa-file-image" + mimeType.startsWith("video/") -> "fas fa-file-video" + mimeType.startsWith("audio/") -> "fas fa-file-audio" + mimeType.contains("pdf") -> "fas fa-file-pdf" + mimeType.startsWith("text/") -> "fas fa-file-alt" + mimeType.contains("zip") || mimeType.contains("tar") || mimeType.contains("rar") -> "fas fa-file-archive" + else -> "fas fa-file" + } + } + + val fileRows = files.joinToString("") { file -> + """ + + + + + ${file["name"]} + + + ${file["description"]} + ${file["size"]} + ${(file["mimeType"] as? String)?.split("/")?.get(0) ?: "file"} + ${(file["created"] as String).split(" ")[0]} + + + + + """.trimIndent() + } + + return if (files.isEmpty()) { + val emptyMessage = if (search.isNotEmpty() || mimeTypeFilter.isNotEmpty()) { + """ +
+
+ +
+

no matches found

+

try different search terms or clear your filters

+ +
+ """ + } else { + """ +
+
+ +
+

no files yet

+

upload some files through discord to see them here

+
+ """ + } + + """ + $emptyMessage + + """.trimIndent() + } else { + """ + + + + + + + + + + + + + $fileRows + +
namedescriptionsizetypedateactions
+ + """.trimIndent() + } +} diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 0000000..98bc45e --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,573 @@ + + + + + + File Storage + + + + + +
+ +
+
+
+ + file storage +
+
# root
+
+
+ +
+
+ + +
+ + + + +
+
+ + + + + loading + + files +
+
+ + localhost:7070 +
+
+ + connecting... +
+
+ + +
+
+
+
+ +
+

loading files...

+

fetching your files from discord storage

+
+
+
+
+
+ + + + + \ No newline at end of file -- cgit v1.2.3