diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ff45cb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +*.css linguist-vendored +*.js linguist-vendored +*.json linguist-vendored +*.html linguist-vendored +*.xml linguist-vendored +*.md linguist-documentation +*.txt linguist-documentation +*.properties linguist-documentation +*.yml linguist-documentation +*.yaml linguist-documentation diff --git a/.gitignore b/.gitignore index 9154f4c..a5c3a45 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ hs_err_pid* replay_pid* +target + +*.env + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..acfd798 --- /dev/null +++ b/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + com.filejet + filejet + 1.0-SNAPSHOT + + + + + io.github.cdimascio + dotenv-java + 3.2.0 + + + + io.javalin + javalin + 6.7.0 + + + + com.fasterxml.jackson.core + jackson-databind + 2.17.2 + + + + org.slf4j + slf4j-simple + 2.0.16 + + + + com.github.ben-manes.caffeine + caffeine + 3.2.0 + + + + + 17 + 17 + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.1 + + com.filejet.API.Server + + + + + + \ No newline at end of file diff --git a/src/main/java/com/filejet/API/Server.java b/src/main/java/com/filejet/API/Server.java new file mode 100644 index 0000000..4e4b049 --- /dev/null +++ b/src/main/java/com/filejet/API/Server.java @@ -0,0 +1,29 @@ +package com.filejet.API; +// server logic + +import com.filejet.API.routes.DownloadRoute; +import com.filejet.API.util.EnvLoader; +import io.javalin.Javalin; + + +public class Server { + public static void main(String[] args) { + Javalin app = Javalin.create(config -> { + config.staticFiles.add("/HTML/"); + }).start(EnvLoader.getPort()); + + app.get("/api/download", DownloadRoute::handle); // for ?path=... downloads + app.get("/api/download/{file}", DownloadRoute::handle); // legacy/compat + app.get("/api/list-isos", DownloadRoute::list); + + app.before("/api/*", ctx -> { + String ua = ctx.header("User-Agent"); + if (ua == null || ua.toLowerCase().contains("bot") + || ua.toLowerCase().contains("curl") + ) { + ctx.status(403).result("Forbidden"); + } + }); + } + +} diff --git a/src/main/java/com/filejet/API/routes/DownloadRoute.java b/src/main/java/com/filejet/API/routes/DownloadRoute.java new file mode 100644 index 0000000..be42955 --- /dev/null +++ b/src/main/java/com/filejet/API/routes/DownloadRoute.java @@ -0,0 +1,150 @@ +package com.filejet.API.routes; + +import io.javalin.http.Context; +import com.filejet.API.util.EnvLoader; + +import java.io.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +public class DownloadRoute { + // Use resource path for ISO_DIR + private static final String isoDir = EnvLoader.getISODir(); + private static final AtomicInteger activeDownloaders = new AtomicInteger(0); + + // Caffeine cache: key = path + '|' + searchTerm, value = List> + private static final Cache>> searchCache = + Caffeine.newBuilder().maximumSize(100).expireAfterWrite(java.time.Duration.ofMinutes(10)).build(); + + public static void list(Context ctx) { + // List files and folders in the given path (relative to ISO_DIR), or search recursively if 'search' param is present + String relPath = ctx.queryParam("path"); + if (relPath == null || relPath.isEmpty()) relPath = "/"; + String searchTerm = ctx.queryParam("search"); + try { + var classLoader = Thread.currentThread().getContextClassLoader(); + String fullPath = getFullPath(relPath); + var resource = classLoader.getResource(fullPath); + if (resource == null) { + ctx.status(404).json(new String[]{"Directory not found"}); + return; + // Helper: construct the full resource path from isoDir and relPath + private static String getFullPath(String relPath) { + String basePath = isoDir.endsWith("/") ? isoDir : isoDir + "/"; + return basePath + (relPath.startsWith("/") ? relPath.substring(1) : relPath); + } + } + File dir = new File(resource.toURI()); + if (!dir.exists() || !dir.isDirectory()) { + ctx.status(404).json(new String[]{"Not a directory"}); + return; + } + + List> result; + if (searchTerm != null && !searchTerm.isEmpty()) { + String cacheKey = relPath + "|" + searchTerm.toLowerCase(); + result = searchCache.getIfPresent(cacheKey); + if (result == null) { + result = new ArrayList<>(); + recursiveSearch(dir, relPath, searchTerm.toLowerCase(), result); + searchCache.put(cacheKey, result); + } + } else { + File[] files = dir.listFiles(); + if (files == null) { + ctx.json(new Object[0]); + return; + } + result = new ArrayList<>(); + for (File f : files) { + var entry = new HashMap(); + entry.put("name", f.getName()); + entry.put("isDir", f.isDirectory()); + result.add(entry); + } + } + ctx.json(result); + } catch (Exception e) { + ctx.status(500).json(new String[]{"Error: " + e.getMessage()}); + } + } + + // Helper: recursively search for files/folders containing searchTerm, add to result with relative path + private static void recursiveSearch(File dir, String relPath, String searchTerm, List> result) { + File[] files = dir.listFiles(); + if (files == null) return; + for (File f : files) { + String rel = relPath.endsWith("/") ? relPath + f.getName() : relPath + "/" + f.getName(); + if (f.getName().toLowerCase().contains(searchTerm)) { + var entry = new HashMap(); + entry.put("name", f.getName()); + entry.put("isDir", f.isDirectory()); + entry.put("path", rel); + result.add(entry); + } + if (f.isDirectory()) { + recursiveSearch(f, rel, searchTerm, result); + } + } + } + + public static void handle(Context ctx) throws IOException { + // Download a file from a given path (relative to ISO_DIR) + String relPath = ctx.queryParam("path"); + if (relPath == null || relPath.isEmpty()) { + ctx.status(400).result("Missing file path"); + return; + } + try { + var classLoader = Thread.currentThread().getContextClassLoader(); + String basePath = isoDir.endsWith("/") ? isoDir : isoDir + "/"; + String fullPath = basePath + (relPath.startsWith("/") ? relPath.substring(1) : relPath); + var resource = classLoader.getResource(fullPath); + if (resource == null) { + ctx.status(404).result("Not Found!"); + return; + } + File file = new File(resource.toURI()); + if (!file.exists() || !file.isFile()) { + ctx.status(404).result("Not Found!"); + return; + } + int totalMB = EnvLoader.getMaxBandwitdh(); + int active = activeDownloaders.incrementAndGet(); + int perClickKB = (totalMB * 1024) / active; + try (InputStream fis = new FileInputStream(file); + OutputStream os = ctx.res().getOutputStream()) { + ctx.res().setHeader("Content-Disposition", "attachment; filename=\"" + file.getName() + "\""); + ctx.res().setContentType("application/octet-stream"); + byte[] buf = new byte[8192]; + int read; + long lastSent = System.nanoTime(); + long byteSent = 0; + int throttleRate = perClickKB * 1024; + while ((read = fis.read(buf)) != -1) { + os.write(buf, 0, read); + byteSent += read; + long elapsedNs = System.nanoTime() - lastSent; + double elapsedSec = elapsedNs / 1_000_000_000.0; + double currentRate = byteSent / elapsedSec; + if (currentRate > throttleRate) { + long sleepTime = (long) ((byteSent / (double) throttleRate - elapsedSec) * 1000); + if (sleepTime > 0) { + try { + Thread.sleep(sleepTime); + } catch (InterruptedException ignored) { + // do nothing :3 + } + } + } + } + } finally { + activeDownloaders.decrementAndGet(); + } + } catch (Exception e) { + ctx.status(500).result("Error: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/filejet/API/util/EnvLoader.java b/src/main/java/com/filejet/API/util/EnvLoader.java new file mode 100644 index 0000000..74d5d31 --- /dev/null +++ b/src/main/java/com/filejet/API/util/EnvLoader.java @@ -0,0 +1,29 @@ +package com.filejet.API.util; + +// we load the env file from resources here + +import io.github.cdimascio.dotenv.Dotenv; + +public class EnvLoader { + //set dotenv up to use + private static final Dotenv dotenv = Dotenv.configure() + .directory("src/main/resources") + .load(); + + public static String get(String key) { + return dotenv.get(key); + } + + public static int getPort() { + // return a plain int (ports aren't float) + return Integer.parseInt(get("PORT")); + } + + public static String getISODir() { + return get("ISO_DIR"); + } + + public static int getMaxBandwitdh() { + return Integer.parseInt(get("MAX_TOTAL_BANDWIDTH_MB")); + } +} diff --git a/src/main/resources/Content/info.md b/src/main/resources/Content/info.md new file mode 100644 index 0000000..006cea8 --- /dev/null +++ b/src/main/resources/Content/info.md @@ -0,0 +1,9 @@ +store your files here for the webserver to host: + +.env should be in resources and look like this: + +```ruby +PORT=8080 +ISO_DIR=Content/ +MAX_TOTAL_BANDWIDTH_MB=10 +``` \ No newline at end of file diff --git a/src/main/resources/HTML/index.html b/src/main/resources/HTML/index.html new file mode 100644 index 0000000..7ec8e7b --- /dev/null +++ b/src/main/resources/HTML/index.html @@ -0,0 +1,45 @@ + + + + + + + + FileJet + + + + + -- FileJet -- + + + ◀ Back + Forward ▶ + ↑ Up + + + + + + + + + NAME + TYPE + SIZE + MODIFIED + ACTION + + + + + + + Ready + + ↑↓: Navigate | ENTER: Open | ESC: Deselect | TAB: Focus controls + + + + + diff --git a/src/main/resources/HTML/script.js b/src/main/resources/HTML/script.js new file mode 100644 index 0000000..49a9fff --- /dev/null +++ b/src/main/resources/HTML/script.js @@ -0,0 +1,267 @@ +document.addEventListener('DOMContentLoaded', () => { + const fileList = document.getElementById('file-list'); + const backBtn = document.getElementById('back-btn'); + const forwardBtn = document.getElementById('forward-btn'); + const upBtn = document.getElementById('up-btn'); + const addressInput = document.getElementById('address-input'); + const searchInput = document.getElementById('search-input'); + const statusText = document.getElementById('status-text'); + + let history = ['/']; + let historyIndex = 0; + let forwardStack = []; + let selectedIndex = -1; + let currentItems = []; + + function getCurrentPath() { + return history[historyIndex]; + } + + function setAddressBar() { + let p = getCurrentPath(); + addressInput.value = p === '/' ? 'Computer' : p; + } + + function updateSelection() { + const items = fileList.querySelectorAll('.file-item'); + items.forEach((item, index) => { + item.classList.toggle('focused', index === selectedIndex); + }); + } + + function selectItem(index) { + const items = fileList.querySelectorAll('.file-item'); + if (index >= 0 && index < items.length) { + selectedIndex = index; + updateSelection(); + // Scroll into view + items[selectedIndex].scrollIntoView({ block: 'nearest' }); + + // Update status + if (currentItems[selectedIndex]) { + const item = currentItems[selectedIndex]; + statusText.textContent = `Selected: ${item.name} (${item.isDir ? 'Folder' : 'File'})`; + } + } + } + + function openSelectedItem() { + if (selectedIndex >= 0 && selectedIndex < currentItems.length) { + const item = currentItems[selectedIndex]; + if (item.isDir) { + let cur = getCurrentPath(); + let next = cur.endsWith('/') ? cur + item.name : cur + '/' + item.name; + history = history.slice(0, historyIndex + 1); + history.push(next); + historyIndex++; + forwardStack = []; + selectedIndex = -1; + fetchFiles(); + } else { + // Download file + let cur = getCurrentPath(); + let filePath = cur.endsWith('/') ? cur + item.name : cur + '/' + item.name; + window.location.href = `/api/download?path=${encodeURIComponent(filePath)}`; + } + } + } + + function fetchFiles(searchTerm = '') { + setAddressBar(); + statusText.textContent = 'Loading...'; + let path = getCurrentPath(); + let url = `/api/list-isos?path=${encodeURIComponent(path)}`; + if (searchTerm) { + url += `&search=${encodeURIComponent(searchTerm)}`; + } + fetch(url) + .then(res => { + if (!res.ok) throw new Error('Failed to fetch file list'); + return res.json(); + }) + .then(items => { + fileList.innerHTML = ''; + currentItems = []; + selectedIndex = -1; + if (!items.length) { + fileList.innerHTML = 'No files or folders found.'; + statusText.textContent = 'No items found'; + return; + } + currentItems = items; + items.forEach((item, index) => { + const div = document.createElement('div'); + div.className = 'file-item'; + div.dataset.index = index; + + // Icon + const icon = document.createElement('img'); + icon.className = 'icon'; + icon.src = item.isDir ? 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTIgM0MxLjQ0NzcyIDMgMSAzLjQ0NzcyIDEgNFYxMkMxIDEyLjU1MjMgMS40NDc3MiAxMyAyIDEzSDE0QzE0LjU1MjMgMTMgMTUgMTIuNTUyMyAxNSAxMlY2QzE1IDUuNDQ3NzIgMTQuNTUyMyA1IDE0IDVIOEw2IDNIMloiIGZpbGw9IiNGRkQ3MDAiLz4KPC9zdmc+' : 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTMgM0MyLjQ0NzcyIDMgMiAzLjQ0NzcyIDIgNFYxMkMyIDEyLjU1MjMgMi40NDc3MiAxMyAzIDEzSDEzQzEzLjU1MjMgMTMgMTQgMTIuNTUyMyAxNCAxMlY0QzE0IDMuNDQ3NzIgMTMuNTUyMyAzIDEzIDNIM1oiIGZpbGw9IiM4ODg4ODgiLz4KPC9zdmc+'; + icon.onerror = () => icon.style.display = 'none'; + div.appendChild(icon); + + // Name (show relative path if searching) + const nameSpan = document.createElement('span'); + nameSpan.className = 'file-name'; + if (searchTerm && item.path) { + nameSpan.textContent = item.path; + } else { + nameSpan.textContent = item.name; + } + div.appendChild(nameSpan); + + // Type + const typeSpan = document.createElement('span'); + typeSpan.className = 'file-type'; + typeSpan.textContent = item.isDir ? 'Folder' : 'File'; + div.appendChild(typeSpan); + + // Size + const sizeSpan = document.createElement('span'); + sizeSpan.className = 'file-size'; + sizeSpan.textContent = item.isDir ? '' : '-'; + div.appendChild(sizeSpan); + + // Date Modified + const dateSpan = document.createElement('span'); + dateSpan.className = 'file-date'; + dateSpan.textContent = ''; + div.appendChild(dateSpan); + + // Action + const actionDiv = document.createElement('div'); + if (!item.isDir) { + const dlBtn = document.createElement('button'); + dlBtn.className = 'download-btn'; + dlBtn.textContent = 'Download'; + dlBtn.onclick = (e) => { + e.stopPropagation(); + let cur = getCurrentPath(); + let filePath = cur.endsWith('/') ? cur + item.name : cur + '/' + item.name; + window.location.href = `/api/download?path=${encodeURIComponent(filePath)}`; + }; + actionDiv.appendChild(dlBtn); + } + div.appendChild(actionDiv); + + // Click handlers + div.onclick = () => { + selectItem(index); + if (item.isDir) { + setTimeout(() => openSelectedItem(), 200); + } + }; + + fileList.appendChild(div); + }); + + statusText.textContent = `${items.length} item${items.length !== 1 ? 's' : ''}`; + + // Update button states + backBtn.disabled = historyIndex <= 0; + forwardBtn.disabled = historyIndex >= history.length - 1; + upBtn.disabled = getCurrentPath() === '/'; + }) + .catch(err => { + fileList.innerHTML = `${err.message}`; + statusText.textContent = 'Error loading files'; + }); + } + + // Navigation event handlers + backBtn.addEventListener('click', () => { + if (historyIndex > 0) { + historyIndex--; + selectedIndex = -1; + fetchFiles(); + } + }); + + forwardBtn.addEventListener('click', () => { + if (historyIndex < history.length - 1) { + historyIndex++; + selectedIndex = -1; + fetchFiles(); + } + }); + + upBtn.addEventListener('click', () => { + let cur = getCurrentPath(); + if (cur === '/' || cur === '') return; + let parts = cur.split('/').filter(Boolean); + parts.pop(); + let upPath = '/' + parts.join('/'); + if (upPath === '') upPath = '/'; + history = history.slice(0, historyIndex + 1); + history.push(upPath); + historyIndex++; + selectedIndex = -1; + fetchFiles(); + }); + + addressInput.addEventListener('click', () => { + addressInput.select(); + }); + + searchInput.addEventListener('input', (e) => { + selectedIndex = -1; + fetchFiles(e.target.value); + }); + + // Keyboard navigation + document.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT') return; + + switch(e.key) { + case 'ArrowUp': + e.preventDefault(); + if (selectedIndex > 0) { + selectItem(selectedIndex - 1); + } else if (currentItems.length > 0) { + selectItem(currentItems.length - 1); + } + break; + + case 'ArrowDown': + e.preventDefault(); + if (selectedIndex < currentItems.length - 1) { + selectItem(selectedIndex + 1); + } else if (currentItems.length > 0) { + selectItem(0); + } + break; + + case 'Enter': + e.preventDefault(); + openSelectedItem(); + break; + + case 'Escape': + e.preventDefault(); + selectedIndex = -1; + updateSelection(); + statusText.textContent = `${currentItems.length} item${currentItems.length !== 1 ? 's' : ''}`; + break; + + case 'Backspace': + e.preventDefault(); + upBtn.click(); + break; + + case 'F5': + e.preventDefault(); + selectedIndex = -1; + fetchFiles(searchInput.value); + break; + + case '/': + e.preventDefault(); + searchInput.focus(); + break; + } + }); + + // Initial load + fetchFiles(); +}); \ No newline at end of file diff --git a/src/main/resources/HTML/styles.css b/src/main/resources/HTML/styles.css new file mode 100644 index 0000000..17ef31c --- /dev/null +++ b/src/main/resources/HTML/styles.css @@ -0,0 +1,241 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: #000; + color: #eee; + font-family: 'Courier New', monospace; + font-size: 15px; + line-height: 1.6; + height: 100vh; + overflow: hidden; +} + +.terminal { + display: flex; + flex-direction: column; + height: 100vh; + background: #000; + border: 2px solid #333; + padding: 20px; + gap: 16px; +} + +.header { + border-bottom: 1px solid #333; + padding-bottom: 10px; + margin-bottom: 10px; +} + +.title-bar { + font-weight: bold; + text-align: center; + font-size: 18px; + margin-bottom: 10px; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.btn { + background: #222; + color: #fff; + border: 1px solid #444; + padding: 6px 12px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; + border-radius: 4px; +} + +.btn:hover:not(:disabled) { + background: #444; + border-color: #666; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn:focus { + outline: 1px solid #fff; + outline-offset: 2px; +} + +.address-bar, +.search-bar { + background: #111; + color: #fff; + border: 1px solid #333; + padding: 6px 10px; + font-size: 13px; + border-radius: 4px; +} + +.address-bar { + flex: 1; + min-width: 240px; +} + +.search-bar { + width: 160px; +} + +.address-bar:focus, +.search-bar:focus { + outline: 1px solid #fff; + background: #222; +} + +.main-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + gap: 10px; +} + +.file-header { + display: grid; + grid-template-columns: 30px 1fr 80px 100px 120px 100px; + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid #333; + font-weight: bold; + background: #111; + font-size: 14px; +} + +.file-list { + flex: 1; + overflow-y: auto; + background: #000; + border: 1px solid #333; + border-radius: 4px; +} + +.file-item { + display: grid; + grid-template-columns: 30px 1fr 80px 100px 120px 100px; + gap: 12px; + padding: 8px 0; + background: #181818; + align-items: center; + cursor: pointer; +} + +.file-item:hover { + background: #111; +} + +.file-item.selected { + background: #222; +} + +.file-item.focused { + background: #333; + outline: 1px solid #fff; +} + +.icon { + width: 16px; + height: 16px; + justify-self: center; +} + +.file-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-type, +.file-size, +.file-date { + font-size: 13px; + color: #aaa; +} + +.download-btn { + background: #222; + color: #fff; + border: 1px solid #555; + padding: 3px 8px; + font-size: 12px; + border-radius: 3px; + cursor: pointer; +} + +.download-btn:hover { + background: #444; +} + +.status-bar { + border-top: 1px solid #333; + padding: 8px 0; + font-size: 12px; + color: #888; + display: flex; + justify-content: space-between; +} + +.help-text { + font-size: 11px; + color: #888; +} + +/* Scrollbar styling */ +.file-list::-webkit-scrollbar { + width: 10px; +} + +.file-list::-webkit-scrollbar-track { + background: #111; +} + +.file-list::-webkit-scrollbar-thumb { + background: #333; + border: 1px solid #555; + border-radius: 6px; +} + +.file-list::-webkit-scrollbar-thumb:hover { + background: #555; +} + +/* Terminal cursor effect */ +@keyframes blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} + +.cursor::after { + content: '█'; + color: #fff; + animation: blink 1s infinite; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .file-header, + .file-item { + grid-template-columns: 24px 1fr 60px 80px; + font-size: 13px; + } + + .file-header :nth-child(5), + .file-header :nth-child(6), + .file-item :nth-child(5), + .file-item :nth-child(6) { + display: none; + } +} +