Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ Generate the bcrypt value requested by `.env`, then open `http://localhost:8080`
- Browse multiple configured mount points from one web UI.
- Upload large files with chunked and resumable upload support.
- Preview common image, audio, video, PDF, and code/text files.
- Share single files with expiring, revocable links.
- Share files and folders with expiring, revocable links and controlled uploads.
- Download folders as ZIP archives from the browser or a public share page.
- Copy, move, and delete files through background jobs.
- Track job progress through WebSocket updates.
- Search directories by file or folder name.
Expand Down
92 changes: 92 additions & 0 deletions backend/internal/handler/archive_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package handler

import (
"archive/zip"
"bytes"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/go-chi/chi/v5"
"github.com/jR4dh3y/BoxBox/backend/internal/middleware"
"github.com/jR4dh3y/BoxBox/backend/internal/model"
)

func TestArchiveStreamsFolderZip(t *testing.T) {
handler, fs, _ := setupTestStreamHandler()
if err := fs.MkdirAll("/data/media/folder/nested", 0o755); err != nil {
t.Fatal(err)
}
if err := fs.WriteFile("/data/media/folder/nested/note.txt", []byte("archive note"), 0o644); err != nil {
t.Fatal(err)
}
mounts := []model.MountPoint{
{Name: "media", Path: "/data/media"},
{Name: "documents", Path: "/data/documents"},
}
router := chi.NewRouter()
router.Route("/api/v1", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(middleware.DevelopmentAuth)
r.Route("/stream", func(r chi.Router) {
r.Use(middleware.MountPointGuard(mounts))
handler.RegisterRoutes(r)
})
})
})

req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/archive/media/folder", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("archive status = %d, want 200: %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); got != "application/zip" {
t.Fatalf("archive Content-Type = %q, want application/zip", got)
}
if got := rec.Header().Get("Content-Disposition"); !strings.Contains(got, "folder.zip") {
t.Fatalf("archive Content-Disposition = %q", got)
}
reader, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len()))
if err != nil {
t.Fatal(err)
}
for _, entry := range reader.File {
if entry.Name != "folder/nested/note.txt" {
continue
}
file, err := entry.Open()
if err != nil {
t.Fatal(err)
}
content, readErr := io.ReadAll(file)
closeErr := file.Close()
if readErr != nil || closeErr != nil {
t.Fatalf("read archive item: read=%v close=%v", readErr, closeErr)
}
if string(content) != "archive note" {
t.Fatalf("archive content = %q", content)
}
return
}
t.Fatalf("ZIP is missing folder/nested/note.txt: %+v", reader.File)
}

func TestArchiveRouteEnforcesMountGuard(t *testing.T) {
handler, _, _ := setupTestStreamHandler()
mounts := []model.MountPoint{{Name: "media", Path: "/data/media"}}
router := chi.NewRouter()
router.Route("/api/v1/stream", func(r chi.Router) {
r.Use(middleware.MountPointGuard(mounts))
handler.RegisterRoutes(r)
})

req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/archive/private/folder", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("archive outside configured mounts status = %d, want 403: %s", rec.Code, rec.Body.String())
}
}
145 changes: 115 additions & 30 deletions backend/internal/handler/share.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/jR4dh3y/BoxBox/backend/internal/model"
"github.com/jR4dh3y/BoxBox/backend/internal/pkg/authcontext"
"github.com/jR4dh3y/BoxBox/backend/internal/service"
"github.com/rs/zerolog/log"
)

// ShareHandler handles share link management and recipient access.
Expand All @@ -38,6 +39,7 @@ func NewShareHandler(shareService service.ShareService, maxUploadMB int) *ShareH
func (h *ShareHandler) RegisterRoutes(r chi.Router) {
r.Post("/", h.Create)
r.Get("/", h.List)
r.Patch("/{id}", h.Update)
r.Delete("/{id}", h.Revoke)
}

Expand All @@ -47,7 +49,9 @@ func (h *ShareHandler) RegisterRoutes(r chi.Router) {
func (h *ShareHandler) RegisterPublicRoutes(r chi.Router) {
r.Get("/{token}", h.GetInfo)
r.Get("/{token}/items", h.ListItems)
r.Delete("/{token}/items", h.DeleteItem)
r.Get("/{token}/download", h.Download)
r.Get("/{token}/archive", h.Archive)
r.Get("/{token}/preview", h.Preview)
r.Post("/{token}/upload", h.Upload)
}
Expand Down Expand Up @@ -80,21 +84,25 @@ func (h *ShareHandler) Create(w http.ResponseWriter, r *http.Request) {
expiresAt = time.Now().Add(time.Duration(*req.ExpiresInSeconds) * time.Second)
}

share, err := h.shareService.Create(r.Context(), username, req.Path, req.Permissions, expiresAt)
share, err := h.shareService.Create(r.Context(), username, req.Path, service.ShareSettings{
Permissions: req.Permissions,
MaxUploadBytes: req.MaxUploadBytes,
}, expiresAt)
if err != nil {
HandleServiceError(w, err)
return
}

writeJSON(w, model.ShareResponse{
ID: share.ID,
Token: share.Token,
URL: "/s/" + share.Token,
FileName: share.FileName,
Permissions: share.Permissions,
IsFolder: share.IsFolder,
CreatedAt: share.CreatedAt,
ExpiresAt: share.ExpiresAt,
ID: share.ID,
Token: share.Token,
URL: "/s/" + share.Token,
FileName: share.FileName,
Permissions: share.Permissions.ToResponse(),
MaxUploadBytes: share.MaxUploadBytes,
IsFolder: share.IsFolder,
CreatedAt: share.CreatedAt,
ExpiresAt: share.ExpiresAt,
}, http.StatusCreated)
}

Expand All @@ -115,22 +123,27 @@ func (h *ShareHandler) List(w http.ResponseWriter, r *http.Request) {

items := make([]model.ShareSummary, 0, len(shares))
for _, share := range shares {
items = append(items, model.ShareSummary{
ID: share.ID,
Token: share.Token,
URL: "/s/" + share.Token,
FileName: share.FileName,
Path: shareDisplayPath(share),
Permissions: share.Permissions,
IsFolder: share.IsFolder,
CreatedAt: share.CreatedAt,
ExpiresAt: share.ExpiresAt,
})
items = append(items, shareSummary(share))
}

writeJSON(w, model.ShareListResponse{Shares: items}, http.StatusOK)
}

func shareSummary(share model.Share) model.ShareSummary {
return model.ShareSummary{
ID: share.ID,
Token: share.Token,
URL: "/s/" + share.Token,
FileName: share.FileName,
Path: shareDisplayPath(share),
Permissions: share.Permissions.ToResponse(),
MaxUploadBytes: share.MaxUploadBytes,
IsFolder: share.IsFolder,
CreatedAt: share.CreatedAt,
ExpiresAt: share.ExpiresAt,
}
}

func shareDisplayPath(share model.Share) string {
return path.Join(share.MountName, strings.ReplaceAll(share.RelPath, "\\", "/"))
}
Expand Down Expand Up @@ -158,6 +171,34 @@ func (h *ShareHandler) Revoke(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"success": true}, http.StatusOK)
}

// Update changes the access on one of the caller's active folder shares.
func (h *ShareHandler) Update(w http.ResponseWriter, r *http.Request) {
username := authcontext.Username(r.Context())
if username == "" {
writeError(w, "Authentication required", model.ErrCodeUnauthorized, http.StatusUnauthorized)
return
}
id := chi.URLParam(r, "id")
if id == "" {
writeError(w, "Share id is required", model.ErrCodeValidationError, http.StatusBadRequest)
return
}
var req model.UpdateShareRequest
if err := decodeJSONBody(w, r, &req); err != nil {
writeError(w, "Invalid request body", model.ErrCodeValidationError, http.StatusBadRequest)
return
}
share, err := h.shareService.Update(username, id, service.ShareUpdateSettings{
Permissions: req.Permissions,
MaxUploadBytes: req.MaxUploadBytes,
})
if err != nil {
HandleServiceError(w, err)
return
}
writeJSON(w, shareSummary(*share), http.StatusOK)
}

// GetInfo returns recipient-facing metadata for a share token. It never exposes
// mount names or internal paths.
// GET /api/v1/share/{token}
Expand All @@ -180,12 +221,13 @@ func (h *ShareHandler) GetInfo(w http.ResponseWriter, r *http.Request) {
}

writeJSON(w, model.ShareInfoResponse{
FileName: info.Name,
Size: info.Size,
MimeType: mimeType,
Permissions: share.Permissions,
IsFolder: share.IsFolder,
ExpiresAt: share.ExpiresAt,
FileName: info.Name,
Size: info.Size,
MimeType: mimeType,
Permissions: share.Permissions.ToResponse(),
MaxUploadBytes: share.MaxUploadBytes,
IsFolder: share.IsFolder,
ExpiresAt: share.ExpiresAt,
}, http.StatusOK)
}

Expand All @@ -205,6 +247,24 @@ func (h *ShareHandler) ListItems(w http.ResponseWriter, r *http.Request) {
writeJSON(w, items, http.StatusOK)
}

// DeleteItem removes a file or subfolder below a share that grants deletion.
func (h *ShareHandler) DeleteItem(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
if token == "" {
writeError(w, "Share token is required", model.ErrCodeValidationError, http.StatusBadRequest)
return
}
if r.URL.Query().Get("path") == "" {
writeError(w, "Path is required", model.ErrCodeValidationError, http.StatusBadRequest)
return
}
if err := h.shareService.DeleteForRecipientPath(r.Context(), token, r.URL.Query().Get("path")); err != nil {
HandleServiceError(w, err)
return
}
writeJSON(w, map[string]any{"success": true}, http.StatusOK)
}

// Download streams the shared file as an attachment with Range support
// GET /api/v1/share/{token}/download
func (h *ShareHandler) Download(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -244,6 +304,27 @@ func (h *ShareHandler) Download(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, info.Name, info.ModTime, file)
}

// Archive streams a ZIP containing a shared folder or a folder below it.
func (h *ShareHandler) Archive(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
if token == "" {
writeError(w, "Share token is required", model.ErrCodeValidationError, http.StatusBadRequest)
return
}
archive, err := h.shareService.PrepareDirectoryArchive(r.Context(), token, r.URL.Query().Get("path"))
if err != nil {
HandleServiceError(w, err)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", streamContentDisposition("attachment", archive.Name+".zip"))
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
if err := archive.WriteTo(r.Context(), w); err != nil {
log.Error().Err(err).Msg("Could not finish shared-folder archive")
}
}

// Preview streams the shared file inline with Range support. Active document
// formats are forced to attachment disposition, mirroring stream previews.
// GET /api/v1/share/{token}/preview
Expand Down Expand Up @@ -307,20 +388,24 @@ func (h *ShareHandler) Upload(w http.ResponseWriter, r *http.Request) {
HandleServiceError(w, err)
return
}
if !share.IsFolder || !share.Permissions.Write {
writeError(w, "This share does not allow updates", model.ErrCodePermissionDenied, http.StatusForbidden)
if !share.IsFolder || !share.Permissions.Upload {
writeError(w, "This share does not allow uploads", model.ErrCodePermissionDenied, http.StatusForbidden)
return
}
if r.ContentLength == 0 {
writeError(w, "Request body is required", model.ErrCodeValidationError, http.StatusBadRequest)
return
}
if r.ContentLength > h.maxUploadBytes {
maxUploadBytes := share.MaxUploadBytes
if maxUploadBytes <= 0 || maxUploadBytes > h.maxUploadBytes {
maxUploadBytes = h.maxUploadBytes
}
if r.ContentLength > maxUploadBytes {
writeError(w, "Upload exceeds the size limit", model.ErrCodeValidationError, http.StatusRequestEntityTooLarge)
return
}

r.Body = http.MaxBytesReader(w, r.Body, h.maxUploadBytes)
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
written, fileName, err := h.shareService.WriteForRecipientPath(
r.Context(), token, r.URL.Query().Get("path"), r.Body,
)
Expand Down
Loading
Loading