diff --git a/.air.toml b/.air.toml index 932ef5d..fb86af2 100644 --- a/.air.toml +++ b/.air.toml @@ -2,7 +2,7 @@ root = "." tmp_dir = "tmp" [build] -args_bin = ["--port", "4321"] +args_bin = ["--port", "4321", "--path", "~/Downloads/portal"] bin = "./tmp/main" cmd = "go build -o ./tmp/main ." delay = 1000 @@ -13,7 +13,7 @@ exclude_unchanged = false follow_symlink = false full_bin = "" include_dir = [".", "public"] -include_ext = ["go", "html", "css", "js"] +include_ext = ["go", "html", "css", "js", "webp", "svg"] include_file = ["main.go"] kill_delay = "1s" log = "build-errors.log" diff --git a/main.go b/main.go index ec4e1b5..41a6a46 100644 --- a/main.go +++ b/main.go @@ -9,14 +9,26 @@ import ( "net" "net/http" "os" + "path/filepath" + "time" ) const ( chunkSize = 1024 * 1024 // 1MB ) -var upgrader = websocket.Upgrader{ - ReadBufferSize: chunkSize, +var ( + upgrader = websocket.Upgrader{ + ReadBufferSize: chunkSize, + } + wd string +) + +func init() { + var err error + if wd, err = os.Getwd(); err != nil { + log.Fatal("failed to get working directory", "err", err) + } } type ( @@ -25,6 +37,8 @@ type ( Name string `json:"name"` // Size is the size of the file in bytes. Size int `json:"size"` + // LastModified is the last modified time of the file. + LastModified int64 `json:"lastModified"` } ) @@ -45,12 +59,27 @@ func wsHandler(w http.ResponseWriter, r *http.Request) { } log.Info("received header", "header", header) - file, err := os.Create(header.Name) + p := filepath.Join(wd, header.Name) + + // check if file is inside wd + if relPath, err := filepath.Rel(wd, p); err != nil || relPath == ".." || relPath[:2] == ".." { + log.Error("file is outside working directory", "path", p) + return + } + + // create parent directories + if err := os.MkdirAll(filepath.Dir(p), 0777); err != nil { + log.Error("failed to create parent directories", "err", err) + return + } + + // create file + f, err := os.Create(p) if err != nil { log.Error("failed to create file", "err", err) return } - defer file.Close() + defer f.Close() // Send READY signal to start receiving file chunks err = conn.WriteMessage(websocket.TextMessage, []byte("READY")) @@ -76,7 +105,7 @@ func wsHandler(w http.ResponseWriter, r *http.Request) { } if messageType == websocket.BinaryMessage { - _, err = file.Write(p) + _, err = f.Write(p) if err != nil { log.Error("failed to write to file", "err", err) return @@ -90,6 +119,12 @@ func wsHandler(w http.ResponseWriter, r *http.Request) { } } + // set last modified time + lastModified := time.UnixMilli(header.LastModified) + if err := os.Chtimes(p, lastModified, lastModified); err != nil { + log.Error("failed to set last modified time", "err", err) + } + // send EOF to client to signal that the file has been received err = conn.WriteMessage(websocket.TextMessage, []byte("EOF")) if err != nil { @@ -119,28 +154,26 @@ func getPublicIP() (string, error) { func main() { port := flag.Int("port", 0, "port to listen on") + path := flag.String("path", ".", "path to save files") flag.Parse() - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/backdrop.webp": - w.Header().Set("Content-Type", "image/webp") - _, _ = w.Write(public.BackdropWebP) - case "/favicon.svg": - w.Header().Set("Content-Type", "image/svg+xml") - _, _ = w.Write(public.FaviconSVG) - case "/index.css": - w.Header().Set("Content-Type", "text/css") - _, _ = w.Write(public.IndexCSS) - case "/": - w.Header().Set("Content-Type", "text/html") - _, _ = w.Write(public.IndexHTML) - case "/index.js": - w.Header().Set("Content-Type", "application/javascript") - _, _ = w.Write(public.IndexJS) - default: - http.NotFound(w, r) + if *path != "." { + if err := os.MkdirAll(*path, 0777); err != nil { + log.Fatal("failed to create directory", "path", path, "err", err) + return + } + if err := os.Chdir(*path); err != nil { + log.Fatal("failed to change directory", "err", err) + return + } + var err error + if wd, err = os.Getwd(); err == nil { + log.Info("working directory", "path", wd) } + } + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.FileServerFS(public.Fs).ServeHTTP(w, r) }) http.HandleFunc("/ws", wsHandler) diff --git a/public/favicon.svg b/public/favicon.svg index a7a21c3..2702211 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1,2 +1,2 @@ -magic portalwaypoint,magic portal,mapfreepz73bu \ No newline at end of file + diff --git a/public/file-earmark-binary.svg b/public/file-earmark-binary.svg new file mode 100644 index 0000000..8b4d1d3 --- /dev/null +++ b/public/file-earmark-binary.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/file-earmark-code.svg b/public/file-earmark-code.svg new file mode 100644 index 0000000..5ca896e --- /dev/null +++ b/public/file-earmark-code.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/file-earmark-font.svg b/public/file-earmark-font.svg new file mode 100644 index 0000000..4c4e9fb --- /dev/null +++ b/public/file-earmark-font.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/file-earmark-image.svg b/public/file-earmark-image.svg new file mode 100644 index 0000000..fdf8a68 --- /dev/null +++ b/public/file-earmark-image.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/file-earmark-music.svg b/public/file-earmark-music.svg new file mode 100644 index 0000000..3867db2 --- /dev/null +++ b/public/file-earmark-music.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/file-earmark-pdf.svg b/public/file-earmark-pdf.svg new file mode 100644 index 0000000..2e2b282 --- /dev/null +++ b/public/file-earmark-pdf.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/file-earmark-play.svg b/public/file-earmark-play.svg new file mode 100644 index 0000000..3cd35d4 --- /dev/null +++ b/public/file-earmark-play.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/file-earmark-text.svg b/public/file-earmark-text.svg new file mode 100644 index 0000000..1f6157d --- /dev/null +++ b/public/file-earmark-text.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/file-earmark.svg b/public/file-earmark.svg new file mode 100644 index 0000000..5400b1c --- /dev/null +++ b/public/file-earmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/index.css b/public/index.css index 71e217b..e43dd80 100644 --- a/public/index.css +++ b/public/index.css @@ -46,13 +46,14 @@ main { main::before { content: "Drop files here"; + filter: drop-shadow(0 0 1rem black); position: fixed; display: block; - font-family: "Garamond", "Bookman Old Style", "Georgia", "Times New Roman", serif; + font-family: "Fondamento", "Garamond", "Bookman Old Style", "Georgia", "Times New Roman", cursive; top: 5lvh; left: 0; width: 100dvw; - font-size: 2rem; + font-size: 3rem; font-weight: bold; text-align: center; } @@ -61,7 +62,7 @@ main::before { content: "Sending..."; } -.ring, .particle, main::before { +.ring, .particle, .file, main::before { pointer-events: none; } @@ -135,7 +136,7 @@ main::before { } .sending .ring { - background: radial-gradient(closest-side, pink, rgba(var(--magic), 1)); + background: radial-gradient(closest-side, rgba(255, 200, 230, 0.25), rgba(var(--magic), 1)); } .particle { @@ -162,7 +163,7 @@ main::before { opacity: 1; } 100% { - transform: translate(50vmin, 10vmin) scale(1); + transform: translate(50vmax, 10vmax) scale(1); opacity: 0; } } @@ -176,7 +177,7 @@ main::before { opacity: 1; } 100% { - transform: translate(-50vmin, -13vmin) scale(1); + transform: translate(-50vmax, -13vmax) scale(1); opacity: 0; } } @@ -190,7 +191,7 @@ main::before { opacity: 1; } 100% { - transform: translate(-10vmin, 50vmin) scale(1); + transform: translate(-10vmax, 50vmax) scale(1); opacity: 0; } } @@ -204,7 +205,7 @@ main::before { opacity: 1; } 100% { - transform: translate(13vmin, -50vmin) scale(1); + transform: translate(13vmax, -50vmax) scale(1); opacity: 0; } } @@ -218,7 +219,7 @@ main::before { opacity: 1; } 100% { - transform: translate(-2vmin, -40vmin) scale(1); + transform: translate(-10vmax, -25vmax) scale(1); opacity: 0; } } @@ -232,7 +233,7 @@ main::before { opacity: 1; } 100% { - transform: translate(40vmin, 2vmin) scale(1); + transform: translate(25vmax, 10vmax) scale(1); opacity: 0; } } @@ -246,7 +247,7 @@ main::before { opacity: 1; } 100% { - transform: translate(-40vmin, -2vmin) scale(1); + transform: translate(-25vmax, -10vmax) scale(1); opacity: 0; } } @@ -260,7 +261,7 @@ main::before { opacity: 1; } 100% { - transform: translate(2vmin, 40vmin) scale(1); + transform: translate(10vmax, 25vmax) scale(1); opacity: 0; } } @@ -306,11 +307,38 @@ main::before { } .particle { + animation-direction: normal; animation-duration: 3s; } +.drag-over .particle { + animation-direction: reverse; + animation-duration: 2s; +} .sending .particle { animation-direction: reverse; animation-duration: 1s; } + +#file-icons { + z-index: 1000; + position: fixed; + top: 0; + left: 0; + padding: min(10vmin, 128px); + width: 100lvw; + height: 100lvh; + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 1vmin; + pointer-events: none; +} + +#file-icons .file { + display: block; + width: 5vmin; + aspect-ratio: auto; +} diff --git a/public/index.html b/public/index.html index 826792b..3913299 100644 --- a/public/index.html +++ b/public/index.html @@ -3,14 +3,18 @@ - Portal + + + +
- + +
@@ -22,6 +26,8 @@
+ +
diff --git a/public/index.js b/public/index.js index 8ed1ead..de2ed63 100644 --- a/public/index.js +++ b/public/index.js @@ -1,4 +1,18 @@ const chunkSize = 1024 * 1024; // 1MB chunks +const fileIcons = document.getElementById("file-icons"); +let filesToSend = 0; + +window.setInterval(() => { + if (filesToSend === 0) { + document.body.classList.remove("sending"); + } else { + document.body.classList.add("sending"); + } +}, 1000) + +if (!fileIcons) { + throw new Error("file-icons not found"); +} /** @returns {Promise} */ function getWs() { @@ -9,18 +23,13 @@ function getWs() { const ws = new WebSocket(url.href); ws.addEventListener("open", function () { - console.log("WS Connected"); resolve(ws); - }) - - ws.addEventListener("close", function () { - console.log("WS Disconnected"); - }) + }); ws.addEventListener("error", function (error) { console.error("WS Error:", error); reject(error); - }) + }); }); } @@ -33,18 +42,20 @@ fileInput.addEventListener("change", async function () { document.body.classList.add("sending"); for (const file of fileInput.files) { - try { - await sendFile(file); - } catch (err) { - console.error("Error sending file:", err); - } + await sendFile(file); } - document.body.classList.remove("sending"); + cleanupFileVis(); fileInput.value = ""; }); -/** @param {File} file */ -const sendFile = (file) => new Promise(async (resolve, reject) => { +/** + * @param {File} file + * @param {string} overwritePath + * @returns {Promise} + */ +const sendFile = (file, overwritePath = file.webkitRelativePath ?? file.name) => new Promise(async (resolve, reject) => { + console.debug("sendFile", {file, overwritePath}); + filesToSend++; const ws = await getWs(); const fileReader = new FileReader(); let offset = 0; @@ -52,13 +63,20 @@ const sendFile = (file) => new Promise(async (resolve, reject) => { ws.onmessage = function (event) { switch (event.data) { case "READY": + if (offset === 0) { + resolve(); + } + updateFileVis(file, offset / file.size); readChunk(file); break; case "EOF": - resolve(); + removeFileVis(file); break; default: + console.error("unexpected message from server:", event.data); reject(event.data); + removeFileVis(file); + break; } }; @@ -76,29 +94,37 @@ const sendFile = (file) => new Promise(async (resolve, reject) => { offset += e.target.result.byteLength; }; - const header = composeHeader(file); + const header = composeHeader(file, overwritePath); ws.send(header); + createFileVis(file) }); -/** @param file {File} */ -function composeHeader(file) { +/** + * @param file {File} + * @param name {string} + * @returns {string} + */ +function composeHeader(file, name) { const header = { - name: file.name, + name: name || file.webkitRelativePath || file.name, size: file.size, + lastModified: file.lastModified, }; return JSON.stringify(header); } -document.body.addEventListener("dragenter", handleDragEnter); +document.body.addEventListener("dragenter", handleDragEnter, {passive: true}); document.body.addEventListener("dragover", handleDragOver); -document.body.addEventListener("dragleave", handleDragLeave); +document.body.addEventListener("dragleave", handleDragLeave, {passive: true}); document.body.addEventListener("drop", handleDrop); function handleDragEnter() { document.body.classList.add("drag-over"); } -function handleDragOver() { +/** @param {DragEvent} ev */ +function handleDragOver(ev) { + ev.preventDefault(); document.body.classList.add("drag-over"); } @@ -106,6 +132,169 @@ function handleDragLeave() { document.body.classList.remove("drag-over"); } -function handleDrop() { +/** @param {DragEvent} ev */ +async function handleDrop(ev) { + ev.stopPropagation(); + ev.preventDefault(); document.body.classList.remove("drag-over"); + document.body.classList.add("sending"); + + const items = Array.from(ev.dataTransfer.items).map((item) => ({ + item, + entry: item.webkitGetAsEntry(), + })); + + for (const i of items) { + if (i.entry) { + if (i.entry.isFile) { + try { + const file = await getFileFromEntry(i.entry); + await sendFile(file); + } catch (err) { + console.error("Failed to process file entry:", i.entry, err); + } + } else if (i.entry.isDirectory) { + try { + await readDirectoryRecursively(i.entry); + } catch (err) { + console.error("Failed to process directory entry:", i.entry, err); + } + } else { + console.error("Unsupported entry type:", i.entry); + } + } else { + console.error("Failed to get entry from item", i); + } + } + + cleanupFileVis(); +} + +/** Helper function to get file from entry */ +async function getFileFromEntry(entry) { + return new Promise((resolve, reject) => { + entry.file(resolve, reject); + }); +} + +/** Recursively read a directory entry */ +async function readDirectoryRecursively(directoryEntry) { + const reader = directoryEntry.createReader(); + const entries = await readAllEntries(reader); + + for (const entry of entries) { + if (entry.isFile) { + try { + const file = await getFileFromEntry(entry); + await sendFile(file, entry.fullPath); + } catch (err) { + console.error("Failed to process file within directory:", entry, err); + } + } else if (entry.isDirectory) { + await readDirectoryRecursively(entry); + } + } +} + +/** Utility function to read all entries from a directory */ +function readAllEntries(reader) { + return new Promise((resolve, reject) => { + const entries = []; + + function readEntries() { + reader.readEntries((results) => { + if (!results.length) { + resolve(entries); + } else { + entries.push(...results); + readEntries(); // Continue reading until all entries are read + } + }, reject); + } + + readEntries(); + }); +} + +/** @param {File} file */ +function createFileVis(file) { + const fileEl = document.createElement("img") + fileEl.classList.add("file") + fileEl.src = getIcon(file) + fileEl.id = file.webkitRelativePath || file.name + fileIcons.appendChild(fileEl) + return fileEl +} + +const fontExtensions = new Set(["ttf", "otf", "woff", "woff2"]); +const codeExtensions = new Set(["go", "rs", "ts", "js", "tsx", "jsx", "astro", "json", "json5", "jsonc", "yaml", "yml", "toml", "java", "kt", "gradle", "swift", "c", "cc", "cpp", "h", "hpp", "cs", "fs", "vb", "py", "rb", "r", "pl", "php", "php5", "lua", "sh", "ps1", "editorconfig", "gitignore", "md", "tex", "bib"]); + +function getIcon(file) { + const ext = file.name.split(".").pop() + if (fontExtensions.has(ext)) { + return "/file-earmark-font.svg" + } + if (codeExtensions.has(ext)) { + return "/file-earmark-code.svg" + } + if (ext === "pdf") { + return "/file-earmark-pdf.svg" + } + + const mime = file.type + if (mime.startsWith("image")) { + return "/file-earmark-image.svg" + } + if (mime.startsWith("audio")) { + return "/file-earmark-music.svg" + } + if (mime.startsWith("video")) { + return "/file-earmark-play.svg" + } + if (mime.startsWith("text")) { + return "/file-earmark-text.svg" + } + if (mime.startsWith("application")) { + return "/file-earmark-binary.svg" + } + + return "/file-earmark.svg" +} + +/** + * @param file {File} + * @param progress {number} + */ +function updateFileVis(file, progress) { + let fileEl = document.getElementById(file.webkitRelativePath || file.name) + if (!fileEl) { + fileEl = createFileVis(file) + } + fileEl.style.opacity = clamp(0, 1 - progress, 1).toFixed(2); +} + +function removeFileVis(file) { + const fileEl = document.getElementById(file.webkitRelativePath || file.name) + if (fileEl) { + filesToSend--; + fileEl.remove() + } +} + +function cleanupFileVis() { + const files = Array.from(document.getElementsByClassName("file")) + + for (const file of files) { + file.remove() + } +} + +/** + * @param min {number} + * @param value {number} + * @param max {number} + * @returns {number} + */ +function clamp(min, value, max) { + return Math.min(Math.max(value, min), max) } diff --git a/public/public.go b/public/public.go index 905ed03..f698a40 100644 --- a/public/public.go +++ b/public/public.go @@ -1,22 +1,10 @@ package public import ( - _ "embed" + "embed" ) var ( - //go:embed backdrop.webp - BackdropWebP []byte - - //go:embed favicon.svg - FaviconSVG []byte - - //go:embed index.css - IndexCSS []byte - - //go:embed index.html - IndexHTML []byte - - //go:embed index.js - IndexJS []byte + //go:embed * + Fs embed.FS )