diff --git a/html/src/Pages/SharePage.tsx b/html/src/Pages/SharePage.tsx index 42a2a5d..667390d 100644 --- a/html/src/Pages/SharePage.tsx +++ b/html/src/Pages/SharePage.tsx @@ -1,6 +1,6 @@ import { Anchor, Box, Button, Center, CopyButton, Group, Paper, rem, Stack, Text, Tooltip } from "@mantine/core"; import { Dropzone } from "@mantine/dropzone"; -import { IconClock, IconFileZip, IconHelpHexagon, IconLink, IconMoodSad, IconUpload, IconX } from "@tabler/icons-react"; +import { IconClock, IconDownload, IconFileZip, IconHelpHexagon, IconLink, IconMoodSad, IconUpload, IconX } from "@tabler/icons-react"; import { useCallback, useEffect, useState } from "react"; import { H } from "../APIClient"; import { UploadQueue, QueueItem } from "../UploadQueue"; @@ -167,13 +167,18 @@ export function SharePage() { <> {/* Top of page copy button */} - - {({ copied, copy }) => ( - - - - )} - + + + {({ copied, copy }) => ( + + + + )} + + {canDownload() && items.length + queueItems.filter((i) => i.failed === false && i.finished === true ).length > 0 && + + } + {/* Message */} diff --git a/hupload/handlers.go b/hupload/handlers.go index b77c5f3..808dfde 100644 --- a/hupload/handlers.go +++ b/hupload/handlers.go @@ -1,6 +1,7 @@ package main import ( + "archive/zip" "bufio" "encoding/json" "errors" @@ -8,6 +9,7 @@ import ( "io" "log/slog" "net/http" + "path" "strconv" "github.com/ybizeul/hupload/internal/storage" @@ -371,6 +373,73 @@ func (h *Hupload) getItem(w http.ResponseWriter, r *http.Request) { } } +// getVersion returns hupload version +func (h *Hupload) downloadShare(w http.ResponseWriter, r *http.Request) { + shareName := r.PathValue("share") + + share, err := h.Config.Storage.GetShare(r.Context(), shareName) + if err != nil { + slog.Error("getItem", slog.String("error", err.Error())) + switch { + case errors.Is(err, storage.ErrInvalidShareName): + writeError(w, http.StatusBadRequest, "invalid share name") + return + case errors.Is(err, storage.ErrShareNotFound): + writeError(w, http.StatusNotFound, "share not found") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + user, _ := auth.AuthForRequest(r) + + if user == "" && (share.Options.Exposure != "both" && share.Options.Exposure != "download") { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + items, err := h.Config.Storage.ListShare(r.Context(), shareName) + if err != nil { + slog.Error("downloadShare", slog.String("error", err.Error())) + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + w.Header().Add("Content-Type", "application/zip") + w.Header().Add("Content-Disposition", "attachment") + + zipWriter := zip.NewWriter(w) + + for _, item := range items { + f, err := zipWriter.Create(path.Base(item.Path)) + if err != nil { + slog.Error("downloadShare", slog.String("error", err.Error())) + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + d, err := h.Config.Storage.GetItemData(r.Context(), shareName, path.Base(item.Path)) + if err != nil { + slog.Error("downloadShare", slog.String("error", err.Error())) + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + defer d.Close() + _, err = io.Copy(f, d) + if err != nil { + slog.Error("downloadShare", slog.String("error", err.Error())) + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + } + err = zipWriter.Close() + if err != nil { + slog.Error("downloadShare", slog.String("error", err.Error())) + writeError(w, http.StatusInternalServerError, err.Error()) + return + } +} + // postLogin returns the user name for the current session func (h *Hupload) postLogin(w http.ResponseWriter, r *http.Request) { user, _ := auth.AuthForRequest(r) diff --git a/hupload/handlers_test.go b/hupload/handlers_test.go index 9d66392..d68f526 100644 --- a/hupload/handlers_test.go +++ b/hupload/handlers_test.go @@ -691,6 +691,40 @@ func TestGetShare(t *testing.T) { } } +func TestDownloadShare(t *testing.T) { + for name, cfg := range cfgs { + if !cfg.Enabled { + continue + } + + shareName := "downloadshare" + h := getHupload(t, cfg.Config) + + makeShare(t, h, shareName, storage.Options{Exposure: "download"}) + t.Cleanup(func() { + _ = h.Config.Storage.DeleteShare(context.Background(), shareName) + }) + + makeItem(t, h, shareName, "newfile1.txt", 1*1024*1024) + makeItem(t, h, shareName, "newfile2.txt", 1*1024*1024) + + t.Run(name, func(t *testing.T) { + t.Cleanup(func() { cfg.Cleanup(h) }) + api := h.API + + req := httptest.NewRequest("GET", path.Join("/d/", shareName), nil) + + w := httptest.NewRecorder() + + api.Mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + return + } + }) + } +} func TestDeleteShare(t *testing.T) { for name, cfg := range cfgs { if !cfg.Enabled { diff --git a/hupload/internal/storage/s3.go b/hupload/internal/storage/s3.go index b434f6f..78e754a 100644 --- a/hupload/internal/storage/s3.go +++ b/hupload/internal/storage/s3.go @@ -381,15 +381,13 @@ func (b *S3Backend) ListShare(ctx context.Context, name string) ([]Item, error) inputs := s3.HeadObjectInput{ Bucket: &b.Options.Bucket, Key: item.Key, - - // ObjectAttributes: []types.ObjectAttributes{ - // types.ObjectAttributesObjectSize, - // }, } + gOutput, err := b.Client.HeadObject(ctx, &inputs) if err != nil { return nil, err } + item := &Item{ Path: *item.Key, ItemInfo: ItemInfo{ diff --git a/hupload/server.go b/hupload/server.go index 55fc6a5..c1b77e9 100644 --- a/hupload/server.go +++ b/hupload/server.go @@ -87,6 +87,7 @@ func (h *Hupload) setup() { api.AddPublicRoute("POST /api/v1/shares/{share}/items/{item}", authenticator, h.postItem) api.AddPublicRoute("DELETE /api/v1/shares/{share}/items/{item}", authenticator, h.deleteItem) + api.AddPublicRoute("GET /d/{share}", authenticator, h.downloadShare) api.AddPublicRoute("GET /d/{share}/{item}", authenticator, h.getItem) // Protected routes @@ -94,7 +95,7 @@ func (h *Hupload) setup() { api.AddRoute("GET /login", authenticator, h.postLogin) api.AddRoute("POST /login", authenticator, h.postLogin) - api.AddRoute("GET /api/v1/defaults", authenticator, h.getDefaults) + api.AddRoute("GET /api/v1/defaults", authenticator, h.getDefaults) api.AddRoute("GET /api/v1/shares", authenticator, h.getShares) api.AddRoute("POST /api/v1/shares", authenticator, h.postShare) @@ -107,7 +108,7 @@ func (h *Hupload) setup() { api.AddRoute("GET /api/v1/version", authenticator, h.getVersion) - api.AddRoute("GET /api/v1/*", authenticator, func(w http.ResponseWriter, r *http.Request) { + api.AddRoute("GET /api/v1/*", authenticator, func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "Error") }) diff --git a/readme_images/share-dark.png b/readme_images/share-dark.png index 8ada7f4..e141028 100644 Binary files a/readme_images/share-dark.png and b/readme_images/share-dark.png differ diff --git a/readme_images/share-light.png b/readme_images/share-light.png index 14e306f..2b6338c 100644 Binary files a/readme_images/share-light.png and b/readme_images/share-light.png differ