WarpBox Explorer - {{ .BoxID }}
+ +/box/{{ .BoxID }}
+ This box is empty.
+ {{ end }} +diff --git a/lib/server/server.go b/lib/server/server.go index ec8a6d1..937e6d2 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -1,11 +1,15 @@ package server import ( + "archive/zip" "crypto/rand" "encoding/hex" "fmt" + "io" + "mime" "mime/multipart" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -16,6 +20,15 @@ import ( const uploadRoot = "data/uploads" +type boxFile struct { + Name string + Size int64 + SizeLabel string + MimeType string + IconPath string + DownloadPath string +} + func Run(addr string) error { router := gin.Default() router.LoadHTMLGlob("templates/*.html") @@ -24,6 +37,76 @@ func Run(addr string) error { ctx.HTML(http.StatusOK, "index.html", gin.H{}) }) + router.GET("/box/:id", func(ctx *gin.Context) { + boxID := ctx.Param("id") + if !validBoxID(boxID) { + ctx.String(http.StatusBadRequest, "Invalid box id") + return + } + + files, err := listBoxFiles(boxID) + if err != nil { + ctx.String(http.StatusNotFound, "Box not found") + return + } + + ctx.HTML(http.StatusOK, "box.html", gin.H{ + "BoxID": boxID, + "Files": files, + "FileCount": len(files), + "DownloadAll": "/box/" + boxID + "/download", + }) + }) + + router.GET("/box/:id/download", func(ctx *gin.Context) { + boxID := ctx.Param("id") + if !validBoxID(boxID) { + ctx.String(http.StatusBadRequest, "Invalid box id") + return + } + + files, err := listBoxFiles(boxID) + if err != nil { + ctx.String(http.StatusNotFound, "Box not found") + return + } + + ctx.Header("Content-Type", "application/zip") + ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID)) + + zipWriter := zip.NewWriter(ctx.Writer) + defer zipWriter.Close() + + for _, file := range files { + if err := addFileToZip(zipWriter, boxID, file.Name); err != nil { + ctx.Status(http.StatusInternalServerError) + return + } + } + }) + + router.GET("/box/:id/files/:filename", func(ctx *gin.Context) { + boxID := ctx.Param("id") + filename, ok := safeFilename(ctx.Param("filename")) + if !validBoxID(boxID) || !ok { + ctx.String(http.StatusBadRequest, "Invalid file") + return + } + + path := filepath.Join(boxPath(boxID), filename) + if !strings.HasPrefix(path, boxPath(boxID)+string(filepath.Separator)) { + ctx.String(http.StatusBadRequest, "Invalid file") + return + } + + if _, err := os.Stat(path); err != nil { + ctx.String(http.StatusNotFound, "File not found") + return + } + + ctx.FileAttachment(path, filename) + }) + router.POST("/box", func(ctx *gin.Context) { boxID, err := newBoxID() if err != nil { @@ -144,6 +227,55 @@ func boxPath(boxID string) string { return filepath.Join(uploadRoot, boxID) } +func listBoxFiles(boxID string) ([]boxFile, error) { + entries, err := os.ReadDir(boxPath(boxID)) + if err != nil { + return nil, err + } + + files := make([]boxFile, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + + info, err := entry.Info() + if err != nil { + return nil, err + } + + name := entry.Name() + mimeType := mimeTypeForFile(filepath.Join(boxPath(boxID), name), name) + files = append(files, boxFile{ + Name: name, + Size: info.Size(), + SizeLabel: formatBytes(info.Size()), + MimeType: mimeType, + IconPath: iconForMimeType(mimeType, name), + DownloadPath: "/box/" + boxID + "/files/" + url.PathEscape(name), + }) + } + + return files, nil +} + +func addFileToZip(zipWriter *zip.Writer, boxID string, filename string) error { + path := filepath.Join(boxPath(boxID), filename) + source, err := os.Open(path) + if err != nil { + return err + } + defer source.Close() + + destination, err := zipWriter.Create(filename) + if err != nil { + return err + } + + _, err = io.Copy(destination, source) + return err +} + func saveUploadedFile(ctx *gin.Context, boxID string, file *multipart.FileHeader) (gin.H, error) { filename, ok := safeFilename(file.Filename) if !ok { @@ -187,3 +319,65 @@ func uniqueFilename(directory string, filename string) string { } } } + +func mimeTypeForFile(path string, filename string) string { + if mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename))); mimeType != "" { + return mimeType + } + + file, err := os.Open(path) + if err != nil { + return "application/octet-stream" + } + defer file.Close() + + buffer := make([]byte, 512) + bytesRead, err := file.Read(buffer) + if err != nil && err != io.EOF { + return "application/octet-stream" + } + + return http.DetectContentType(buffer[:bytesRead]) +} + +func iconForMimeType(mimeType string, filename string) string { + extension := strings.ToLower(filepath.Ext(filename)) + + switch { + case strings.HasPrefix(mimeType, "image/"): + return "/static/img/sprites/bitmap.png" + case strings.HasPrefix(mimeType, "video/"): + return "/static/img/icons/netshow_notransm-1.png" + case strings.HasPrefix(mimeType, "audio/"): + return "/static/img/icons/netshow_notransm-1.png" + case strings.HasPrefix(mimeType, "text/") || extension == ".md": + return "/static/img/sprites/notepad_file-1.png" + case strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "compressed") || extension == ".rar" || extension == ".7z" || extension == ".tar" || extension == ".gz": + return "/static/img/icons/Windows Icons - PNG/zipfldr.dll_14_101-0.png" + case extension == ".ttf" || extension == ".otf" || extension == ".woff" || extension == ".woff2": + return "/static/img/sprites/font.png" + case extension == ".pdf": + return "/static/img/sprites/journal.png" + case extension == ".html" || extension == ".css" || extension == ".js": + return "/static/img/sprites/frame_web-0.png" + default: + return "/static/img/sprites/freepad.png" + } +} + +func formatBytes(bytes int64) string { + units := []string{"B", "KB", "MB", "GB"} + size := float64(bytes) + unitIndex := 0 + + for size >= 1024 && unitIndex < len(units)-1 { + size /= 1024 + unitIndex++ + } + + if unitIndex == 0 { + return fmt.Sprintf("%d %s", bytes, units[unitIndex]) + } + + return fmt.Sprintf("%.1f %s", size, units[unitIndex]) +} diff --git a/static/css/box.css b/static/css/box.css new file mode 100644 index 0000000..4d3fd14 --- /dev/null +++ b/static/css/box.css @@ -0,0 +1,160 @@ +.box-window { + width: 640px; + height: 460px; +} + +.box-toolbar { + display: flex; + gap: 8px; + height: 40px; + box-sizing: border-box; + padding: 6px 8px; +} + +.box-toolbar-button { + width: 116px; +} + +.box-address { + display: grid; + grid-template-columns: 58px minmax(0, 1fr); + align-items: center; + height: 28px; + box-sizing: border-box; + padding: 0 8px 6px; + gap: 6px; + font-size: 13px; + line-height: 13px; +} + +.box-address code { + min-width: 0; + height: 22px; + display: flex; + align-items: center; + box-sizing: border-box; + padding: 0 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #000000; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #dfdfdf; + border-bottom: 1px solid #dfdfdf; + font-family: inherit; +} + +.box-panel { + flex: 1; + min-height: 0; + margin: 0 8px 8px; + overflow: auto; +} + +.box-file-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); + gap: 8px; + padding: 10px; + box-sizing: border-box; +} + +.box-file { + min-width: 0; + height: 96px; + display: grid; + grid-template-rows: 34px 18px 28px; + justify-items: center; + align-items: center; + box-sizing: border-box; + padding: 8px 6px; + color: #000000; + text-decoration: none; +} + +.box-file:hover, +.box-file:focus-visible { + color: #ffffff; + background: #000078; + outline: 1px dotted #ffffff; + outline-offset: -3px; +} + +.box-file-icon { + width: 32px; + height: 32px; + object-fit: contain; + image-rendering: pixelated; +} + +.box-file-name, +.box-file-meta { + width: 100%; + min-width: 0; + overflow: hidden; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.box-file-name { + font-size: 13px; + line-height: 13px; +} + +.box-file-meta { + align-self: start; + color: #555555; + font-size: 11px; + line-height: 13px; +} + +.box-file:hover .box-file-meta, +.box-file:focus-visible .box-file-meta { + color: #ffffff; +} + +.box-empty { + margin: 0; + padding: 12px; + color: #555555; + font-size: 13px; + line-height: 15px; +} + +.box-statusbar { + grid-template-columns: 1fr 96px; +} + +@media (max-width: 600px) { + main { + display: block; + min-height: 100dvh; + } + + .box-window { + width: 100vw; + height: 100dvh; + border: 0; + box-shadow: none; + } + + .box-titlebar { + height: 24px; + margin: 0; + } + + .box-menu { + height: 26px; + } + + .box-panel { + margin: 0 6px 8px; + } + + .box-file-grid { + grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); + } +} diff --git a/static/css/upload.css b/static/css/upload.css index 86684cf..0681fd5 100644 --- a/static/css/upload.css +++ b/static/css/upload.css @@ -1,6 +1,6 @@ .upload-window { width: 520px; - height: 420px; + height: 456px; } .upload-form { @@ -126,6 +126,55 @@ border-bottom: 2px solid #ffffff; } +.upload-result { + flex: 0 0 auto; + display: grid; + grid-template-columns: 72px minmax(0, 1fr) 72px; + align-items: center; + gap: 6px; + height: 36px; + box-sizing: border-box; + margin-top: 8px; + padding: 4px 6px; + background: #dfdfdf; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + font-size: 12px; + line-height: 12px; +} + +.upload-result-label { + font-weight: bold; +} + +.upload-result-link { + min-width: 0; + overflow: hidden; + color: #000078; + text-overflow: ellipsis; + white-space: nowrap; +} + +.upload-result-link.is-empty { + color: #555555; + pointer-events: none; + text-decoration: none; +} + +.upload-share-button { + width: 72px; + height: 24px; + font-size: 12px; + line-height: 12px; +} + +.upload-share-button:disabled { + color: #808080; + text-shadow: 1px 1px 0 #ffffff; +} + .upload-empty-state { margin: 0; padding: 9px 8px; @@ -264,4 +313,8 @@ .upload-file-list { min-height: 160px; } + + .upload-result { + grid-template-columns: 64px minmax(0, 1fr) 68px; + } } diff --git a/static/js/app.js b/static/js/app.js index d3d696c..11f70c7 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -5,9 +5,12 @@ const dropzone = document.querySelector(".upload-dropzone"); const uploadForm = document.querySelector(".upload-form"); const uploadStatus = document.querySelector(".upload-statusbar span:first-child"); const boxStatus = document.querySelector(".upload-statusbar span:last-child"); +const boxLink = document.querySelector("#upload-box-link"); +const shareButton = document.querySelector("#upload-share-button"); let selectedFiles = []; let statusTimer = null; +let shareURL = ""; function formatBytes(bytes) { const units = ["B", "KB", "MB", "GB"]; @@ -56,6 +59,22 @@ function setBoxStatus(message) { } } +function setBoxLink(path) { + shareURL = path ? new URL(path, window.location.origin).toString() : ""; + + if (boxLink) { + boxLink.href = shareURL || "#"; + boxLink.textContent = shareURL || "Waiting for upload"; + boxLink.title = shareURL; + boxLink.classList.toggle("is-empty", !shareURL); + boxLink.setAttribute("aria-disabled", shareURL ? "false" : "true"); + } + + if (shareButton) { + shareButton.disabled = !shareURL; + } +} + function updateFileCount() { if (fileCount) { fileCount.textContent = `${selectedFiles.length} ${selectedFiles.length === 1 ? "file" : "files"}`; @@ -122,6 +141,7 @@ function updateSelectedFiles(files) { fileList.append(emptyState); updateStatus("Ready"); setBoxStatus("WarpBox"); + setBoxLink(""); return; } @@ -133,6 +153,7 @@ function updateSelectedFiles(files) { fileList.append(fragment); updateStatus("Files selected"); setBoxStatus("WarpBox"); + setBoxLink(""); } async function createBox() { @@ -246,6 +267,7 @@ if (uploadForm) { try { const box = await createBox(); setBoxStatus(box.box_url); + setBoxLink(box.box_url); await Promise.allSettled(selectedFiles.map((selectedFile) => { return uploadFile(box.box_id, selectedFile, () => { @@ -267,3 +289,27 @@ if (uploadForm) { } }); } + +if (shareButton) { + shareButton.addEventListener("click", async () => { + if (!shareURL) { + return; + } + + try { + if (navigator.share) { + await navigator.share({ + title: "WarpBox download", + text: "Download these files from WarpBox", + url: shareURL, + }); + return; + } + + await navigator.clipboard.writeText(shareURL); + updateStatus("Link copied"); + } catch (error) { + updateStatus("Share cancelled"); + } + }); +} diff --git a/templates/box.html b/templates/box.html new file mode 100644 index 0000000..daacd02 --- /dev/null +++ b/templates/box.html @@ -0,0 +1,66 @@ + + +
+ + +/box/{{ .BoxID }}
+ This box is empty.
+ {{ end }} +No files selected