From 670c577c10baeac385f586fd3d285e89eedc9256 Mon Sep 17 00:00:00 2001 From: "usehoplite[bot]" <288093033+usehoplite[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 08:56:39 +0000 Subject: [PATCH 1/6] Improve folder sharing and ZIP downloads Co-authored-by: Radhey Kalra --- README.md | 3 +- backend/internal/handler/archive_test.go | 58 ++++ backend/internal/handler/share.go | 145 +++++++-- backend/internal/handler/share_test.go | 213 +++++++++++- backend/internal/handler/stream.go | 23 ++ backend/internal/model/share.go | 135 +++++--- backend/internal/model/share_test.go | 68 ++++ backend/internal/pkg/filesystem/fs.go | 71 ++++ backend/internal/pkg/filesystem/fs_test.go | 66 ++++ backend/internal/service/archive.go | 235 ++++++++++++++ backend/internal/service/archive_test.go | 160 +++++++++ backend/internal/service/file.go | 19 ++ backend/internal/service/path_safety.go | 10 +- backend/internal/service/share.go | 305 ++++++++++++++---- backend/internal/service/share_test.go | 255 ++++++++++++--- docs/api.md | 61 +++- docs/security.md | 11 +- frontend/src/lib/api/files.ts | 8 + frontend/src/lib/api/index.ts | 4 + frontend/src/lib/api/shares.ts | 40 ++- .../lib/components/FolderShareModal.svelte | 66 +++- .../settings/ShareLinksSettings.svelte | 135 ++++++++ .../src/lib/components/ui/ContextMenu.svelte | 2 +- frontend/src/lib/utils/fileContextMenu.ts | 7 +- frontend/src/lib/utils/shareAccess.ts | 7 + frontend/src/routes/browse/+page.svelte | 11 +- frontend/src/routes/layout.css | 1 + frontend/src/routes/s/[token]/+page.svelte | 79 ++++- frontend/src/routes/settings/+page.svelte | 29 +- 29 files changed, 1983 insertions(+), 244 deletions(-) create mode 100644 backend/internal/handler/archive_test.go create mode 100644 backend/internal/model/share_test.go create mode 100644 backend/internal/service/archive.go create mode 100644 backend/internal/service/archive_test.go create mode 100644 frontend/src/lib/components/settings/ShareLinksSettings.svelte create mode 100644 frontend/src/lib/utils/shareAccess.ts diff --git a/README.md b/README.md index 5c3e53a..6c05726 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/internal/handler/archive_test.go b/backend/internal/handler/archive_test.go new file mode 100644 index 0000000..7ef654d --- /dev/null +++ b/backend/internal/handler/archive_test.go @@ -0,0 +1,58 @@ +package handler + +import ( + "archive/zip" + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +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) + } + router := createStreamTestRouter(handler) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/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) +} diff --git a/backend/internal/handler/share.go b/backend/internal/handler/share.go index ada5b60..ba7594e 100644 --- a/backend/internal/handler/share.go +++ b/backend/internal/handler/share.go @@ -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. @@ -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) } @@ -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) } @@ -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) } @@ -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, "\\", "/")) } @@ -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.ShareSettings{ + 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} @@ -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) } @@ -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) { @@ -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 @@ -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, ) diff --git a/backend/internal/handler/share_test.go b/backend/internal/handler/share_test.go index 7fc4ef4..f480b06 100644 --- a/backend/internal/handler/share_test.go +++ b/backend/internal/handler/share_test.go @@ -1,6 +1,7 @@ package handler import ( + "archive/zip" "bytes" "context" "encoding/json" @@ -64,12 +65,23 @@ func newShareManagementRequestForUser(username, method, target string, body io.R } func createShareViaAPI(t *testing.T, router *chi.Mux, path string, permissions model.SharePermissions, expiresInSeconds *int64) model.ShareResponse { + return createShareViaAPIWithOptions(t, router, path, permissions, expiresInSeconds, nil) +} + +func createShareViaAPIWithLimit(t *testing.T, router *chi.Mux, path string, permissions model.SharePermissions, maxUploadBytes int64) model.ShareResponse { + return createShareViaAPIWithOptions(t, router, path, permissions, nil, &maxUploadBytes) +} + +func createShareViaAPIWithOptions(t *testing.T, router *chi.Mux, path string, permissions model.SharePermissions, expiresInSeconds *int64, maxUploadBytes *int64) model.ShareResponse { t.Helper() body := map[string]any{"path": path, "permissions": permissions} if expiresInSeconds != nil { body["expiresInSeconds"] = *expiresInSeconds } + if maxUploadBytes != nil { + body["maxUploadBytes"] = *maxUploadBytes + } encoded, err := json.Marshal(body) if err != nil { t.Fatal(err) @@ -110,7 +122,7 @@ func TestCreateShareViaAPI(t *testing.T) { if response.FileName != "file.txt" { t.Fatalf("fileName = %q, want file.txt", response.FileName) } - if !response.Permissions.View || !response.Permissions.Download || response.Permissions.Write { + if !response.Permissions.View || !response.Permissions.Download || response.Permissions.Upload || response.Permissions.Delete { t.Fatalf("permissions = %+v", response.Permissions) } if response.ExpiresAt.IsZero() { @@ -160,8 +172,8 @@ func TestCreateFolderShareViaAPI(t *testing.T) { handler, _, _ := setupTestShareHandler() router := createShareTestRouter(handler) - response := createShareViaAPI(t, router, "media/shared", model.SharePermissions{Write: true}, nil) - if !response.IsFolder || !response.Permissions.View || !response.Permissions.Download || !response.Permissions.Write { + response := createShareViaAPI(t, router, "media/shared", model.SharePermissions{Upload: true}, nil) + if !response.IsFolder || !response.Permissions.View || !response.Permissions.Download || !response.Permissions.Upload || response.Permissions.Delete { t.Fatalf("folder response = %+v", response) } } @@ -198,6 +210,45 @@ func TestShareManagementRequiresUsername(t *testing.T) { } } +func TestShareLegacyReplacementCapabilityInResponses(t *testing.T) { + handler, _, shareService := setupTestShareHandler() + router := createShareTestRouter(handler) + share, err := shareService.Create(context.Background(), "owner", "media/shared", service.ShareSettings{ + Permissions: model.SharePermissions{Upload: true, LegacyReplace: true}, + }, time.Time{}) + if err != nil { + t.Fatal(err) + } + + infoReq := httptest.NewRequest(http.MethodGet, "/api/v1/share/"+share.Token, nil) + infoRec := httptest.NewRecorder() + router.ServeHTTP(infoRec, infoReq) + if infoRec.Code != http.StatusOK { + t.Fatalf("share info status = %d, want %d: %s", infoRec.Code, http.StatusOK, infoRec.Body.String()) + } + var info model.ShareInfoResponse + if err := json.NewDecoder(infoRec.Body).Decode(&info); err != nil { + t.Fatal(err) + } + if !info.Permissions.CanReplace || info.Permissions.Delete { + t.Fatalf("recipient permissions = %+v, want replace without delete", info.Permissions) + } + + listReq := newShareManagementRequest(http.MethodGet, "/api/v1/shares", nil) + listRec := httptest.NewRecorder() + router.ServeHTTP(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("share list status = %d, want %d: %s", listRec.Code, http.StatusOK, listRec.Body.String()) + } + var list model.ShareListResponse + if err := json.NewDecoder(listRec.Body).Decode(&list); err != nil { + t.Fatal(err) + } + if len(list.Shares) != 1 || !list.Shares[0].Permissions.CanReplace || list.Shares[0].Permissions.Delete { + t.Fatalf("owner share permissions = %+v, want replace without delete", list.Shares) + } +} + func TestShareListAndRevokeViaAPI(t *testing.T) { handler, _, _ := setupTestShareHandler() router := createShareTestRouter(handler) @@ -261,7 +312,7 @@ func TestShareManagementIsScopedToOwnerViaAPI(t *testing.T) { router := createShareTestRouter(handler) owner := createShareViaAPI(t, router, "media/file.txt", model.SharePermissions{View: true}, nil) - other, err := shareSvc.Create(context.Background(), "other", "media/file.txt", model.SharePermissions{Download: true}, time.Time{}) + other, err := shareSvc.Create(context.Background(), "other", "media/file.txt", service.ShareSettings{Permissions: model.SharePermissions{Download: true}}, time.Time{}) if err != nil { t.Fatal(err) } @@ -321,7 +372,7 @@ func TestShareInfoOmitsInternalPaths(t *testing.T) { if info.FileName != "file.txt" || info.Size != int64(len("shared content")) || info.MimeType == "" { t.Fatalf("info = %+v", info) } - if !info.Permissions.View || !info.Permissions.Download || info.Permissions.Write { + if !info.Permissions.View || !info.Permissions.Download || info.Permissions.Upload || info.Permissions.Delete { t.Fatalf("info permissions = %+v", info.Permissions) } } @@ -359,7 +410,7 @@ func TestShareFolderItemsAndNestedDownloadViaAPI(t *testing.T) { func TestShareFolderRejectsInvalidPathsViaAPI(t *testing.T) { handler, _, _ := setupTestShareHandler() router := createShareTestRouter(handler) - share := createShareViaAPI(t, router, "media/shared", model.SharePermissions{Write: true}, nil) + share := createShareViaAPI(t, router, "media/shared", model.SharePermissions{Upload: true}, nil) traversalReq := httptest.NewRequest(http.MethodGet, "/api/v1/share/"+share.Token+"/items?path=../media", nil) traversalRec := httptest.NewRecorder() @@ -414,7 +465,7 @@ func TestShareFolderViewerAndReadOnlyMountCannotUploadViaAPI(t *testing.T) { func TestShareFileUploadIsRejectedViaAPI(t *testing.T) { handler, _, _ := setupTestShareHandler() router := createShareTestRouter(handler) - share := createShareViaAPI(t, router, "media/file.txt", model.SharePermissions{Write: true}, nil) + share := createShareViaAPI(t, router, "media/file.txt", model.SharePermissions{Upload: true}, nil) req := httptest.NewRequest(http.MethodPost, "/api/v1/share/"+share.Token+"/upload", strings.NewReader("blocked")) rec := httptest.NewRecorder() @@ -427,7 +478,7 @@ func TestShareFileUploadIsRejectedViaAPI(t *testing.T) { func TestShareRecipientEndpointsAreUniformlyNotFound(t *testing.T) { handler, _, shareSvc := setupTestShareHandler() router := createShareTestRouter(handler) - perms := model.SharePermissions{View: true, Download: true, Write: true} + perms := service.ShareSettings{Permissions: model.SharePermissions{View: true, Download: true, Upload: true}} expired, err := shareSvc.Create(context.Background(), "owner", "media/file.txt", perms, time.Now().Add(-time.Hour)) if err != nil { @@ -595,7 +646,7 @@ func TestShareFolderUploadOverwritesFileViaAPI(t *testing.T) { handler, fs, _ := setupTestShareHandler() router := createShareTestRouter(handler) - share := createShareViaAPI(t, router, "media", model.SharePermissions{Write: true}, nil) + share := createShareViaAPI(t, router, "media", model.SharePermissions{Upload: true, Delete: true}, nil) req := httptest.NewRequest(http.MethodPost, "/api/v1/share/"+share.Token+"/upload?path=file.txt", strings.NewReader("new contents")) rec := httptest.NewRecorder() @@ -633,7 +684,7 @@ func TestShareUploadRejectsInvalidRequests(t *testing.T) { router := createShareTestRouter(handler) readOnly := createShareViaAPI(t, router, "media", model.SharePermissions{}, nil) - writable := createShareViaAPI(t, router, "media", model.SharePermissions{Write: true}, nil) + writable := createShareViaAPI(t, router, "media", model.SharePermissions{Upload: true}, nil) tests := []struct { name string @@ -663,7 +714,7 @@ func TestShareUploadEnforcesMaxBytes(t *testing.T) { // NewShareHandler(_, 1) caps uploads at 1 MiB. handler, fs, _ := setupTestShareHandler() router := createShareTestRouter(handler) - share := createShareViaAPI(t, router, "media", model.SharePermissions{Write: true}, nil) + share := createShareViaAPI(t, router, "media", model.SharePermissions{Upload: true}, nil) oversized := bytes.Repeat([]byte("a"), 1<<20+1) @@ -696,3 +747,143 @@ func TestShareUploadEnforcesMaxBytes(t *testing.T) { t.Fatalf("target content after rejected uploads = %q, want original", content) } } + +func TestShareUploadOnlyRespectsPerLinkLimitAndCannotOverwriteViaAPI(t *testing.T) { + handler, fs, _ := setupTestShareHandler() + router := createShareTestRouter(handler) + share := createShareViaAPIWithLimit(t, router, "media", model.SharePermissions{Upload: true}, 4) + if share.MaxUploadBytes != 4 { + t.Fatalf("share upload limit = %d, want 4", share.MaxUploadBytes) + } + + denied := httptest.NewRequest(http.MethodPost, "/api/v1/share/"+share.Token+"/upload?path=file.txt", strings.NewReader("no")) + deniedRec := httptest.NewRecorder() + router.ServeHTTP(deniedRec, denied) + if deniedRec.Code != http.StatusForbidden { + t.Fatalf("upload-only overwrite status = %d, want 403: %s", deniedRec.Code, deniedRec.Body.String()) + } + + allowed := httptest.NewRequest(http.MethodPost, "/api/v1/share/"+share.Token+"/upload?path=new.txt", strings.NewReader("four")) + allowedRec := httptest.NewRecorder() + router.ServeHTTP(allowedRec, allowed) + if allowedRec.Code != http.StatusOK { + t.Fatalf("upload-only create status = %d, want 200: %s", allowedRec.Code, allowedRec.Body.String()) + } + + tooLarge := httptest.NewRequest(http.MethodPost, "/api/v1/share/"+share.Token+"/upload?path=large.txt", strings.NewReader("five!")) + tooLargeRec := httptest.NewRecorder() + router.ServeHTTP(tooLargeRec, tooLarge) + if tooLargeRec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("per-link oversize status = %d, want 413: %s", tooLargeRec.Code, tooLargeRec.Body.String()) + } + if exists, err := fs.Exists("/data/media/large.txt"); err != nil || exists { + t.Fatalf("oversize upload left a file (exists=%t, err=%v)", exists, err) + } +} + +func TestShareUpdateAndRecipientDeleteViaAPI(t *testing.T) { + handler, fs, _ := setupTestShareHandler() + router := createShareTestRouter(handler) + if err := fs.WriteFile("/data/media/shared/note.txt", []byte("note"), 0o644); err != nil { + t.Fatal(err) + } + share := createShareViaAPIWithLimit(t, router, "media/shared", model.SharePermissions{Upload: true}, 8) + + deletePath := "/api/v1/share/" + share.Token + "/items?path=note.txt" + deniedDelete := httptest.NewRequest(http.MethodDelete, deletePath, nil) + deniedDeleteRec := httptest.NewRecorder() + router.ServeHTTP(deniedDeleteRec, deniedDelete) + if deniedDeleteRec.Code != http.StatusForbidden { + t.Fatalf("upload-only delete status = %d, want 403: %s", deniedDeleteRec.Code, deniedDeleteRec.Body.String()) + } + + updatedRequest := model.UpdateShareRequest{ + Permissions: model.SharePermissions{Upload: true, Delete: true}, + MaxUploadBytes: 16, + } + updatedBody, err := json.Marshal(updatedRequest) + if err != nil { + t.Fatal(err) + } + updateReq := newShareManagementRequest(http.MethodPatch, "/api/v1/shares/"+share.ID, bytes.NewReader(updatedBody)) + updateRec := httptest.NewRecorder() + router.ServeHTTP(updateRec, updateReq) + if updateRec.Code != http.StatusOK { + t.Fatalf("update status = %d, want 200: %s", updateRec.Code, updateRec.Body.String()) + } + var updated model.ShareSummary + if err := json.Unmarshal(updateRec.Body.Bytes(), &updated); err != nil { + t.Fatal(err) + } + if !updated.Permissions.Upload || !updated.Permissions.Delete || updated.MaxUploadBytes != 16 { + t.Fatalf("updated share = %+v", updated) + } + + wrongOwnerReq := newShareManagementRequestForUser("other", http.MethodPatch, "/api/v1/shares/"+share.ID, bytes.NewReader(updatedBody)) + wrongOwnerRec := httptest.NewRecorder() + router.ServeHTTP(wrongOwnerRec, wrongOwnerReq) + if wrongOwnerRec.Code != http.StatusNotFound { + t.Fatalf("other owner's update status = %d, want 404", wrongOwnerRec.Code) + } + + deleteReq := httptest.NewRequest(http.MethodDelete, deletePath, nil) + deleteRec := httptest.NewRecorder() + router.ServeHTTP(deleteRec, deleteReq) + if deleteRec.Code != http.StatusOK { + t.Fatalf("authorized delete status = %d, want 200: %s", deleteRec.Code, deleteRec.Body.String()) + } + if exists, err := fs.Exists("/data/media/shared/note.txt"); err != nil || exists { + t.Fatalf("deleted item remains (exists=%t, err=%v)", exists, err) + } +} + +func TestShareFolderArchiveViaAPI(t *testing.T) { + handler, fs, _ := setupTestShareHandler() + router := createShareTestRouter(handler) + if err := fs.MkdirAll("/data/media/shared/nested", 0o755); err != nil { + t.Fatal(err) + } + if err := fs.WriteFile("/data/media/shared/nested/note.txt", []byte("archive note"), 0o644); err != nil { + t.Fatal(err) + } + share := createShareViaAPI(t, router, "media/shared", model.SharePermissions{}, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/share/"+share.Token+"/archive", 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, "shared.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) + } + found := false + for _, entry := range reader.File { + if entry.Name != "shared/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) + } + found = true + } + if !found { + t.Fatalf("ZIP is missing shared/nested/note.txt: %+v", reader.File) + } +} diff --git a/backend/internal/handler/stream.go b/backend/internal/handler/stream.go index f12e656..04baa67 100644 --- a/backend/internal/handler/stream.go +++ b/backend/internal/handler/stream.go @@ -16,6 +16,7 @@ import ( "github.com/jR4dh3y/BoxBox/backend/internal/model" "github.com/jR4dh3y/BoxBox/backend/internal/pkg/fileutil" "github.com/jR4dh3y/BoxBox/backend/internal/service" + "github.com/rs/zerolog/log" ) var errUploadTooLarge = errors.New("upload too large") @@ -51,6 +52,7 @@ func NewStreamHandler(fileService service.FileService, chunkSizeMB int, maxUploa // RegisterRoutes registers stream routes on the given router func (h *StreamHandler) RegisterRoutes(r chi.Router) { r.Get("/download/*", h.Download) + r.Get("/archive/*", h.Archive) r.Get("/preview/*", h.Preview) r.Get("/thumbnail/*", h.Thumbnail) r.Post("/upload/*", h.Upload) @@ -136,6 +138,27 @@ func (h *StreamHandler) Download(w http.ResponseWriter, r *http.Request) { http.ServeContent(w, r, info.Name, info.ModTime, file) } +// Archive streams a folder as a bounded ZIP attachment. +func (h *StreamHandler) Archive(w http.ResponseWriter, r *http.Request) { + path := chi.URLParam(r, "*") + if path == "" { + writeError(w, "Path is required", model.ErrCodeValidationError, http.StatusBadRequest) + return + } + archive, err := h.fileService.PrepareDirectoryArchive(r.Context(), 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 folder archive") + } +} + // Preview handles file preview requests (inline viewing) with Range header support // GET /api/v1/stream/preview/*path func (h *StreamHandler) Preview(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/model/share.go b/backend/internal/model/share.go index edc1cf6..4e483f0 100644 --- a/backend/internal/model/share.go +++ b/backend/internal/model/share.go @@ -1,29 +1,78 @@ package model -import "time" +import ( + "encoding/json" + "time" +) // SharePermissions defines what a share-link recipient may do with a shared resource. type SharePermissions struct { - View bool `json:"view"` - Download bool `json:"download"` - Write bool `json:"write"` + View bool `json:"view"` + Download bool `json:"download"` + Upload bool `json:"upload"` + Delete bool `json:"delete"` + LegacyReplace bool `json:"-"` +} + +// UnmarshalJSON keeps existing share records and older clients' write flag +// readable while exposing upload and delete as separate capabilities. +func (p *SharePermissions) UnmarshalJSON(data []byte) error { + var value struct { + View bool `json:"view"` + Download bool `json:"download"` + Upload *bool `json:"upload"` + Delete bool `json:"delete"` + Write bool `json:"write"` + } + if err := json.Unmarshal(data, &value); err != nil { + return err + } + p.View = value.View + p.Download = value.Download + p.Upload = value.Write + if value.Upload != nil { + p.Upload = *value.Upload + } + p.Delete = value.Delete + p.LegacyReplace = value.Upload == nil && value.Write + return nil +} + +// SharePermissionsResponse reports the effective permissions to share clients. +type SharePermissionsResponse struct { + View bool `json:"view"` + Download bool `json:"download"` + Upload bool `json:"upload"` + Delete bool `json:"delete"` + CanReplace bool `json:"canReplace"` +} + +func (p SharePermissions) ToResponse() SharePermissionsResponse { + return SharePermissionsResponse{ + View: p.View, + Download: p.Download, + Upload: p.Upload, + Delete: p.Delete, + CanReplace: p.Delete || p.LegacyReplace, + } } // Share represents a file or folder share link. MountName and RelPath are // internal bookkeeping for re-resolving the target at access time and are // never exposed to recipients. type Share struct { - ID string `json:"id"` - Token string `json:"token"` - MountName string `json:"-"` - RelPath string `json:"-"` - IsFolder bool `json:"isFolder"` - Permissions SharePermissions `json:"permissions"` - FileName string `json:"fileName"` - CreatedAt time.Time `json:"createdAt"` - ExpiresAt time.Time `json:"expiresAt,omitempty"` // zero means never - Revoked bool `json:"revoked"` - CreatedBy string `json:"createdBy"` + ID string `json:"id"` + Token string `json:"token"` + MountName string `json:"-"` + RelPath string `json:"-"` + IsFolder bool `json:"isFolder"` + Permissions SharePermissions `json:"permissions"` + MaxUploadBytes int64 `json:"maxUploadBytes"` + FileName string `json:"fileName"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt time.Time `json:"expiresAt,omitempty"` // zero means never + Revoked bool `json:"revoked"` + CreatedBy string `json:"createdBy"` } // CreateShareRequest is the request body for creating a share link. @@ -31,32 +80,41 @@ type Share struct { type CreateShareRequest struct { Path string `json:"path"` Permissions SharePermissions `json:"permissions"` + MaxUploadBytes int64 `json:"maxUploadBytes,omitempty"` ExpiresInSeconds *int64 `json:"expiresInSeconds,omitempty"` } +// UpdateShareRequest changes the access granted by an active folder share. +type UpdateShareRequest struct { + Permissions SharePermissions `json:"permissions"` + MaxUploadBytes int64 `json:"maxUploadBytes"` +} + // ShareResponse is returned when a share link is created type ShareResponse struct { - ID string `json:"id"` - Token string `json:"token"` - URL string `json:"url"` - FileName string `json:"fileName"` - Permissions SharePermissions `json:"permissions"` - IsFolder bool `json:"isFolder"` - CreatedAt time.Time `json:"createdAt"` - ExpiresAt time.Time `json:"expiresAt,omitempty"` + ID string `json:"id"` + Token string `json:"token"` + URL string `json:"url"` + FileName string `json:"fileName"` + Permissions SharePermissionsResponse `json:"permissions"` + MaxUploadBytes int64 `json:"maxUploadBytes"` + IsFolder bool `json:"isFolder"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt time.Time `json:"expiresAt,omitempty"` } // ShareSummary is one active share in the owner's share list type ShareSummary struct { - ID string `json:"id"` - Token string `json:"token"` - URL string `json:"url"` - FileName string `json:"fileName"` - Path string `json:"path"` - Permissions SharePermissions `json:"permissions"` - IsFolder bool `json:"isFolder"` - CreatedAt time.Time `json:"createdAt"` - ExpiresAt time.Time `json:"expiresAt,omitempty"` + ID string `json:"id"` + Token string `json:"token"` + URL string `json:"url"` + FileName string `json:"fileName"` + Path string `json:"path"` + Permissions SharePermissionsResponse `json:"permissions"` + MaxUploadBytes int64 `json:"maxUploadBytes"` + IsFolder bool `json:"isFolder"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt time.Time `json:"expiresAt,omitempty"` } // ShareListResponse is the owner's list of active shares @@ -67,12 +125,13 @@ type ShareListResponse struct { // ShareInfoResponse is the recipient-facing share metadata. It deliberately // omits mount names and internal paths. type ShareInfoResponse struct { - FileName string `json:"fileName"` - Size int64 `json:"size"` - MimeType string `json:"mimeType"` - Permissions SharePermissions `json:"permissions"` - IsFolder bool `json:"isFolder"` - ExpiresAt time.Time `json:"expiresAt,omitempty"` + FileName string `json:"fileName"` + Size int64 `json:"size"` + MimeType string `json:"mimeType"` + Permissions SharePermissionsResponse `json:"permissions"` + MaxUploadBytes int64 `json:"maxUploadBytes"` + IsFolder bool `json:"isFolder"` + ExpiresAt time.Time `json:"expiresAt,omitempty"` } // ShareItem is a directory entry exposed below a shared folder. Path is diff --git a/backend/internal/model/share_test.go b/backend/internal/model/share_test.go new file mode 100644 index 0000000..db6ed78 --- /dev/null +++ b/backend/internal/model/share_test.go @@ -0,0 +1,68 @@ +package model + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestSharePermissionsAcceptLegacyWriteWithoutGrantingDelete(t *testing.T) { + var permissions SharePermissions + if err := json.Unmarshal([]byte(`{"view":true,"download":true,"write":true}`), &permissions); err != nil { + t.Fatal(err) + } + if !permissions.Upload || permissions.Delete || !permissions.LegacyReplace { + t.Fatalf("legacy permissions = %+v", permissions) + } + + data, err := json.Marshal(permissions) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), `"write"`) || strings.Contains(string(data), `legacyReplace`) { + t.Fatalf("legacy permission leaked in API JSON: %s", data) + } + if !strings.Contains(string(data), `"upload":true`) || !strings.Contains(string(data), `"delete":false`) { + t.Fatalf("split permission fields missing from API JSON: %s", data) + } +} + +func TestSharePermissionsPreferExplicitUploadOverLegacyWrite(t *testing.T) { + var permissions SharePermissions + if err := json.Unmarshal([]byte(`{"upload":false,"write":true,"delete":false}`), &permissions); err != nil { + t.Fatal(err) + } + if permissions.Upload || permissions.Delete || permissions.LegacyReplace { + t.Fatalf("explicit upload=false was overridden: %+v", permissions) + } +} + +func TestSharePermissionsResponseReportsEffectiveReplacement(t *testing.T) { + tests := []struct { + name string + permissions SharePermissions + want bool + }{ + {name: "legacy write", permissions: SharePermissions{Upload: true, LegacyReplace: true}, want: true}, + {name: "upload only", permissions: SharePermissions{Upload: true}, want: false}, + {name: "upload and delete", permissions: SharePermissions{Upload: true, Delete: true}, want: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := test.permissions.ToResponse() + if response.CanReplace != test.want { + t.Fatalf("canReplace = %t, want %t", response.CanReplace, test.want) + } + }) + } +} + +func TestSharePermissionsIgnoreComputedReplacementInput(t *testing.T) { + var permissions SharePermissions + if err := json.Unmarshal([]byte(`{"upload":true,"canReplace":true}`), &permissions); err != nil { + t.Fatal(err) + } + if permissions.LegacyReplace { + t.Fatal("client-supplied canReplace granted legacy replacement access") + } +} diff --git a/backend/internal/pkg/filesystem/fs.go b/backend/internal/pkg/filesystem/fs.go index f27a8e4..94b35a6 100644 --- a/backend/internal/pkg/filesystem/fs.go +++ b/backend/internal/pkg/filesystem/fs.go @@ -44,6 +44,9 @@ type FS interface { // Rename renames (moves) oldpath to newpath. Rename(oldpath, newpath string) error + // RenameNoReplace publishes oldpath at newpath only if newpath does not exist. + RenameNoReplace(oldpath, newpath string) error + // MkdirAll creates a directory named path, along with any necessary parents. MkdirAll(path string, perm os.FileMode) error @@ -227,6 +230,74 @@ func (a *AferoFS) Rename(oldpath, newpath string) error { return a.fs.Rename(oldpath, newpath) } +func (a *AferoFS) RenameNoReplace(oldpath, newpath string) error { + if err := validateFilesystemPath(oldpath); err != nil { + return err + } + if err := validateFilesystemPath(newpath); err != nil { + return err + } + oldpath = filepath.Clean("/" + oldpath) + newpath = filepath.Clean("/" + newpath) + if _, ok := a.fs.(*afero.OsFs); ok { + return renameNoReplaceWithLink(oldpath, newpath, os.Link) + } + if _, err := a.fs.Stat(newpath); err == nil { + return fs.ErrExist + } else if !errors.Is(err, fs.ErrNotExist) { + return err + } + return a.fs.Rename(oldpath, newpath) +} + +func renameNoReplaceWithLink(oldpath, newpath string, link func(string, string) error) error { + if err := link(oldpath, newpath); err == nil { + if err := os.Remove(oldpath); err != nil { + _ = os.Remove(newpath) + return err + } + return nil + } else if errors.Is(err, fs.ErrExist) { + return err + } + + return copyNoReplace(oldpath, newpath) +} + +func copyNoReplace(oldpath, newpath string) error { + source, err := os.Open(oldpath) + if err != nil { + return err + } + info, err := source.Stat() + if err != nil { + _ = source.Close() + return err + } + destination, err := os.OpenFile(newpath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm()) + if err != nil { + _ = source.Close() + return err + } + + _, copyErr := io.Copy(destination, source) + sourceCloseErr := source.Close() + destinationCloseErr := destination.Close() + if err := errors.Join(copyErr, sourceCloseErr, destinationCloseErr); err != nil { + _ = os.Remove(newpath) + return err + } + if err := os.Chmod(newpath, info.Mode().Perm()); err != nil { + _ = os.Remove(newpath) + return err + } + if err := os.Remove(oldpath); err != nil { + _ = os.Remove(newpath) + return err + } + return nil +} + // MkdirAll creates a directory named path, along with any necessary parents. func (a *AferoFS) MkdirAll(path string, perm os.FileMode) error { if err := validateFilesystemPath(path); err != nil { diff --git a/backend/internal/pkg/filesystem/fs_test.go b/backend/internal/pkg/filesystem/fs_test.go index 2dc94f0..15f078c 100644 --- a/backend/internal/pkg/filesystem/fs_test.go +++ b/backend/internal/pkg/filesystem/fs_test.go @@ -2,6 +2,9 @@ package filesystem import ( "errors" + "io/fs" + "os" + "path/filepath" "testing" ) @@ -35,3 +38,66 @@ func TestAferoFSKeepsSafePathsAndRejectsTraversal(t *testing.T) { } } } + +func TestRenameNoReplaceFallsBackWhenHardLinksAreUnsupported(t *testing.T) { + directory := t.TempDir() + source := filepath.Join(directory, "staged.txt") + destination := filepath.Join(directory, "uploaded.txt") + if err := os.WriteFile(source, []byte("upload"), 0o640); err != nil { + t.Fatal(err) + } + if err := os.Chmod(source, 0o640); err != nil { + t.Fatal(err) + } + + errLinkUnsupported := errors.New("hard links unsupported") + err := renameNoReplaceWithLink(source, destination, func(string, string) error { + return errLinkUnsupported + }) + if err != nil { + t.Fatalf("rename with copy fallback: %v", err) + } + if _, err := os.Stat(source); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("staged file still exists: %v", err) + } + data, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + if string(data) != "upload" { + t.Fatalf("destination = %q, want upload", data) + } + info, err := os.Stat(destination) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o640 { + t.Fatalf("destination mode = %04o, want 0640", got) + } +} + +func TestRenameNoReplaceFallbackDoesNotOverwrite(t *testing.T) { + directory := t.TempDir() + source := filepath.Join(directory, "staged.txt") + destination := filepath.Join(directory, "uploaded.txt") + if err := os.WriteFile(source, []byte("new"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(destination, []byte("existing"), 0o600); err != nil { + t.Fatal(err) + } + + err := renameNoReplaceWithLink(source, destination, func(string, string) error { + return errors.New("hard links unsupported") + }) + if !errors.Is(err, fs.ErrExist) { + t.Fatalf("rename error = %v, want fs.ErrExist", err) + } + data, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + if string(data) != "existing" { + t.Fatalf("existing destination = %q, want unchanged", data) + } +} diff --git a/backend/internal/service/archive.go b/backend/internal/service/archive.go new file mode 100644 index 0000000..2037fff --- /dev/null +++ b/backend/internal/service/archive.go @@ -0,0 +1,235 @@ +package service + +import ( + "archive/zip" + "context" + "errors" + "io" + "io/fs" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/jR4dh3y/BoxBox/backend/internal/pkg/filesystem" +) + +const maxArchiveEntries = 100_000 + +var archivePathReplacer = strings.NewReplacer("%", "%25", "\\", "%5C", ":", "%3A") + +type archiveEntry struct { + path string + relativePath string + info fs.FileInfo +} + +// DirectoryArchive is a validated, bounded ZIP plan for a directory. +type DirectoryArchive struct { + Name string + fs filesystem.FS + root string + entries []archiveEntry +} + +func prepareDirectoryArchive(ctx context.Context, fsys filesystem.FS, root, boundary string) (*DirectoryArchive, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + root, err := fsys.EvalSymlinks(root) + if err != nil { + return nil, err + } + boundary, err = fsys.EvalSymlinks(boundary) + if err != nil { + return nil, err + } + if !pathWithinRoot(boundary, root) { + return nil, ErrPermissionDenied + } + rootInfo, err := fsys.Stat(root) + if err != nil { + return nil, err + } + if !rootInfo.IsDir() { + return nil, ErrNotDirectory + } + + archive := &DirectoryArchive{ + Name: safeArchiveName(rootInfo.Name()), + fs: fsys, + root: root, + } + err = NewWalker(fsys).Walk(ctx, root, WalkOptions{ + IncludeHidden: true, + MaxEntries: maxArchiveEntries, + LoadMetadata: true, + }, func(entry WalkEntry) error { + if !pathWithinRoot(root, entry.Path) { + return ErrPermissionDenied + } + resolved, err := fsys.EvalSymlinks(entry.Path) + if err != nil { + return err + } + if !pathWithinRoot(root, resolved) { + return ErrPermissionDenied + } + if entry.Metadata.IsDir() || entry.Metadata.Mode().IsRegular() { + archive.entries = append(archive.entries, archiveEntry{ + path: entry.Path, + relativePath: entry.RelativePath, + info: entry.Metadata, + }) + } + return nil + }) + if err != nil { + return nil, err + } + sort.Slice(archive.entries, func(i, j int) bool { + return archive.entries[i].relativePath < archive.entries[j].relativePath + }) + if err := validateArchiveEntryNames(archive); err != nil { + return nil, err + } + return archive, nil +} + +func validateArchiveEntryNames(archive *DirectoryArchive) error { + if _, ok := archiveCollisionKey(archive.Name); !ok { + return ErrInvalidOperation + } + seen := make(map[string]struct{}, len(archive.entries)) + for _, entry := range archive.entries { + name, err := archiveEntryName(archive.Name, entry.relativePath, entry.info.IsDir()) + if err != nil { + return err + } + key, ok := archiveCollisionKey(name) + if !ok { + return ErrInvalidOperation + } + if _, exists := seen[key]; exists { + return ErrInvalidOperation + } + seen[key] = struct{}{} + } + return nil +} + +func archiveCollisionKey(name string) (string, bool) { + components := strings.Split(strings.TrimSuffix(name, "/"), "/") + for index, component := range components { + component = strings.TrimRight(component, " .") + if component == "" || component == "." || component == ".." { + return "", false + } + components[index] = strings.ToLower(component) + } + return strings.Join(components, "/"), true +} + +// WriteTo streams a ZIP after its traversal and path boundaries have been checked. +func (a *DirectoryArchive) WriteTo(ctx context.Context, destination io.Writer) error { + if a == nil || a.fs == nil || a.root == "" { + return ErrInvalidOperation + } + zipWriter := zip.NewWriter(destination) + root := a.Name + rootHeader := &zip.FileHeader{Name: root + "/", Method: zip.Store} + rootHeader.SetMode(fs.ModeDir | 0o755) + _, writeErr := zipWriter.CreateHeader(rootHeader) + if writeErr == nil { + for _, entry := range a.entries { + if err := ctx.Err(); err != nil { + writeErr = err + break + } + name, err := archiveEntryName(root, entry.relativePath, entry.info.IsDir()) + if err != nil { + writeErr = err + break + } + header := &zip.FileHeader{Name: name, Modified: entry.info.ModTime()} + if entry.info.IsDir() { + header.Method = zip.Store + header.SetMode(fs.ModeDir | entry.info.Mode().Perm()) + if _, writeErr = zipWriter.CreateHeader(header); writeErr != nil { + break + } + continue + } + + file, err := a.fs.Open(entry.path) + if err != nil { + writeErr = err + break + } + if err = verifyOpenFileWithinRoot(a.fs, a.root, file); err != nil { + _ = file.Close() + writeErr = err + break + } + fileInfo, err := file.Stat() + if err != nil || !fileInfo.Mode().IsRegular() { + _ = file.Close() + if err != nil { + writeErr = err + } else { + writeErr = ErrNotFile + } + break + } + header.Method = zip.Deflate + header.SetMode(fileInfo.Mode()) + fileWriter, err := zipWriter.CreateHeader(header) + if err == nil { + _, err = io.Copy(fileWriter, archiveContextReader{ctx: ctx, reader: file}) + } + closeErr := file.Close() + if err != nil || closeErr != nil { + writeErr = errors.Join(err, closeErr) + break + } + } + } + return errors.Join(writeErr, zipWriter.Close()) +} + +type archiveContextReader struct { + ctx context.Context + reader io.Reader +} + +func (r archiveContextReader) Read(buffer []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.reader.Read(buffer) +} + +func archiveEntryName(root, relative string, directory bool) (string, error) { + relative = sanitizeArchivePath(filepath.ToSlash(relative)) + cleaned := path.Clean(relative) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || path.IsAbs(cleaned) { + return "", ErrPermissionDenied + } + name := path.Join(root, cleaned) + if directory { + name += "/" + } + return name, nil +} + +func safeArchiveName(name string) string { + name = strings.Trim(sanitizeArchivePath(name), "/") + if name == "" || name == "." || name == ".." { + return "folder" + } + return name +} + +func sanitizeArchivePath(name string) string { + return archivePathReplacer.Replace(name) +} diff --git a/backend/internal/service/archive_test.go b/backend/internal/service/archive_test.go new file mode 100644 index 0000000..c7ac1f8 --- /dev/null +++ b/backend/internal/service/archive_test.go @@ -0,0 +1,160 @@ +package service + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + + "github.com/jR4dh3y/BoxBox/backend/internal/pkg/filesystem" +) + +func TestDirectoryArchiveSanitizesWindowsVolumeSyntax(t *testing.T) { + fsys := filesystem.NewMemMapFS() + if err := fsys.MkdirAll("/data/media/C:/C:", 0o755); err != nil { + t.Fatal(err) + } + if err := fsys.WriteFile("/data/media/C:/root.txt", []byte("root"), 0o644); err != nil { + t.Fatal(err) + } + if err := fsys.WriteFile("/data/media/C:/C:/nested.txt", []byte("nested"), 0o644); err != nil { + t.Fatal(err) + } + + archive, err := prepareDirectoryArchive(context.Background(), fsys, "/data/media/C:", "/data/media") + if err != nil { + t.Fatal(err) + } + if archive.Name != "C%3A" { + t.Fatalf("archive name = %q, want C%%3A", archive.Name) + } + + var output bytes.Buffer + if err := archive.WriteTo(context.Background(), &output); err != nil { + t.Fatal(err) + } + reader, err := zip.NewReader(bytes.NewReader(output.Bytes()), int64(output.Len())) + if err != nil { + t.Fatal(err) + } + want := map[string]bool{ + "C%3A/": true, + "C%3A/root.txt": true, + "C%3A/C%3A/": true, + "C%3A/C%3A/nested.txt": true, + } + for _, entry := range reader.File { + if entry.Name != "" && want[entry.Name] { + delete(want, entry.Name) + } + if strings.Contains(entry.Name, ":") || strings.HasPrefix(entry.Name, "C:/") { + t.Fatalf("unsafe archive entry name %q", entry.Name) + } + } + if len(want) != 0 { + t.Fatalf("archive is missing expected entries: %v", want) + } +} + +func TestDirectoryArchiveEscapesDistinctNamesWithoutCollisions(t *testing.T) { + fsys := filesystem.NewMemMapFS() + if err := fsys.MkdirAll("/data/media/folder", 0o755); err != nil { + t.Fatal(err) + } + files := map[string]string{ + "a:b.txt": "colon", + "a_b.txt": "underscore", + "a%3Ab.txt": "percent", + } + for name, content := range files { + if err := fsys.WriteFile("/data/media/folder/"+name, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + archive, err := prepareDirectoryArchive(context.Background(), fsys, "/data/media/folder", "/data/media") + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + if err := archive.WriteTo(context.Background(), &output); err != nil { + t.Fatal(err) + } + reader, err := zip.NewReader(bytes.NewReader(output.Bytes()), int64(output.Len())) + if err != nil { + t.Fatal(err) + } + want := map[string]string{ + "folder/a%3Ab.txt": "colon", + "folder/a_b.txt": "underscore", + "folder/a%253Ab.txt": "percent", + } + for _, entry := range reader.File { + if entry.FileInfo().IsDir() { + continue + } + expected, ok := want[entry.Name] + if !ok { + t.Fatalf("unexpected or duplicate archive entry %q", entry.Name) + } + 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) != expected { + t.Fatalf("archive content for %q = %q, want %q", entry.Name, content, expected) + } + delete(want, entry.Name) + } + if len(want) != 0 { + t.Fatalf("archive is missing entries: %v", want) + } +} + +func TestDirectoryArchiveRejectsCaseInsensitiveNameCollisions(t *testing.T) { + fsys := filesystem.NewMemMapFS() + if err := fsys.MkdirAll("/data/media/folder", 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"Foo.txt", "foo.txt"} { + if err := fsys.WriteFile("/data/media/folder/"+name, []byte(name), 0o644); err != nil { + t.Fatal(err) + } + } + + if _, err := prepareDirectoryArchive(context.Background(), fsys, "/data/media/folder", "/data/media"); !errors.Is(err, ErrInvalidOperation) { + t.Fatalf("prepare archive error = %v, want %v", err, ErrInvalidOperation) + } +} + +func TestDirectoryArchiveRejectsWindowsTrailingDotCollisions(t *testing.T) { + fsys := filesystem.NewMemMapFS() + if err := fsys.MkdirAll("/data/media/folder", 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"report", "report."} { + if err := fsys.WriteFile("/data/media/folder/"+name, []byte(name), 0o644); err != nil { + t.Fatal(err) + } + } + + if _, err := prepareDirectoryArchive(context.Background(), fsys, "/data/media/folder", "/data/media"); !errors.Is(err, ErrInvalidOperation) { + t.Fatalf("prepare archive error = %v, want %v", err, ErrInvalidOperation) + } +} + +func TestArchiveCollisionKeyRejectsWindowsDotSegments(t *testing.T) { + for _, name := range []string{"folder/. /file.txt", "folder/.. /file.txt", ".. "} { + if _, ok := archiveCollisionKey(name); ok { + t.Errorf("archiveCollisionKey(%q) accepted a Windows dot segment", name) + } + } +} diff --git a/backend/internal/service/file.go b/backend/internal/service/file.go index 472aef2..f1b45ca 100644 --- a/backend/internal/service/file.go +++ b/backend/internal/service/file.go @@ -70,6 +70,7 @@ type FileCommander interface { // FileStreamer contains streaming-oriented filesystem operations. type FileStreamer interface { OpenFile(ctx context.Context, path string) (File, *model.FileInfo, error) + PrepareDirectoryArchive(ctx context.Context, path string) (*DirectoryArchive, error) ResolvePath(path string) (*model.MountPoint, string, error) // GetFilesystem returns the underlying filesystem for advanced operations GetFilesystem() filesystem.FS @@ -454,6 +455,24 @@ func (s *fileService) OpenFile(ctx context.Context, path string) (File, *model.F return file, &fileInfo, nil } +func (s *fileService) PrepareDirectoryArchive(ctx context.Context, path string) (*DirectoryArchive, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + mount, fsPath, err := s.ResolvePath(path) + if err != nil { + if errors.Is(err, validator.ErrOutsideMountPoint) { + return nil, ErrMountPointNotFound + } + return nil, err + } + fsPath, err = resolveExistingPathWithinMount(s.fs, mount, fsPath) + if err != nil { + return nil, err + } + return prepareDirectoryArchive(ctx, s.fs, fsPath, mount.Path) +} + // CreateFile creates a new file for writing using the filesystem abstraction func (s *fileService) CreateFile(ctx context.Context, path string) (WriteFile, error) { // Resolve the path to filesystem path diff --git a/backend/internal/service/path_safety.go b/backend/internal/service/path_safety.go index 11a34b0..270a1fd 100644 --- a/backend/internal/service/path_safety.go +++ b/backend/internal/service/path_safety.go @@ -74,6 +74,14 @@ func startsWithParent(path string) bool { } func verifyOpenFileWithinMount(fsys boxfs.FS, mount *model.MountPoint, opened any) error { + root, err := fsys.EvalSymlinks(mount.Path) + if err != nil { + return err + } + return verifyOpenFileWithinRoot(fsys, root, opened) +} + +func verifyOpenFileWithinRoot(fsys boxfs.FS, root string, opened any) error { if runtime.GOOS != "linux" { return nil } @@ -86,7 +94,7 @@ func verifyOpenFileWithinMount(fsys boxfs.FS, mount *model.MountPoint, opened an return nil } actual = strings.TrimSuffix(actual, " (deleted)") - root, err := fsys.EvalSymlinks(mount.Path) + root, err = fsys.EvalSymlinks(root) if err != nil { return err } diff --git a/backend/internal/service/share.go b/backend/internal/service/share.go index bb6a259..b876c49 100644 --- a/backend/internal/service/share.go +++ b/backend/internal/service/share.go @@ -37,9 +37,11 @@ var ( // ShareService manages file and folder share links. type ShareService interface { // Create shares an existing file or directory and returns the new share. - Create(ctx context.Context, username string, path string, permissions model.SharePermissions, expiresAt time.Time) (*model.Share, error) + Create(ctx context.Context, username string, path string, settings ShareSettings, expiresAt time.Time) (*model.Share, error) // List returns the user's active (non-revoked, non-expired) shares, newest first. List(username string) ([]model.Share, error) + // Update changes the permissions and upload limit on one of the user's folder shares. + Update(username string, id string, settings ShareSettings) (*model.Share, error) // Revoke permanently disables one of the user's shares by ID. Revoke(username string, id string) error // ResolveForRecipient returns the share for a token, or ErrShareNotFound for @@ -58,6 +60,16 @@ type ShareService interface { // WriteForRecipientPath creates or replaces a file below a writable shared // folder and returns the number of bytes written and its name. WriteForRecipientPath(ctx context.Context, token string, relativePath string, body io.Reader) (int64, string, error) + // DeleteForRecipientPath removes a file or subfolder below a deletable share. + DeleteForRecipientPath(ctx context.Context, token string, relativePath string) error + // PrepareDirectoryArchive validates a recipient folder and prepares its ZIP archive. + PrepareDirectoryArchive(ctx context.Context, token string, relativePath string) (*DirectoryArchive, error) +} + +// ShareSettings contains the recipient capabilities and per-link upload limit. +type ShareSettings struct { + Permissions model.SharePermissions + MaxUploadBytes int64 } // ShareServiceConfig holds configuration for the share service. @@ -81,17 +93,19 @@ type shareService struct { // shareRecord is the persistence shape for a share. It is separate from // model.Share so internal routing fields stay out of any marshaled API response. type shareRecord struct { - ID string `json:"id"` - Token string `json:"token"` - MountName string `json:"mountName"` - RelPath string `json:"relPath"` - IsFolder bool `json:"isFolder"` - Permissions model.SharePermissions `json:"permissions"` - FileName string `json:"fileName"` - CreatedAt time.Time `json:"createdAt"` - ExpiresAt time.Time `json:"expiresAt,omitempty"` - Revoked bool `json:"revoked"` - CreatedBy string `json:"createdBy"` + ID string `json:"id"` + Token string `json:"token"` + MountName string `json:"mountName"` + RelPath string `json:"relPath"` + IsFolder bool `json:"isFolder"` + Permissions model.SharePermissions `json:"permissions"` + MaxUploadBytes int64 `json:"maxUploadBytes,omitempty"` + LegacyReplace bool `json:"legacyReplace,omitempty"` + FileName string `json:"fileName"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt time.Time `json:"expiresAt,omitempty"` + Revoked bool `json:"revoked"` + CreatedBy string `json:"createdBy"` } type sharesData struct { @@ -121,13 +135,17 @@ func NewShareService(fsys filesystem.FS, cfg ShareServiceConfig) ShareService { return shareSvc } -func (s *shareService) Create(ctx context.Context, username string, path string, permissions model.SharePermissions, expiresAt time.Time) (*model.Share, error) { +func (s *shareService) Create(ctx context.Context, username string, path string, settings ShareSettings, expiresAt time.Time) (*model.Share, error) { if s.storageErr != nil { return nil, s.storageErr } if username == "" { return nil, ErrInvalidOperation } + maxUploadBytes, err := s.resolveUploadLimit(settings.MaxUploadBytes) + if err != nil { + return nil, err + } mount, fsPath, err := validator.ValidatePathAgainstMounts(path, s.mounts()) if err != nil { return nil, err @@ -141,19 +159,26 @@ func (s *shareService) Create(ctx context.Context, username string, path string, return nil, err } isFolder := info.IsDir() + permissions := settings.Permissions if !isFolder && !info.Mode().IsRegular() { return nil, ErrNotFile } if isFolder { - // Folder links use a small, Drive-like permission model: viewers can - // browse/download and editors can also add or replace files. - permissions = model.SharePermissions{View: true, Download: true, Write: permissions.Write} + if permissions.Delete && !permissions.Upload { + return nil, ErrInvalidOperation + } + permissions.View = true + permissions.Download = true + if !permissions.Upload { + permissions.Delete = false + permissions.LegacyReplace = false + } } else { // A file link always includes the complete file experience. There is no // useful standalone "edit" mode for a single file share. permissions = model.SharePermissions{View: true, Download: true} } - if permissions.Write && mount.ReadOnly { + if (permissions.Upload || permissions.Delete) && mount.ReadOnly { return nil, ErrPermissionDenied } mountRoot, err := s.fs.EvalSymlinks(mount.Path) @@ -173,16 +198,17 @@ func (s *shareService) Create(ctx context.Context, username string, path string, return nil, err } share := model.Share{ - ID: uuid.New().String(), - Token: token, - MountName: mount.Name, - RelPath: relPath, - IsFolder: isFolder, - Permissions: permissions, - FileName: info.Name(), - CreatedAt: time.Now().UTC(), - ExpiresAt: expiresAt, - CreatedBy: username, + ID: uuid.New().String(), + Token: token, + MountName: mount.Name, + RelPath: relPath, + IsFolder: isFolder, + Permissions: permissions, + MaxUploadBytes: maxUploadBytes, + FileName: info.Name(), + CreatedAt: time.Now().UTC(), + ExpiresAt: expiresAt, + CreatedBy: username, } s.mu.Lock() @@ -195,6 +221,16 @@ func (s *shareService) Create(ctx context.Context, username string, path string, return &share, nil } +func (s *shareService) resolveUploadLimit(requested int64) (int64, error) { + if requested < 0 || requested > s.maxUploadBytes { + return 0, ErrInvalidOperation + } + if requested == 0 { + return s.maxUploadBytes, nil + } + return requested, nil +} + func (s *shareService) List(username string) ([]model.Share, error) { if s.storageErr != nil { return nil, s.storageErr @@ -213,7 +249,7 @@ func (s *shareService) List(username string) ([]model.Share, error) { if record.CreatedBy != username || record.Revoked || isExpired(record.ExpiresAt, now) { continue } - shares = append(shares, fromShareRecord(record)) + shares = append(shares, s.shareFromRecord(record)) } sort.Slice(shares, func(i, j int) bool { return shares[i].CreatedAt.After(shares[j].CreatedAt) }) return shares, nil @@ -240,6 +276,63 @@ func (s *shareService) Revoke(username string, id string) error { return ErrShareNotFound } +func (s *shareService) Update(username string, id string, settings ShareSettings) (*model.Share, error) { + if s.storageErr != nil { + return nil, s.storageErr + } + if username == "" { + return nil, ErrInvalidOperation + } + maxUploadBytes, err := s.resolveUploadLimit(settings.MaxUploadBytes) + if err != nil { + return nil, err + } + requestedPermissions := settings.Permissions + if !requestedPermissions.View && !requestedPermissions.Download && !requestedPermissions.Upload && !requestedPermissions.Delete { + return nil, ErrInvalidOperation + } + if requestedPermissions.Delete && !requestedPermissions.Upload { + return nil, ErrInvalidOperation + } + permissions := model.SharePermissions{ + View: true, + Download: true, + Upload: requestedPermissions.Upload, + Delete: requestedPermissions.Delete, + LegacyReplace: requestedPermissions.LegacyReplace && requestedPermissions.Upload && !requestedPermissions.Delete, + } + + s.mu.Lock() + defer s.mu.Unlock() + + data := s.loadLocked() + for i := range data.Shares { + record := &data.Shares[i] + if record.ID != id { + continue + } + if record.CreatedBy != username || record.Revoked || isExpired(record.ExpiresAt, time.Now()) || !record.IsFolder { + return nil, ErrShareNotFound + } + share := fromShareRecord(*record) + mount, _, err := s.resolveShareTarget(&share) + if err != nil { + return nil, err + } + if (permissions.Upload || permissions.Delete) && mount.ReadOnly { + return nil, ErrPermissionDenied + } + share.Permissions = permissions + share.MaxUploadBytes = maxUploadBytes + data.Shares[i] = toShareRecord(share) + if err := s.saveLocked(data); err != nil { + return nil, err + } + return &share, nil + } + return nil, ErrShareNotFound +} + func (s *shareService) ResolveForRecipient(token string) (*model.Share, error) { if s.storageErr != nil { return nil, s.storageErr @@ -259,7 +352,7 @@ func (s *shareService) resolveActiveShareLocked(token string, now time.Time) (*m if record.Revoked || isExpired(record.ExpiresAt, now) { return nil, ErrShareNotFound } - share := fromShareRecord(record) + share := s.shareFromRecord(record) return &share, nil } return nil, ErrShareNotFound @@ -388,6 +481,21 @@ func (s *shareService) ListForRecipient(ctx context.Context, token string, relat return &model.ShareDirectoryResponse{Path: cleanPath, Items: items}, nil } +func (s *shareService) PrepareDirectoryArchive(ctx context.Context, token string, relativePath string) (*DirectoryArchive, error) { + share, err := s.ResolveForRecipient(token) + if err != nil { + return nil, err + } + if !share.Permissions.Download { + return nil, ErrPermissionDenied + } + _, target, root, err := s.resolveFolderPath(share, relativePath, false) + if err != nil { + return nil, err + } + return prepareDirectoryArchive(ctx, s.fs, target, root) +} + func (s *shareService) OpenForRecipientPath(ctx context.Context, token string, relativePath string) (File, *model.FileInfo, error) { share, err := s.ResolveForRecipient(token) if err != nil { @@ -431,9 +539,12 @@ func (s *shareService) WriteForRecipientPath(ctx context.Context, token string, if err != nil { return 0, "", err } - if !share.IsFolder || !share.Permissions.Write { + if !share.IsFolder || !share.Permissions.Upload { return 0, "", ErrPermissionDenied } + if err := ctx.Err(); err != nil { + return 0, "", err + } mount, target, _, err := s.resolveFolderPath(share, relativePath, true) if err != nil { @@ -442,10 +553,14 @@ func (s *shareService) WriteForRecipientPath(ctx context.Context, token string, if mount.ReadOnly { return 0, "", ErrPermissionDenied } + canReplace := share.Permissions.Delete || share.Permissions.LegacyReplace replacementMode := os.FileMode(0o644) if exists, existsErr := s.fs.Exists(target); existsErr != nil { return 0, "", existsErr } else if exists { + if !canReplace { + return 0, "", ErrPermissionDenied + } info, statErr := s.fs.Stat(target) if statErr != nil { return 0, "", statErr @@ -481,14 +596,14 @@ func (s *shareService) WriteForRecipientPath(ctx context.Context, token string, } }() - written, copyErr := io.Copy(file, io.LimitReader(body, s.maxUploadBytes+1)) + written, copyErr := io.Copy(file, io.LimitReader(body, share.MaxUploadBytes+1)) if copyErr != nil { return 0, "", copyErr } if written == 0 { return 0, "", ErrInvalidOperation } - if written > s.maxUploadBytes { + if written > share.MaxUploadBytes { return 0, "", ErrShareTooLarge } if err := file.Sync(); err != nil { @@ -498,15 +613,19 @@ func (s *shareService) WriteForRecipientPath(ctx context.Context, token string, return 0, "", err } - s.mu.RLock() - defer s.mu.RUnlock() + s.mu.Lock() + defer s.mu.Unlock() share, err = s.resolveActiveShareLocked(token, time.Now()) if err != nil { return 0, "", err } - if !share.IsFolder || !share.Permissions.Write { + if !share.IsFolder || !share.Permissions.Upload { return 0, "", ErrPermissionDenied } + canReplace = share.Permissions.Delete || share.Permissions.LegacyReplace + if written > share.MaxUploadBytes { + return 0, "", ErrShareTooLarge + } finalMount, finalPath, _, err := s.resolveFolderPath(share, relativePath, true) if err != nil { return 0, "", err @@ -517,6 +636,9 @@ func (s *shareService) WriteForRecipientPath(ctx context.Context, token string, if exists, existsErr := s.fs.Exists(finalPath); existsErr != nil { return 0, "", existsErr } else if exists { + if !canReplace { + return 0, "", ErrPermissionDenied + } finalInfo, statErr := s.fs.Stat(finalPath) if statErr != nil { return 0, "", statErr @@ -528,13 +650,52 @@ func (s *shareService) WriteForRecipientPath(ctx context.Context, token string, return 0, "", err } } - if err := s.fs.Rename(temporary, finalPath); err != nil { + if canReplace { + err = s.fs.Rename(temporary, finalPath) + } else { + err = s.fs.RenameNoReplace(temporary, finalPath) + if errors.Is(err, fs.ErrExist) { + return 0, "", ErrPermissionDenied + } + } + if err != nil { return 0, "", err } complete = true return written, filepath.Base(finalPath), nil } +func (s *shareService) DeleteForRecipientPath(ctx context.Context, token string, relativePath string) error { + if err := ctx.Err(); err != nil { + return err + } + s.mu.RLock() + defer s.mu.RUnlock() + + share, err := s.resolveActiveShareLocked(token, time.Now()) + if err != nil { + return err + } + if !share.IsFolder || !share.Permissions.Delete { + return ErrPermissionDenied + } + mount, target, root, err := s.resolveFolderPath(share, relativePath, false) + if err != nil { + return err + } + if mount.ReadOnly || target == root || !pathWithinRoot(root, target) { + return ErrPermissionDenied + } + info, err := s.statTarget(target) + if err != nil { + return err + } + if !info.IsDir() && !info.Mode().IsRegular() { + return ErrNotFile + } + return s.fs.RemoveAll(target) +} + func (s *shareService) resolveFolderPath(share *model.Share, relativePath string, allowMissing bool) (*model.MountPoint, string, string, error) { if !share.IsFolder { return nil, "", "", ErrNotDirectory @@ -719,42 +880,56 @@ func isExpired(expiresAt time.Time, now time.Time) bool { func toShareRecord(share model.Share) shareRecord { return shareRecord{ - ID: share.ID, - Token: share.Token, - MountName: share.MountName, - RelPath: share.RelPath, - IsFolder: share.IsFolder, - Permissions: share.Permissions, - FileName: share.FileName, - CreatedAt: share.CreatedAt, - ExpiresAt: share.ExpiresAt, - Revoked: share.Revoked, - CreatedBy: share.CreatedBy, + ID: share.ID, + Token: share.Token, + MountName: share.MountName, + RelPath: share.RelPath, + IsFolder: share.IsFolder, + Permissions: share.Permissions, + MaxUploadBytes: share.MaxUploadBytes, + LegacyReplace: share.Permissions.LegacyReplace, + FileName: share.FileName, + CreatedAt: share.CreatedAt, + ExpiresAt: share.ExpiresAt, + Revoked: share.Revoked, + CreatedBy: share.CreatedBy, } } func fromShareRecord(record shareRecord) model.Share { + permissions := record.Permissions + permissions.LegacyReplace = record.LegacyReplace || permissions.LegacyReplace + if !permissions.Upload { + permissions.Delete = false + permissions.LegacyReplace = false + } share := model.Share{ - ID: record.ID, - Token: record.Token, - MountName: record.MountName, - RelPath: record.RelPath, - IsFolder: record.IsFolder, - Permissions: record.Permissions, - FileName: record.FileName, - CreatedAt: record.CreatedAt, - ExpiresAt: record.ExpiresAt, - Revoked: record.Revoked, - CreatedBy: record.CreatedBy, + ID: record.ID, + Token: record.Token, + MountName: record.MountName, + RelPath: record.RelPath, + IsFolder: record.IsFolder, + Permissions: permissions, + MaxUploadBytes: record.MaxUploadBytes, + FileName: record.FileName, + CreatedAt: record.CreatedAt, + ExpiresAt: record.ExpiresAt, + Revoked: record.Revoked, + CreatedBy: record.CreatedBy, } - if share.IsFolder { - share.Permissions = model.SharePermissions{ - View: true, - Download: true, - Write: share.Permissions.Write, - } - } else { + if !share.IsFolder { share.Permissions = model.SharePermissions{View: true, Download: true} + } else { + share.Permissions.View = true + share.Permissions.Download = true + } + return share +} + +func (s *shareService) shareFromRecord(record shareRecord) model.Share { + share := fromShareRecord(record) + if share.MaxUploadBytes <= 0 || share.MaxUploadBytes > s.maxUploadBytes { + share.MaxUploadBytes = s.maxUploadBytes } return share } diff --git a/backend/internal/service/share_test.go b/backend/internal/service/share_test.go index 29bb90b..4f096c2 100644 --- a/backend/internal/service/share_test.go +++ b/backend/internal/service/share_test.go @@ -1,10 +1,10 @@ package service import ( + "archive/zip" "bytes" "context" "encoding/base64" - "encoding/json" "errors" "io" "os" @@ -75,7 +75,7 @@ func TestShareCreateRejectsInvalidTargets(t *testing.T) { setupShareTestFS(fsys) shares := newShareTestService(fsys, func() []model.MountPoint { return shareTestMounts() }) - share, err := shares.Create(context.Background(), "owner", test.path, test.perms, time.Time{}) + share, err := shares.Create(context.Background(), "owner", test.path, ShareSettings{Permissions: test.perms}, time.Time{}) if !errors.Is(err, test.wantErr) { t.Fatalf("Create() error = %v, want %v", err, test.wantErr) } @@ -95,13 +95,13 @@ func TestShareCreateSupportsFoldersAndSimplifiesFilePermissions(t *testing.T) { context.Background(), "owner", "media/subdir", - model.SharePermissions{View: true, Write: true}, + ShareSettings{Permissions: model.SharePermissions{View: true, Upload: true}}, time.Time{}, ) if err != nil { t.Fatal(err) } - if !folder.IsFolder || !folder.Permissions.View || !folder.Permissions.Download || !folder.Permissions.Write { + if !folder.IsFolder || !folder.Permissions.View || !folder.Permissions.Download || !folder.Permissions.Upload || folder.Permissions.Delete { t.Fatalf("folder share = %+v", folder) } @@ -109,13 +109,13 @@ func TestShareCreateSupportsFoldersAndSimplifiesFilePermissions(t *testing.T) { context.Background(), "owner", "archive/file.txt", - model.SharePermissions{Write: true}, + ShareSettings{Permissions: model.SharePermissions{Upload: true}}, time.Time{}, ) if err != nil { t.Fatal(err) } - if file.IsFolder || !file.Permissions.View || !file.Permissions.Download || file.Permissions.Write { + if file.IsFolder || !file.Permissions.View || !file.Permissions.Download || file.Permissions.Upload || file.Permissions.Delete { t.Fatalf("file share = %+v", file) } } @@ -126,7 +126,7 @@ func TestShareFolderListsNestedItemsAndKeepsPathsRelative(t *testing.T) { _ = fsys.WriteFile("/data/media/subdir/notes.txt", []byte("notes"), 0o644) shares := newShareTestService(fsys, func() []model.MountPoint { return shareTestMounts() }) - folder, err := shares.Create(context.Background(), "owner", "media/subdir", model.SharePermissions{Write: true}, time.Time{}) + folder, err := shares.Create(context.Background(), "owner", "media/subdir", ShareSettings{Permissions: model.SharePermissions{Upload: true}}, time.Time{}) if err != nil { t.Fatal(err) } @@ -158,7 +158,7 @@ func TestShareFolderEditorCanCreateFileButViewerCannot(t *testing.T) { setupShareTestFS(fsys) shares := newShareTestService(fsys, func() []model.MountPoint { return shareTestMounts() }) - editor, err := shares.Create(context.Background(), "owner", "media/subdir", model.SharePermissions{Write: true}, time.Time{}) + editor, err := shares.Create(context.Background(), "owner", "media/subdir", ShareSettings{Permissions: model.SharePermissions{Upload: true}}, time.Time{}) if err != nil { t.Fatal(err) } @@ -170,7 +170,7 @@ func TestShareFolderEditorCanCreateFileButViewerCannot(t *testing.T) { t.Fatalf("created file = %q, err=%v", content, err) } - viewer, err := shares.Create(context.Background(), "owner", "media/subdir", model.SharePermissions{}, time.Time{}) + viewer, err := shares.Create(context.Background(), "owner", "media/subdir", ShareSettings{}, time.Time{}) if err != nil { t.Fatal(err) } @@ -183,7 +183,7 @@ func TestShareFolderRejectsInvalidRecipientPaths(t *testing.T) { fsys := filesystem.NewMemMapFS() setupShareTestFS(fsys) shares := newShareTestService(fsys, func() []model.MountPoint { return shareTestMounts() }) - folder, err := shares.Create(context.Background(), "owner", "media/subdir", model.SharePermissions{Write: true}, time.Time{}) + folder, err := shares.Create(context.Background(), "owner", "media/subdir", ShareSettings{Permissions: model.SharePermissions{Upload: true}}, time.Time{}) if err != nil { t.Fatal(err) } @@ -205,36 +205,89 @@ func TestShareFolderRejectsInvalidRecipientPaths(t *testing.T) { func TestShareLegacyFilePermissionsAreNormalized(t *testing.T) { fsys := filesystem.NewMemMapFS() setupShareTestFS(fsys) - record := shareRecord{ - ID: "legacy-id", - Token: "legacy-token", - MountName: "media", - RelPath: "file.txt", - Permissions: model.SharePermissions{ - Write: true, - }, - FileName: "file.txt", - CreatedAt: time.Now().UTC(), - CreatedBy: "owner", + const token = "legacy-token" + data := []byte(`{"shares":[{"id":"legacy-id","token":"legacy-token","mountName":"media","relPath":"file.txt","permissions":{"write":true},"fileName":"file.txt","createdAt":"2025-01-01T00:00:00Z","createdBy":"owner"}]}`) + if err := fsys.WriteFile(filepath.Join("/data", config.SharesFileName), data, 0o600); err != nil { + t.Fatal(err) } - data, err := json.Marshal(sharesData{Shares: []shareRecord{record}}) + + shares := newShareTestService(fsys, func() []model.MountPoint { return shareTestMounts() }) + share, err := shares.ResolveForRecipient(token) if err != nil { t.Fatal(err) } + if !share.Permissions.View || !share.Permissions.Download || share.Permissions.Upload || share.Permissions.Delete { + t.Fatalf("legacy file permissions = %+v", share.Permissions) + } + if _, _, err := shares.WriteForRecipientPath(context.Background(), token, "file.txt", strings.NewReader("should stay read-only")); !errors.Is(err, ErrPermissionDenied) { + t.Fatalf("legacy file upload error = %v, want %v", err, ErrPermissionDenied) + } +} + +func TestShareLegacyFolderWriteKeepsReplacementOnlyForLegacyUpdates(t *testing.T) { + fsys := filesystem.NewMemMapFS() + setupShareTestFS(fsys) + const token = "legacy-folder-token" + data := []byte(`{"shares":[{"id":"legacy-folder","token":"legacy-folder-token","mountName":"media","relPath":"","isFolder":true,"permissions":{"write":true},"fileName":"media","createdAt":"2025-01-01T00:00:00Z","createdBy":"owner"}]}`) if err := fsys.WriteFile(filepath.Join("/data", config.SharesFileName), data, 0o600); err != nil { t.Fatal(err) } + shares := NewShareService(fsys, ShareServiceConfig{ + DataDir: "/data", + MaxUploadBytes: 32, + Mounts: func() []model.MountPoint { return shareTestMounts() }, + }) - shares := newShareTestService(fsys, func() []model.MountPoint { return shareTestMounts() }) - share, err := shares.ResolveForRecipient(record.Token) + share, err := shares.ResolveForRecipient(token) if err != nil { t.Fatal(err) } - if !share.Permissions.View || !share.Permissions.Download || share.Permissions.Write { - t.Fatalf("legacy file permissions = %+v", share.Permissions) + if !share.Permissions.Upload || share.Permissions.Delete || !share.Permissions.LegacyReplace { + t.Fatalf("legacy folder permissions = %+v", share.Permissions) } - if _, _, err := shares.WriteForRecipientPath(context.Background(), record.Token, "file.txt", strings.NewReader("should stay read-only")); !errors.Is(err, ErrPermissionDenied) { - t.Fatalf("legacy file upload error = %v, want %v", err, ErrPermissionDenied) + if share.MaxUploadBytes != 32 { + t.Fatalf("legacy upload limit = %d, want configured default 32", share.MaxUploadBytes) + } + if _, _, err := shares.WriteForRecipientPath(context.Background(), token, "file.txt", strings.NewReader("replaced")); err != nil { + t.Fatalf("legacy replacement failed: %v", err) + } + if err := shares.DeleteForRecipientPath(context.Background(), token, "file.txt"); !errors.Is(err, ErrPermissionDenied) { + t.Fatalf("legacy delete error = %v, want %v", err, ErrPermissionDenied) + } + + updated, err := shares.Update("owner", share.ID, ShareSettings{ + Permissions: model.SharePermissions{Upload: true, LegacyReplace: true}, + MaxUploadBytes: 24, + }) + if err != nil { + t.Fatal(err) + } + if !updated.Permissions.LegacyReplace || updated.Permissions.Delete { + t.Fatalf("updated legacy permissions = %+v", updated.Permissions) + } + if _, _, err := shares.WriteForRecipientPath(context.Background(), token, "file.txt", strings.NewReader("again")); err != nil { + t.Fatalf("legacy replacement stopped after settings update: %v", err) + } + + modernUpdate, err := shares.Update("owner", share.ID, ShareSettings{ + Permissions: model.SharePermissions{View: true, Download: true, Upload: true}, + MaxUploadBytes: 24, + }) + if err != nil { + t.Fatal(err) + } + if modernUpdate.Permissions.LegacyReplace || modernUpdate.Permissions.Delete { + t.Fatalf("modern upload-only update retained legacy access: %+v", modernUpdate.Permissions) + } + if _, _, err := shares.WriteForRecipientPath(context.Background(), token, "file.txt", strings.NewReader("blocked")); !errors.Is(err, ErrPermissionDenied) { + t.Fatalf("modern upload-only replacement error = %v, want %v", err, ErrPermissionDenied) + } + contents, err := fsys.ReadFile("/data/media/file.txt") + if err != nil { + t.Fatal(err) + } + if string(contents) != "again" { + t.Fatalf("modern update changed existing file to %q", contents) } } @@ -243,11 +296,11 @@ func TestShareCreateGeneratesHighEntropyTokens(t *testing.T) { setupShareTestFS(fsys) shares := newShareTestService(fsys, func() []model.MountPoint { return shareTestMounts() }) - first, err := shares.Create(context.Background(), "owner", "media/file.txt", model.SharePermissions{View: true}, time.Time{}) + first, err := shares.Create(context.Background(), "owner", "media/file.txt", ShareSettings{Permissions: model.SharePermissions{View: true}}, time.Time{}) if err != nil { t.Fatal(err) } - second, err := shares.Create(context.Background(), "owner", "media/file.txt", model.SharePermissions{View: true}, time.Time{}) + second, err := shares.Create(context.Background(), "owner", "media/file.txt", ShareSettings{Permissions: model.SharePermissions{View: true}}, time.Time{}) if err != nil { t.Fatal(err) } @@ -273,7 +326,7 @@ func TestShareResolveForRecipientIsUniform(t *testing.T) { fsys := filesystem.NewMemMapFS() setupShareTestFS(fsys) shares := newShareTestService(fsys, func() []model.MountPoint { return shareTestMounts() }) - perms := model.SharePermissions{View: true, Download: true, Write: true} + perms := ShareSettings{Permissions: model.SharePermissions{View: true, Download: true, Upload: true}} valid, err := shares.Create(context.Background(), "owner", "media/file.txt", perms, time.Now().Add(time.Hour)) if err != nil { @@ -343,7 +396,7 @@ func TestShareListReturnsActiveSharesOnly(t *testing.T) { fsys := filesystem.NewMemMapFS() setupShareTestFS(fsys) shares := newShareTestService(fsys, func() []model.MountPoint { return shareTestMounts() }) - perms := model.SharePermissions{View: true} + perms := ShareSettings{Permissions: model.SharePermissions{View: true}} active, err := shares.Create(context.Background(), "owner", "media/file.txt", perms, time.Now().Add(time.Hour)) if err != nil { @@ -387,7 +440,7 @@ func TestShareRecipientRevalidatesLiveMounts(t *testing.T) { setupShareTestFS(fsys) mounts := shareTestMounts() shares := newShareTestService(fsys, func() []model.MountPoint { return mounts }) - perms := model.SharePermissions{Write: true} + perms := ShareSettings{Permissions: model.SharePermissions{Upload: true}} share, err := shares.Create(context.Background(), "owner", "media", perms, time.Time{}) if err != nil { @@ -426,7 +479,7 @@ func TestShareFileUploadIsAlwaysDenied(t *testing.T) { setupShareTestFS(fsys) shares := newShareTestService(fsys, func() []model.MountPoint { return shareTestMounts() }) - share, err := shares.Create(context.Background(), "owner", "media/file.txt", model.SharePermissions{View: true, Download: true}, time.Time{}) + share, err := shares.Create(context.Background(), "owner", "media/file.txt", ShareSettings{Permissions: model.SharePermissions{View: true, Download: true}}, time.Time{}) if err != nil { t.Fatal(err) } @@ -441,7 +494,7 @@ func TestShareRecipientRejectsChangedFolderTarget(t *testing.T) { mounts := shareTestMounts() shares := newShareTestService(fsys, func() []model.MountPoint { return mounts }) - share, err := shares.Create(context.Background(), "owner", "media/subdir", model.SharePermissions{}, time.Time{}) + share, err := shares.Create(context.Background(), "owner", "media/subdir", ShareSettings{}, time.Time{}) if err != nil { t.Fatal(err) } @@ -480,7 +533,7 @@ func TestShareFolderUploadIsAtomicAndBounded(t *testing.T) { MaxUploadBytes: 16, Mounts: mounts, }) - perms := model.SharePermissions{View: true, Download: true, Write: true} + perms := ShareSettings{Permissions: model.SharePermissions{View: true, Download: true, Upload: true, Delete: true}} share, err := shares.Create(context.Background(), "owner", "media", perms, time.Time{}) if err != nil { @@ -541,6 +594,122 @@ func TestShareFolderUploadIsAtomicAndBounded(t *testing.T) { assertOnlyEntries(t, media, "file.txt") } +func TestShareUploadOnlyCannotReplaceOrDeleteAndEnforcesLinkLimit(t *testing.T) { + fsys := filesystem.NewMemMapFS() + setupShareTestFS(fsys) + shares := newShareTestService(fsys, func() []model.MountPoint { return shareTestMounts() }) + share, err := shares.Create(context.Background(), "owner", "media", ShareSettings{ + Permissions: model.SharePermissions{Upload: true}, + MaxUploadBytes: 4, + }, time.Time{}) + if err != nil { + t.Fatal(err) + } + if share.MaxUploadBytes != 4 { + t.Fatalf("share upload limit = %d, want 4", share.MaxUploadBytes) + } + + if _, _, err := shares.WriteForRecipientPath(context.Background(), share.Token, "file.txt", strings.NewReader("replacement")); !errors.Is(err, ErrPermissionDenied) { + t.Fatalf("upload-only replacement error = %v, want %v", err, ErrPermissionDenied) + } + if err := shares.DeleteForRecipientPath(context.Background(), share.Token, "file.txt"); !errors.Is(err, ErrPermissionDenied) { + t.Fatalf("upload-only delete error = %v, want %v", err, ErrPermissionDenied) + } + if _, _, err := shares.WriteForRecipientPath(context.Background(), share.Token, "new.txt", strings.NewReader("four")); err != nil { + t.Fatalf("new upload failed: %v", err) + } + if _, _, err := shares.WriteForRecipientPath(context.Background(), share.Token, "too-large.txt", strings.NewReader("five!")); !errors.Is(err, ErrShareTooLarge) { + t.Fatalf("oversized upload error = %v, want %v", err, ErrShareTooLarge) + } + if exists, err := fsys.Exists("/data/media/too-large.txt"); err != nil || exists { + t.Fatalf("oversized upload left a file (exists=%t, err=%v)", exists, err) + } + + managed, err := shares.Update("owner", share.ID, ShareSettings{ + Permissions: model.SharePermissions{Upload: true, Delete: true}, + MaxUploadBytes: 8, + }) + if err != nil { + t.Fatal(err) + } + if !managed.Permissions.Upload || !managed.Permissions.Delete || managed.MaxUploadBytes != 8 { + t.Fatalf("updated share = %+v", managed) + } + if _, _, err := shares.WriteForRecipientPath(context.Background(), share.Token, "file.txt", strings.NewReader("replace")); err != nil { + t.Fatalf("upload-and-delete replacement failed: %v", err) + } + if err := shares.DeleteForRecipientPath(context.Background(), share.Token, ""); !errors.Is(err, ErrPermissionDenied) { + t.Fatalf("shared-root deletion error = %v, want %v", err, ErrPermissionDenied) + } + if err := shares.DeleteForRecipientPath(context.Background(), share.Token, "../outside"); !errors.Is(err, validator.ErrPathTraversal) { + t.Fatalf("traversal deletion error = %v, want %v", err, validator.ErrPathTraversal) + } + if err := shares.DeleteForRecipientPath(context.Background(), share.Token, "new.txt"); err != nil { + t.Fatalf("managed delete failed: %v", err) + } + if exists, err := fsys.Exists("/data/media/new.txt"); err != nil || exists { + t.Fatalf("managed delete left a file (exists=%t, err=%v)", exists, err) + } + + viewer, err := shares.Update("owner", share.ID, ShareSettings{ + Permissions: model.SharePermissions{View: true}, + MaxUploadBytes: 8, + }) + if err != nil { + t.Fatal(err) + } + if viewer.Permissions.Upload || viewer.Permissions.Delete { + t.Fatalf("view-only update kept write permissions: %+v", viewer.Permissions) + } + if _, _, err := shares.WriteForRecipientPath(context.Background(), share.Token, "blocked.txt", strings.NewReader("no")); !errors.Is(err, ErrPermissionDenied) { + t.Fatalf("view-only upload error = %v, want %v", err, ErrPermissionDenied) + } +} + +func TestShareFolderArchiveContainsRelativeZipEntries(t *testing.T) { + fsys := filesystem.NewMemMapFS() + setupShareTestFS(fsys) + _ = fsys.MkdirAll("/data/media/subdir/nested", 0o755) + _ = fsys.WriteFile("/data/media/subdir/nested/notes.txt", []byte("archive content"), 0o644) + shares := newShareTestService(fsys, func() []model.MountPoint { return shareTestMounts() }) + share, err := shares.Create(context.Background(), "owner", "media/subdir", ShareSettings{}, time.Time{}) + if err != nil { + t.Fatal(err) + } + + archive, err := shares.PrepareDirectoryArchive(context.Background(), share.Token, "") + if err != nil { + t.Fatal(err) + } + var buffer bytes.Buffer + if err := archive.WriteTo(context.Background(), &buffer); err != nil { + t.Fatal(err) + } + reader, err := zip.NewReader(bytes.NewReader(buffer.Bytes()), int64(buffer.Len())) + if err != nil { + t.Fatal(err) + } + contents := make(map[string]string, len(reader.File)) + for _, entry := range reader.File { + if strings.Contains(entry.Name, "media/") || strings.HasPrefix(entry.Name, "/") || strings.Contains(entry.Name, "../") { + t.Fatalf("archive entry escaped share-relative names: %q", entry.Name) + } + 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 entry %q: read=%v close=%v", entry.Name, readErr, closeErr) + } + contents[entry.Name] = string(content) + } + if contents["subdir/nested/notes.txt"] != "archive content" { + t.Fatalf("archive contents = %+v", contents) + } +} + func assertOnlyEntries(t *testing.T, dir string, allowed ...string) { t.Helper() allowedSet := make(map[string]bool, len(allowed)) @@ -581,7 +750,7 @@ func TestShareWriteRenamesOverResolvedTargetNotSymlink(t *testing.T) { }) // Sharing a symlink pins the resolved target inside the mount. - share, err := shares.Create(context.Background(), "owner", "media", model.SharePermissions{Write: true}, time.Time{}) + share, err := shares.Create(context.Background(), "owner", "media", ShareSettings{Permissions: model.SharePermissions{Upload: true, Delete: true}}, time.Time{}) if err != nil { t.Fatal(err) } @@ -633,7 +802,7 @@ func TestShareRejectsSymlinkEscapingMount(t *testing.T) { }, }) - if _, err := shares.Create(context.Background(), "owner", "media/escape.txt", model.SharePermissions{View: true}, time.Time{}); !errors.Is(err, ErrPermissionDenied) { + if _, err := shares.Create(context.Background(), "owner", "media/escape.txt", ShareSettings{Permissions: model.SharePermissions{View: true}}, time.Time{}); !errors.Is(err, ErrPermissionDenied) { t.Fatalf("Create() through escaping symlink = %v, want %v", err, ErrPermissionDenied) } } @@ -643,11 +812,11 @@ func TestShareManagementIsScopedToOwner(t *testing.T) { setupShareTestFS(fsys) shares := newShareTestService(fsys, func() []model.MountPoint { return shareTestMounts() }) - aliceShare, err := shares.Create(context.Background(), "alice", "media/file.txt", model.SharePermissions{View: true}, time.Time{}) + aliceShare, err := shares.Create(context.Background(), "alice", "media/file.txt", ShareSettings{Permissions: model.SharePermissions{View: true}}, time.Time{}) if err != nil { t.Fatal(err) } - bobShare, err := shares.Create(context.Background(), "bob", "media/file.txt", model.SharePermissions{Download: true}, time.Time{}) + bobShare, err := shares.Create(context.Background(), "bob", "media/file.txt", ShareSettings{Permissions: model.SharePermissions{Download: true}}, time.Time{}) if err != nil { t.Fatal(err) } @@ -679,7 +848,7 @@ func TestShareRevocationDuringUploadPreventsCommit(t *testing.T) { setupShareTestFS(fsys) shares := newShareTestService(fsys, func() []model.MountPoint { return shareTestMounts() }) - share, err := shares.Create(context.Background(), "owner", "media", model.SharePermissions{Write: true}, time.Time{}) + share, err := shares.Create(context.Background(), "owner", "media", ShareSettings{Permissions: model.SharePermissions{Upload: true, Delete: true}}, time.Time{}) if err != nil { t.Fatal(err) } @@ -731,7 +900,7 @@ func TestShareRevokeWaitsForInFlightCommit(t *testing.T) { } shares := newShareTestService(fsys, func() []model.MountPoint { return shareTestMounts() }) - share, err := shares.Create(context.Background(), "owner", "media", model.SharePermissions{Write: true}, time.Time{}) + share, err := shares.Create(context.Background(), "owner", "media", ShareSettings{Permissions: model.SharePermissions{Upload: true, Delete: true}}, time.Time{}) if err != nil { t.Fatal(err) } @@ -802,7 +971,7 @@ func TestShareStoreUsesPrivatePermissions(t *testing.T) { assertPrivatePermissions(t, dataDir, 0o700) assertPrivatePermissions(t, sharesPath, 0o600) - if _, err := shares.Create(context.Background(), "owner", "media/file.txt", model.SharePermissions{View: true}, time.Time{}); err != nil { + if _, err := shares.Create(context.Background(), "owner", "media/file.txt", ShareSettings{Permissions: model.SharePermissions{View: true}}, time.Time{}); err != nil { t.Fatal(err) } assertPrivatePermissions(t, dataDir, 0o700) diff --git a/docs/api.md b/docs/api.md index a8a98e0..1c138eb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -198,6 +198,14 @@ GET /api/v1/stream/download/{path} The download endpoint supports HTTP range requests. +### Folder Archive + +```http +GET /api/v1/stream/archive/{folderPath} +``` + +Requires authentication and streams the folder as an `application/zip` attachment. + ### Preview ```http @@ -256,7 +264,7 @@ GET /api/v1/stream/upload/status/?uploadId=upload_123 ## Share Links -Share links expose exactly one existing file through a token URL. Management endpoints require a JWT; recipient endpoints are public and authenticate by share token alone. +Share links expose an existing file or folder through a token URL. Management endpoints require a JWT; recipient endpoints are public and authenticate by share token alone. ### Create a Share @@ -265,13 +273,14 @@ POST /api/v1/shares Content-Type: application/json { - "path": "home/projects/notes.txt", - "permissions": { "view": true, "download": true, "write": false }, + "path": "home/projects/shared", + "permissions": { "view": true, "download": true, "upload": true, "delete": false }, + "maxUploadBytes": 104857600, "expiresInSeconds": 3600 } ``` -`expiresInSeconds` is optional; omit it for a share that never expires. `write` is rejected for files on read-only mount points. +`expiresInSeconds` is optional; omit it for a share that never expires. Folder links always allow viewing and downloading. Set `upload` to allow new files, and set both `upload` and `delete` to allow replacing or removing items below the shared folder. The optional `maxUploadBytes` value is a per-file cap; omitting it uses the server's `max_upload_mb` limit. The server cap always applies. File links always use view/download permissions. Share responses include the effective `canReplace` capability; it is true when delete is allowed or a persisted legacy `write` permission retains replacement access. Requests cannot set `canReplace`. Response uses `201 Created`: @@ -280,8 +289,10 @@ Response uses `201 Created`: "id": "b0c1d2e3-...", "token": "7nArUufciyjMLLFstZGU_zIerVgTpr2Qr2eL1lUTJFY", "url": "/s/7nArUufciyjMLLFstZGU_zIerVgTpr2Qr2eL1lUTJFY", - "fileName": "notes.txt", - "permissions": { "view": true, "download": true, "write": false }, + "fileName": "shared", + "permissions": { "view": true, "download": true, "upload": true, "delete": false, "canReplace": false }, + "maxUploadBytes": 104857600, + "isFolder": true, "createdAt": "2026-09-02T09:00:00Z", "expiresAt": "2026-09-02T10:00:00Z" } @@ -293,7 +304,21 @@ Response uses `201 Created`: GET /api/v1/shares ``` -Returns only the authenticated caller's active (non-revoked, non-expired) shares. Each entry includes the `token`, `url`, `fileName`, mount-relative `path`, `permissions`, and expiry. +Returns only the authenticated caller's active (non-revoked, non-expired) shares. Each entry includes the `token`, `url`, `fileName`, mount-relative `path`, `permissions`, `maxUploadBytes`, `isFolder`, and expiry. + +### Update a Folder Share + +```http +PATCH /api/v1/shares/{id} +Content-Type: application/json + +{ + "permissions": { "view": true, "download": true, "upload": true, "delete": true }, + "maxUploadBytes": 52428800 +} +``` + +Updates permissions and the per-file upload cap on an active folder share owned by the caller. `delete` requires `upload`; a value of `0` for `maxUploadBytes` restores the server's configured maximum. ### Revoke a Share @@ -315,10 +340,12 @@ Returns recipient-facing metadata only; mount names and internal paths are never ```json { - "fileName": "notes.txt", + "fileName": "shared", "size": 1024, - "mimeType": "text/plain; charset=utf-8", - "permissions": { "view": true, "download": true, "write": false }, + "mimeType": "application/octet-stream", + "permissions": { "view": true, "download": true, "upload": true, "delete": false, "canReplace": false }, + "maxUploadBytes": 104857600, + "isFolder": true, "expiresAt": "2026-09-02T10:00:00Z" } ``` @@ -326,22 +353,26 @@ Returns recipient-facing metadata only; mount names and internal paths are never ```http GET /api/v1/share/{token}/download GET /api/v1/share/{token}/preview +GET /api/v1/share/{token}/items?path=reports +GET /api/v1/share/{token}/archive?path=reports +POST /api/v1/share/{token}/upload?path=reports/new.txt +DELETE /api/v1/share/{token}/items?path=reports/old.txt ``` -Download streams the file as an attachment; preview streams it inline for browser media. Both support HTTP range requests, set a sandboxed `Content-Security-Policy`, and force attachment disposition for active document formats (HTML, SVG, XML). +Folder item paths are relative to the share root. Archive downloads include only paths inside that root. Delete requires the `delete` permission and cannot remove the shared root. Download streams a file as an attachment; preview streams it inline for browser media. Both support HTTP range requests, set a sandboxed `Content-Security-Policy`, and force attachment disposition for active document formats (HTML, SVG, XML). Unknown, expired, and revoked tokens all return the same `404`, so recipients cannot distinguish between them. Requests the share's permissions do not allow return `403`. -### Overwrite Through a Share +### Upload Through a Folder Share ```http -POST /api/v1/share/{token}/upload +POST /api/v1/share/{token}/upload?path=reports/new.txt Content-Type: application/octet-stream [file body] ``` -Requires the `write` permission and a writable mount point. The body replaces the shared file atomically (temporary file plus rename) and is capped by `max_upload_mb`. If a revocation completes while the body is still uploading, the pending overwrite is discarded: +Requires the `upload` permission and a writable mount point. Upload-only links create new files but cannot replace an existing item; links with `delete` can also replace or delete items below the shared folder. Uploads are atomically published and capped by both the per-link `maxUploadBytes` and server `max_upload_mb` limits. If a revocation completes while the body is still uploading, the pending upload is discarded: ```json { @@ -350,7 +381,7 @@ Requires the `write` permission and a writable mount point. The body replaces th } ``` -`403` means the share does not allow updates; `413` means the body exceeded the size limit. +`403` means the share does not allow this operation; `413` means the body exceeded a size limit. Existing persisted `write` links retain their historical replacement behavior but do not gain delete permission. ## Search diff --git a/docs/security.md b/docs/security.md index 91775cb..df5e324 100644 --- a/docs/security.md +++ b/docs/security.md @@ -119,16 +119,17 @@ Use `max_upload_mb` to cap accepted upload sizes. ## Share Links -Share links expose exactly one file to anyone holding the token URL: +Share links expose one file or a folder tree to anyone holding the token URL: - Tokens are 256-bit random values (43 URL-safe characters); possession of the token is the only credential for recipient endpoints. - Recipient endpoints are public and rate-limited per client IP, separately from the stricter auth budget. - Unknown, expired, and revoked tokens all return the same `404`, so recipients cannot probe why a link stopped working. -- Share management is scoped to the creating account: users can list and revoke only their own links. -- Owners can revoke a link at any time; expiry is optional (`expiresInSeconds` at creation). A revocation that completes during an upload prevents that upload's final overwrite. -- The shared file is re-resolved against the current mount configuration on every recipient access. Removed or renamed mounts invalidate the link, and a mount that became read-only still serves reads while denying overwrites. +- Share management is scoped to the creating account: users can list, update, and revoke only their own links. +- Owners can revoke a link at any time; expiry is optional (`expiresInSeconds` at creation). A revocation that completes during an upload prevents that upload from being published. +- Shared targets are re-resolved against the current mount configuration on every recipient access. Removed or renamed mounts invalidate the link, and a mount that became read-only still serves reads while denying uploads or deletes. - Recipient downloads and previews carry the same sandboxed `Content-Security-Policy` as the app's streaming endpoints, and active document formats (HTML, SVG, XML) are forced to download instead of rendering inline. -- Recipient overwrites are single-shot, capped by `max_upload_mb`, and applied atomically (temporary file plus rename), so a failed upload never corrupts the original. +- Folder uploads are capped by both the per-link `maxUploadBytes` and server `max_upload_mb` limits. Upload-only links cannot replace existing items; delete permission also permits replacement and deletion below (never at) the shared root. +- Folder ZIP archives validate traversal and resolved paths against the share root before streaming. - Recipient metadata responses never include mount names or internal paths. - The share store directory is restricted to owner access (`0700`) and its token-bearing `shares.json` file to `0600`, including existing stores when the service initializes. diff --git a/frontend/src/lib/api/files.ts b/frontend/src/lib/api/files.ts index 797d66a..48795e7 100644 --- a/frontend/src/lib/api/files.ts +++ b/frontend/src/lib/api/files.ts @@ -286,6 +286,14 @@ export function getDownloadUrl(path: string): string { return token ? `${baseUrl}?token=${encodeURIComponent(token)}` : baseUrl; } +/** Get the download URL for a folder ZIP archive. */ +export function getDirectoryArchiveUrl(path: string): string { + const token = tokenStorage.getAccessToken(); + const encodedPath = encodeRoutePath(path); + const baseUrl = `/api/v1/stream/archive/${encodedPath}`; + return token ? `${baseUrl}?token=${encodeURIComponent(token)}` : baseUrl; +} + /** * Fetch file content as text (for code/text preview) */ diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts index 5537bdf..d643b8b 100644 --- a/frontend/src/lib/api/index.ts +++ b/frontend/src/lib/api/index.ts @@ -31,6 +31,7 @@ export { saveFileContent, deleteFile, search, + getDirectoryArchiveUrl, type FileInfo, type FileList, type MountPoint, @@ -65,13 +66,16 @@ export { createShare, listShares, revokeShare, + deleteShareItem, getShareInfo, sharePageUrl, shareDownloadUrl, + shareArchiveUrl, sharePreviewUrl, shareUploadUrl, hasShareExpiry, type SharePermissions, + type SharePermissionInput, type ShareRecord, type ShareListResponse, type CreateShareOptions, diff --git a/frontend/src/lib/api/shares.ts b/frontend/src/lib/api/shares.ts index e63676f..2ec1fe8 100644 --- a/frontend/src/lib/api/shares.ts +++ b/frontend/src/lib/api/shares.ts @@ -11,9 +11,16 @@ import { api, apiRequest } from './client'; export interface SharePermissions { view: boolean; download: boolean; - write: boolean; + upload: boolean; + delete: boolean; + canReplace: boolean; } +export type SharePermissionInput = Pick< + SharePermissions, + 'view' | 'download' | 'upload' | 'delete' +>; + /** * Active share in the owner's share list */ @@ -25,6 +32,7 @@ export interface ShareRecord { path: string; isFolder: boolean; permissions: SharePermissions; + maxUploadBytes: number; createdAt: string; expiresAt?: string; } @@ -40,8 +48,10 @@ export interface ShareListResponse { * Options when creating a share link */ export interface CreateShareOptions { - /** Legacy file permission payload; folder shares may use explicit permissions. */ - permissions?: SharePermissions; + /** File shares always use view/download; folder shares can also upload or delete. */ + permissions?: SharePermissionInput; + /** Optional per-file upload cap; omitted means the server's configured maximum. */ + maxUploadBytes?: number; /** Seconds until the share expires; omitted means it never expires */ expiresInSeconds?: number; } @@ -56,6 +66,7 @@ export interface CreateShareResponse { fileName: string; isFolder: boolean; permissions: SharePermissions; + maxUploadBytes: number; createdAt: string; expiresAt?: string; } @@ -69,6 +80,7 @@ export interface ShareInfoResponse { mimeType: string; isFolder: boolean; permissions: SharePermissions; + maxUploadBytes: number; expiresAt?: string; } @@ -89,7 +101,7 @@ export interface ShareDirectoryResponse { items: ShareItem[]; } -interface RevokeShareResponse { +interface ShareActionResponse { success: boolean; } @@ -104,6 +116,7 @@ export async function createShare( return api.post('/shares', { path, ...(options.permissions ? { permissions: options.permissions } : {}), + ...(options.maxUploadBytes !== undefined ? { maxUploadBytes: options.maxUploadBytes } : {}), expiresInSeconds: options.expiresInSeconds ?? null }); } @@ -120,8 +133,17 @@ export async function listShares(): Promise { * Revoke a share link * DELETE /api/v1/shares/{id} */ -export async function revokeShare(id: string): Promise { - return api.delete(`/shares/${encodeURIComponent(id)}`); +export async function revokeShare(id: string): Promise { + return api.delete(`/shares/${encodeURIComponent(id)}`); +} + +/** Remove an item below a shared folder. The token is the only credential. */ +export async function deleteShareItem(token: string, path: string): Promise { + return apiRequest(`/share/${encodeURIComponent(token)}/items`, { + method: 'DELETE', + skipAuth: true, + params: { path } + }); } /** @@ -158,6 +180,12 @@ export function shareDownloadUrl(token: string, path?: string): string { return `/api/v1/share/${encodeURIComponent(token)}/download${query}`; } +/** Download a shared folder or subfolder as a ZIP archive. */ +export function shareArchiveUrl(token: string, path?: string): string { + const query = path ? `?path=${encodeURIComponent(path)}` : ''; + return `/api/v1/share/${encodeURIComponent(token)}/archive${query}`; +} + /** * Inline preview URL for a shared file (browser media streaming with ranges) */ diff --git a/frontend/src/lib/components/FolderShareModal.svelte b/frontend/src/lib/components/FolderShareModal.svelte index 4e82c94..847b32d 100644 --- a/frontend/src/lib/components/FolderShareModal.svelte +++ b/frontend/src/lib/components/FolderShareModal.svelte @@ -8,11 +8,12 @@ revokeShare, type CreateShareResponse, type FileInfo, - type SharePermissions, + type SharePermissionInput, type ShareRecord } from '$lib/api'; import { toastStore } from '$lib/stores/toast.svelte'; - import { formatDate } from '$lib/utils/format'; + import { formatDate, formatFileSize } from '$lib/utils/format'; + import { getShareAccessLabel } from '$lib/utils/shareAccess'; interface Props { open?: boolean; @@ -26,7 +27,14 @@ { value: '604800', label: '1 week' }, { value: '2592000', label: '30 days' } ]; - let access = $state<'view' | 'edit'>('view'); + type FolderShareAccess = 'view' | 'upload' | 'upload-delete'; + const ACCESS_OPTIONS: Array<{ value: FolderShareAccess; label: string; detail: string }> = [ + { value: 'view', label: 'View only', detail: 'Browse and download files' }, + { value: 'upload', label: 'Upload only', detail: 'Add files, but not replace or delete' }, + { value: 'upload-delete', label: 'Upload + delete', detail: 'Add, replace, and remove files' } + ]; + let access = $state('view'); + let maxUploadMB = $state(undefined); let expiry = $state('0'); let creating = $state(false); let loading = $state(false); @@ -43,6 +51,7 @@ $effect(() => { if (open && folder) { access = 'view'; + maxUploadMB = undefined; expiry = '0'; created = null; copied = false; @@ -66,16 +75,28 @@ async function handleCreate() { if (!folder || creating) return; + if ( + access !== 'view' && + maxUploadMB !== undefined && + (!Number.isSafeInteger(maxUploadMB) || maxUploadMB < 1) + ) { + error = 'Enter a positive whole number for the upload limit.'; + return; + } creating = true; error = null; try { - const permissions: SharePermissions = { + const permissions: SharePermissionInput = { view: true, download: true, - write: access === 'edit' + upload: access !== 'view', + delete: access === 'upload-delete' }; created = await createShare(folder.path, { permissions, + ...(access !== 'view' && maxUploadMB !== undefined + ? { maxUploadBytes: maxUploadMB * 1024 * 1024 } + : {}), ...(expiry !== '0' ? { expiresInSeconds: Number(expiry) } : {}) }); await loadShares(); @@ -164,9 +185,10 @@ {share.url} - {share.permissions.write ? 'Editor' : 'Viewer'} + {getShareAccessLabel(share.permissions)} + {#if share.permissions.upload}Max {formatFileSize(share.maxUploadBytes)} per file{/if} {formatExpiry(share)} + + {#if error}{/if} + {#if message}

{message}

{/if} + {#if loading} +
+ {:else if shares.length === 0} +

+ No active share links. +

+ {:else} +
    + {#each shares as share (share.id)} +
  • +
    + {share.fileName} +

    {share.path}

    +

    + {share.isFolder ? 'Folder' : 'File'} · {getShareAccessLabel(share.permissions)} · {formatExpiry( + share + )} + {#if share.permissions.upload} + · Max {formatFileSize(share.maxUploadBytes)} per file{/if} +

    +
    +
    + + +
    +
  • + {/each} +
+ {/if} + diff --git a/frontend/src/lib/components/ui/ContextMenu.svelte b/frontend/src/lib/components/ui/ContextMenu.svelte index a2207ea..1d0fb16 100644 --- a/frontend/src/lib/components/ui/ContextMenu.svelte +++ b/frontend/src/lib/components/ui/ContextMenu.svelte @@ -79,7 +79,7 @@ > @@ -265,12 +303,26 @@ > {/each} - {#if info.permissions.write} - - {/if} +
+ {#if info.permissions.download} + + {/if} + {#if info.permissions.upload} + + {/if} +
+ {#if info.permissions.upload}

+ Uploads are limited to {formatFileSize(info.maxUploadBytes)} per file. +

{/if} {#if uploading}
@@ -310,7 +362,7 @@ - {#if !item.isDir} + {#if !item.isDir && info.permissions.download} {/if} + {#if info.permissions.delete} + + {/if}
{/each} diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 17d5a9d..a2a5c9f 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -17,6 +17,7 @@ } from '$lib/stores/settings'; import SearchBar from '$lib/components/SearchBar.svelte'; import WallpaperSettings from '$lib/components/settings/wallpaper/WallpaperSettings.svelte'; + import ShareLinksSettings from '$lib/components/settings/ShareLinksSettings.svelte'; import { Button, ProgressButton, Select, Toggle } from '$lib/components/ui'; import { normalizeBackgroundImageMode } from '$lib/utils/wallpaper'; import { @@ -34,13 +35,15 @@ Palette, RotateCcw, Save, + Share2, Settings, User, PaintRollerIcon, X } from 'lucide-svelte'; - type SettingsSectionId = 'display' | 'personalization' | 'behavior' | 'defaults' | 'account'; + type SettingsSectionId = + 'display' | 'personalization' | 'behavior' | 'defaults' | 'sharing' | 'account'; type SettingsCategory = 'all' | SettingsSectionId; type ApplyProgressVariant = 'default' | 'success' | 'danger'; @@ -100,6 +103,11 @@ label: 'Default View', icon: Layout }, + { + id: 'sharing', + label: 'Shared Links', + icon: Share2 + }, { id: 'account', label: 'Account', @@ -364,11 +372,16 @@ categoryAllows('account') && matchesSearch('account', 'session', 'reset defaults', 'logout', 'local preferences') ); + const showSharingSection = $derived( + categoryAllows('sharing') && + matchesSearch('sharing', 'share links', 'active links', 'revoke links') + ); const hasSearchResults = $derived( showDisplaySection || showPersonalizationSection || showBehaviorSection || showDefaultsSection || + showSharingSection || showAccountSection ); @@ -695,6 +708,20 @@ {/if} + {#if showSharingSection} +
+
+
+

+ + Shared Links +

+
+
+ +
+ {/if} + {#if showAccountSection}
From fa5acab22baa8b0c8c8318f25025c4422f97f691 Mon Sep 17 00:00:00 2001 From: "usehoplite[bot]" <288093033+usehoplite[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 09:16:35 +0000 Subject: [PATCH 2/6] Fix share uploads and portable folder archives Co-authored-by: Radhey Kalra --- backend/internal/handler/archive_test.go | 38 +++- backend/internal/handler/share.go | 2 +- backend/internal/handler/share_test.go | 30 ++- backend/internal/model/share.go | 2 +- backend/internal/pkg/filesystem/fs.go | 59 ++---- backend/internal/pkg/filesystem/fs_test.go | 71 ++++--- .../pkg/filesystem/rename_noreplace_linux.go | 22 +++ .../pkg/filesystem/rename_noreplace_other.go | 9 + backend/internal/service/archive.go | 184 +++++++++++++----- backend/internal/service/archive_test.go | 92 ++++++--- backend/internal/service/share.go | 56 ++++-- backend/internal/service/share_test.go | 61 +++++- 12 files changed, 464 insertions(+), 162 deletions(-) create mode 100644 backend/internal/pkg/filesystem/rename_noreplace_linux.go create mode 100644 backend/internal/pkg/filesystem/rename_noreplace_other.go diff --git a/backend/internal/handler/archive_test.go b/backend/internal/handler/archive_test.go index 7ef654d..d313f6b 100644 --- a/backend/internal/handler/archive_test.go +++ b/backend/internal/handler/archive_test.go @@ -8,6 +8,10 @@ import ( "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) { @@ -18,9 +22,22 @@ func TestArchiveStreamsFolderZip(t *testing.T) { if err := fs.WriteFile("/data/media/folder/nested/note.txt", []byte("archive note"), 0o644); err != nil { t.Fatal(err) } - router := createStreamTestRouter(handler) + 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/archive/media/folder", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/archive/media/folder", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { @@ -56,3 +73,20 @@ func TestArchiveStreamsFolderZip(t *testing.T) { } 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()) + } +} diff --git a/backend/internal/handler/share.go b/backend/internal/handler/share.go index ba7594e..b27bd52 100644 --- a/backend/internal/handler/share.go +++ b/backend/internal/handler/share.go @@ -188,7 +188,7 @@ func (h *ShareHandler) Update(w http.ResponseWriter, r *http.Request) { writeError(w, "Invalid request body", model.ErrCodeValidationError, http.StatusBadRequest) return } - share, err := h.shareService.Update(username, id, service.ShareSettings{ + share, err := h.shareService.Update(username, id, service.ShareUpdateSettings{ Permissions: req.Permissions, MaxUploadBytes: req.MaxUploadBytes, }) diff --git a/backend/internal/handler/share_test.go b/backend/internal/handler/share_test.go index f480b06..ce5740d 100644 --- a/backend/internal/handler/share_test.go +++ b/backend/internal/handler/share_test.go @@ -307,6 +307,33 @@ func TestShareListAndRevokeViaAPI(t *testing.T) { } } +func TestSharePermissionOnlyUpdatePreservesUploadLimit(t *testing.T) { + handler, _, _ := setupTestShareHandler() + router := createShareTestRouter(handler) + share := createShareViaAPIWithLimit(t, router, "media/shared", model.SharePermissions{Upload: true}, 8) + + updateReq := newShareManagementRequest(http.MethodPatch, "/api/v1/shares/"+share.ID, strings.NewReader(`{"permissions":{"upload":true}}`)) + updateRec := httptest.NewRecorder() + router.ServeHTTP(updateRec, updateReq) + if updateRec.Code != http.StatusOK { + t.Fatalf("permissions-only update status = %d, want 200: %s", updateRec.Code, updateRec.Body.String()) + } + var updated model.ShareSummary + if err := json.Unmarshal(updateRec.Body.Bytes(), &updated); err != nil { + t.Fatal(err) + } + if updated.MaxUploadBytes != 8 { + t.Fatalf("permissions-only update raised upload limit to %d, want 8", updated.MaxUploadBytes) + } + + uploadReq := httptest.NewRequest(http.MethodPost, "/api/v1/share/"+share.Token+"/upload?path=too-large.txt", strings.NewReader("123456789")) + uploadRec := httptest.NewRecorder() + router.ServeHTTP(uploadRec, uploadReq) + if uploadRec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("upload over preserved limit status = %d, want 413: %s", uploadRec.Code, uploadRec.Body.String()) + } +} + func TestShareManagementIsScopedToOwnerViaAPI(t *testing.T) { handler, _, shareSvc := setupTestShareHandler() router := createShareTestRouter(handler) @@ -797,9 +824,10 @@ func TestShareUpdateAndRecipientDeleteViaAPI(t *testing.T) { t.Fatalf("upload-only delete status = %d, want 403: %s", deniedDeleteRec.Code, deniedDeleteRec.Body.String()) } + maxUploadBytes := int64(16) updatedRequest := model.UpdateShareRequest{ Permissions: model.SharePermissions{Upload: true, Delete: true}, - MaxUploadBytes: 16, + MaxUploadBytes: &maxUploadBytes, } updatedBody, err := json.Marshal(updatedRequest) if err != nil { diff --git a/backend/internal/model/share.go b/backend/internal/model/share.go index 4e483f0..e0aa83d 100644 --- a/backend/internal/model/share.go +++ b/backend/internal/model/share.go @@ -87,7 +87,7 @@ type CreateShareRequest struct { // UpdateShareRequest changes the access granted by an active folder share. type UpdateShareRequest struct { Permissions SharePermissions `json:"permissions"` - MaxUploadBytes int64 `json:"maxUploadBytes"` + MaxUploadBytes *int64 `json:"maxUploadBytes,omitempty"` } // ShareResponse is returned when a share link is created diff --git a/backend/internal/pkg/filesystem/fs.go b/backend/internal/pkg/filesystem/fs.go index 94b35a6..01c2449 100644 --- a/backend/internal/pkg/filesystem/fs.go +++ b/backend/internal/pkg/filesystem/fs.go @@ -29,6 +29,9 @@ type FS interface { // Stat returns a FileInfo describing the named file. Stat(name string) (fs.FileInfo, error) + // Lstat returns information about the path without following a final symlink. + Lstat(name string) (fs.FileInfo, error) + // Open opens the named file for reading. Open(name string) (afero.File, error) @@ -181,6 +184,21 @@ func (a *AferoFS) Stat(name string) (fs.FileInfo, error) { return a.fs.Stat(name) } +func (a *AferoFS) Lstat(name string) (fs.FileInfo, error) { + if err := validateFilesystemPath(name); err != nil { + return nil, err + } + name = filepath.Clean("/" + name) + if _, ok := a.fs.(*afero.OsFs); ok { + return os.Lstat(name) + } + if lstat, ok := a.fs.(afero.Lstater); ok { + info, _, err := lstat.LstatIfPossible(name) + return info, err + } + return a.fs.Stat(name) +} + // Open opens the named file for reading. func (a *AferoFS) Open(name string) (afero.File, error) { if err := validateFilesystemPath(name); err != nil { @@ -240,7 +258,7 @@ func (a *AferoFS) RenameNoReplace(oldpath, newpath string) error { oldpath = filepath.Clean("/" + oldpath) newpath = filepath.Clean("/" + newpath) if _, ok := a.fs.(*afero.OsFs); ok { - return renameNoReplaceWithLink(oldpath, newpath, os.Link) + return renameNoReplaceOS(oldpath, newpath) } if _, err := a.fs.Stat(newpath); err == nil { return fs.ErrExist @@ -251,44 +269,7 @@ func (a *AferoFS) RenameNoReplace(oldpath, newpath string) error { } func renameNoReplaceWithLink(oldpath, newpath string, link func(string, string) error) error { - if err := link(oldpath, newpath); err == nil { - if err := os.Remove(oldpath); err != nil { - _ = os.Remove(newpath) - return err - } - return nil - } else if errors.Is(err, fs.ErrExist) { - return err - } - - return copyNoReplace(oldpath, newpath) -} - -func copyNoReplace(oldpath, newpath string) error { - source, err := os.Open(oldpath) - if err != nil { - return err - } - info, err := source.Stat() - if err != nil { - _ = source.Close() - return err - } - destination, err := os.OpenFile(newpath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm()) - if err != nil { - _ = source.Close() - return err - } - - _, copyErr := io.Copy(destination, source) - sourceCloseErr := source.Close() - destinationCloseErr := destination.Close() - if err := errors.Join(copyErr, sourceCloseErr, destinationCloseErr); err != nil { - _ = os.Remove(newpath) - return err - } - if err := os.Chmod(newpath, info.Mode().Perm()); err != nil { - _ = os.Remove(newpath) + if err := link(oldpath, newpath); err != nil { return err } if err := os.Remove(oldpath); err != nil { diff --git a/backend/internal/pkg/filesystem/fs_test.go b/backend/internal/pkg/filesystem/fs_test.go index 15f078c..93861c8 100644 --- a/backend/internal/pkg/filesystem/fs_test.go +++ b/backend/internal/pkg/filesystem/fs_test.go @@ -39,14 +39,11 @@ func TestAferoFSKeepsSafePathsAndRejectsTraversal(t *testing.T) { } } -func TestRenameNoReplaceFallsBackWhenHardLinksAreUnsupported(t *testing.T) { +func TestRenameNoReplaceDoesNotPublishWhenHardLinksAreUnsupported(t *testing.T) { directory := t.TempDir() source := filepath.Join(directory, "staged.txt") destination := filepath.Join(directory, "uploaded.txt") - if err := os.WriteFile(source, []byte("upload"), 0o640); err != nil { - t.Fatal(err) - } - if err := os.Chmod(source, 0o640); err != nil { + if err := os.WriteFile(source, []byte("complete upload"), 0o640); err != nil { t.Fatal(err) } @@ -54,29 +51,19 @@ func TestRenameNoReplaceFallsBackWhenHardLinksAreUnsupported(t *testing.T) { err := renameNoReplaceWithLink(source, destination, func(string, string) error { return errLinkUnsupported }) - if err != nil { - t.Fatalf("rename with copy fallback: %v", err) - } - if _, err := os.Stat(source); !errors.Is(err, fs.ErrNotExist) { - t.Fatalf("staged file still exists: %v", err) - } - data, err := os.ReadFile(destination) - if err != nil { - t.Fatal(err) - } - if string(data) != "upload" { - t.Fatalf("destination = %q, want upload", data) + if !errors.Is(err, errLinkUnsupported) { + t.Fatalf("rename error = %v, want hard-link error", err) } - info, err := os.Stat(destination) - if err != nil { - t.Fatal(err) + data, err := os.ReadFile(source) + if err != nil || string(data) != "complete upload" { + t.Fatalf("staged source after failure = %q, error = %v", data, err) } - if got := info.Mode().Perm(); got != 0o640 { - t.Fatalf("destination mode = %04o, want 0640", got) + if _, err := os.Stat(destination); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("destination appeared before atomic publication: %v", err) } } -func TestRenameNoReplaceFallbackDoesNotOverwrite(t *testing.T) { +func TestRenameNoReplacePublishesAtomicallyWithoutOverwriting(t *testing.T) { directory := t.TempDir() source := filepath.Join(directory, "staged.txt") destination := filepath.Join(directory, "uploaded.txt") @@ -87,9 +74,8 @@ func TestRenameNoReplaceFallbackDoesNotOverwrite(t *testing.T) { t.Fatal(err) } - err := renameNoReplaceWithLink(source, destination, func(string, string) error { - return errors.New("hard links unsupported") - }) + fsys := NewOsFS() + err := fsys.RenameNoReplace(source, destination) if !errors.Is(err, fs.ErrExist) { t.Fatalf("rename error = %v, want fs.ErrExist", err) } @@ -100,4 +86,37 @@ func TestRenameNoReplaceFallbackDoesNotOverwrite(t *testing.T) { if string(data) != "existing" { t.Fatalf("existing destination = %q, want unchanged", data) } + if err := os.Remove(destination); err != nil { + t.Fatal(err) + } + if err := fsys.RenameNoReplace(source, destination); err != nil { + t.Fatalf("publish complete upload: %v", err) + } + if _, err := os.Stat(source); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("source remains after successful publish: %v", err) + } + data, err = os.ReadFile(destination) + if err != nil || string(data) != "new" { + t.Fatalf("published destination = %q, error = %v", data, err) + } +} + +func TestAferoFSLstatDoesNotFollowSymlinks(t *testing.T) { + directory := t.TempDir() + target := filepath.Join(directory, "target.txt") + link := filepath.Join(directory, "link.txt") + if err := os.WriteFile(target, []byte("target"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + info, err := NewOsFS().Lstat(link) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("Lstat mode = %v, want symlink", info.Mode()) + } } diff --git a/backend/internal/pkg/filesystem/rename_noreplace_linux.go b/backend/internal/pkg/filesystem/rename_noreplace_linux.go new file mode 100644 index 0000000..9133274 --- /dev/null +++ b/backend/internal/pkg/filesystem/rename_noreplace_linux.go @@ -0,0 +1,22 @@ +//go:build linux + +package filesystem + +import ( + "errors" + "io/fs" + "os" + + "golang.org/x/sys/unix" +) + +func renameNoReplaceOS(oldpath, newpath string) error { + err := unix.Renameat2(unix.AT_FDCWD, oldpath, unix.AT_FDCWD, newpath, unix.RENAME_NOREPLACE) + if err == nil || errors.Is(err, fs.ErrExist) { + return err + } + if !errors.Is(err, unix.ENOSYS) && !errors.Is(err, unix.EINVAL) && !errors.Is(err, unix.EOPNOTSUPP) { + return err + } + return renameNoReplaceWithLink(oldpath, newpath, os.Link) +} diff --git a/backend/internal/pkg/filesystem/rename_noreplace_other.go b/backend/internal/pkg/filesystem/rename_noreplace_other.go new file mode 100644 index 0000000..24ef9c4 --- /dev/null +++ b/backend/internal/pkg/filesystem/rename_noreplace_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package filesystem + +import "os" + +func renameNoReplaceOS(oldpath, newpath string) error { + return renameNoReplaceWithLink(oldpath, newpath, os.Link) +} diff --git a/backend/internal/service/archive.go b/backend/internal/service/archive.go index 2037fff..cf8327d 100644 --- a/backend/internal/service/archive.go +++ b/backend/internal/service/archive.go @@ -3,13 +3,17 @@ package service import ( "archive/zip" "context" + "crypto/sha256" + "encoding/hex" "errors" "io" "io/fs" "path" "path/filepath" "sort" + "strconv" "strings" + "unicode/utf8" "github.com/jR4dh3y/BoxBox/backend/internal/pkg/filesystem" ) @@ -21,12 +25,14 @@ var archivePathReplacer = strings.NewReplacer("%", "%25", "\\", "%5C", ":", "%3A type archiveEntry struct { path string relativePath string + archiveName string info fs.FileInfo } // DirectoryArchive is a validated, bounded ZIP plan for a directory. type DirectoryArchive struct { Name string + zipRoot string fs filesystem.FS root string entries []archiveEntry @@ -56,9 +62,13 @@ func prepareDirectoryArchive(ctx context.Context, fsys filesystem.FS, root, boun } archive := &DirectoryArchive{ - Name: safeArchiveName(rootInfo.Name()), - fs: fsys, - root: root, + Name: safeArchiveName(rootInfo.Name()), + zipRoot: sanitizeArchiveComponent(rootInfo.Name()), + fs: fsys, + root: root, + } + if archive.zipRoot == "" { + archive.zipRoot = "folder" } err = NewWalker(fsys).Walk(ctx, root, WalkOptions{ IncludeHidden: true, @@ -90,44 +100,140 @@ func prepareDirectoryArchive(ctx context.Context, fsys filesystem.FS, root, boun sort.Slice(archive.entries, func(i, j int) bool { return archive.entries[i].relativePath < archive.entries[j].relativePath }) - if err := validateArchiveEntryNames(archive); err != nil { - return nil, err + allocator := newArchiveNameAllocator() + for i := range archive.entries { + name, err := allocator.entryPath(archive.zipRoot, archive.entries[i].relativePath, archive.entries[i].info.IsDir()) + if err != nil { + return nil, err + } + archive.entries[i].archiveName = name } return archive, nil } -func validateArchiveEntryNames(archive *DirectoryArchive) error { - if _, ok := archiveCollisionKey(archive.Name); !ok { - return ErrInvalidOperation +type archiveNameAllocator struct { + children map[string]map[string]string + used map[string]map[string]struct{} +} + +func newArchiveNameAllocator() *archiveNameAllocator { + return &archiveNameAllocator{ + children: make(map[string]map[string]string), + used: make(map[string]map[string]struct{}), } - seen := make(map[string]struct{}, len(archive.entries)) - for _, entry := range archive.entries { - name, err := archiveEntryName(archive.Name, entry.relativePath, entry.info.IsDir()) - if err != nil { - return err +} + +func (a *archiveNameAllocator) entryPath(root, relative string, directory bool) (string, error) { + relative = filepath.ToSlash(relative) + if path.IsAbs(relative) { + return "", ErrPermissionDenied + } + cleaned := path.Clean(relative) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return "", ErrPermissionDenied + } + components := strings.Split(cleaned, "/") + parent := root + output := make([]string, len(components)) + for i, component := range components { + if component == "" || component == "." || component == ".." { + return "", ErrPermissionDenied } - key, ok := archiveCollisionKey(name) - if !ok { - return ErrInvalidOperation + output[i] = a.component(parent, component) + parent = path.Join(parent, output[i]) + } + name := path.Join(root, strings.Join(output, "/")) + if directory { + name += "/" + } + return name, nil +} + +func (a *archiveNameAllocator) component(parent, source string) string { + if _, ok := a.children[parent]; !ok { + a.children[parent] = make(map[string]string) + a.used[parent] = make(map[string]struct{}) + } + if output, ok := a.children[parent][source]; ok { + return output + } + + output := sanitizeArchiveComponent(source) + if output == "" { + output = "%00" + } + key := archiveNameKey(output) + if _, exists := a.used[parent][key]; exists { + output = disambiguateArchiveComponent(output, source, a.used[parent]) + key = archiveNameKey(output) + } + a.children[parent][source] = output + a.used[parent][key] = struct{}{} + return output +} + +func disambiguateArchiveComponent(base, source string, used map[string]struct{}) string { + hash := sha256.Sum256([]byte(source)) + suffix := "~" + hex.EncodeToString(hash[:6]) + for index := 0; ; index++ { + candidateSuffix := suffix + if index > 0 { + candidateSuffix += "-" + strconv.Itoa(index) } - if _, exists := seen[key]; exists { - return ErrInvalidOperation + prefix := truncateArchiveComponent(base, 240-len(candidateSuffix)) + candidate := prefix + candidateSuffix + if _, exists := used[archiveNameKey(candidate)]; !exists { + return candidate } - seen[key] = struct{}{} } - return nil } -func archiveCollisionKey(name string) (string, bool) { - components := strings.Split(strings.TrimSuffix(name, "/"), "/") - for index, component := range components { - component = strings.TrimRight(component, " .") - if component == "" || component == "." || component == ".." { - return "", false +func truncateArchiveComponent(name string, maxBytes int) string { + if len(name) <= maxBytes { + return name + } + name = name[:maxBytes] + for !utf8.ValidString(name) { + name = name[:len(name)-1] + } + return name +} + +func archiveNameKey(name string) string { + return strings.ToLower(strings.TrimRight(name, " .")) +} + +func sanitizeArchiveComponent(name string) string { + name = archivePathReplacer.Replace(name) + trimmedEnd := len(name) + for trimmedEnd > 0 && (name[trimmedEnd-1] == '.' || name[trimmedEnd-1] == ' ') { + trimmedEnd-- + } + reserved := isWindowsReservedArchiveName(name) + var output strings.Builder + for i := 0; i < len(name); i++ { + value := name[i] + invalid := value < 0x20 || value == 0x7f || strings.ContainsRune(`<>"|?*`, rune(value)) + trailing := i >= trimmedEnd && (value == '.' || value == ' ') + if (reserved && i == 0) || invalid || trailing { + output.WriteByte('%') + output.WriteByte("0123456789ABCDEF"[value>>4]) + output.WriteByte("0123456789ABCDEF"[value&0x0f]) + } else { + output.WriteByte(value) } - components[index] = strings.ToLower(component) } - return strings.Join(components, "/"), true + return output.String() +} + +func isWindowsReservedArchiveName(name string) bool { + stem := strings.ToLower(strings.SplitN(strings.TrimRight(name, " ."), ".", 2)[0]) + switch stem { + case "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9": + return true + default: + return false + } } // WriteTo streams a ZIP after its traversal and path boundaries have been checked. @@ -136,7 +242,7 @@ func (a *DirectoryArchive) WriteTo(ctx context.Context, destination io.Writer) e return ErrInvalidOperation } zipWriter := zip.NewWriter(destination) - root := a.Name + root := a.zipRoot rootHeader := &zip.FileHeader{Name: root + "/", Method: zip.Store} rootHeader.SetMode(fs.ModeDir | 0o755) _, writeErr := zipWriter.CreateHeader(rootHeader) @@ -146,12 +252,7 @@ func (a *DirectoryArchive) WriteTo(ctx context.Context, destination io.Writer) e writeErr = err break } - name, err := archiveEntryName(root, entry.relativePath, entry.info.IsDir()) - if err != nil { - writeErr = err - break - } - header := &zip.FileHeader{Name: name, Modified: entry.info.ModTime()} + header := &zip.FileHeader{Name: entry.archiveName, Modified: entry.info.ModTime()} if entry.info.IsDir() { header.Method = zip.Store header.SetMode(fs.ModeDir | entry.info.Mode().Perm()) @@ -209,19 +310,6 @@ func (r archiveContextReader) Read(buffer []byte) (int, error) { return r.reader.Read(buffer) } -func archiveEntryName(root, relative string, directory bool) (string, error) { - relative = sanitizeArchivePath(filepath.ToSlash(relative)) - cleaned := path.Clean(relative) - if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || path.IsAbs(cleaned) { - return "", ErrPermissionDenied - } - name := path.Join(root, cleaned) - if directory { - name += "/" - } - return name, nil -} - func safeArchiveName(name string) string { name = strings.Trim(sanitizeArchivePath(name), "/") if name == "" || name == "." || name == ".." { diff --git a/backend/internal/service/archive_test.go b/backend/internal/service/archive_test.go index c7ac1f8..454d0f6 100644 --- a/backend/internal/service/archive_test.go +++ b/backend/internal/service/archive_test.go @@ -4,7 +4,6 @@ import ( "archive/zip" "bytes" "context" - "errors" "io" "strings" "testing" @@ -119,42 +118,91 @@ func TestDirectoryArchiveEscapesDistinctNamesWithoutCollisions(t *testing.T) { } } -func TestDirectoryArchiveRejectsCaseInsensitiveNameCollisions(t *testing.T) { +func TestDirectoryArchivePreservesNamesThatCollideOnWindows(t *testing.T) { fsys := filesystem.NewMemMapFS() - if err := fsys.MkdirAll("/data/media/folder", 0o755); err != nil { - t.Fatal(err) + for _, path := range []string{ + "/data/media/folder", + "/data/media/folder/Group", + "/data/media/folder/group", + } { + if err := fsys.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + } + files := map[string]string{ + "Foo.txt": "uppercase", + "foo.txt": "lowercase", + "report": "plain extensionless", + "report.": "trailing dot", + "CON.txt": "reserved uppercase", + "con.txt": "reserved lowercase", + "Group/inside.txt": "first directory", + "group/inside.txt": "second directory", } - for _, name := range []string{"Foo.txt", "foo.txt"} { - if err := fsys.WriteFile("/data/media/folder/"+name, []byte(name), 0o644); err != nil { + for name, content := range files { + if err := fsys.WriteFile("/data/media/folder/"+name, []byte(content), 0o644); err != nil { t.Fatal(err) } } - if _, err := prepareDirectoryArchive(context.Background(), fsys, "/data/media/folder", "/data/media"); !errors.Is(err, ErrInvalidOperation) { - t.Fatalf("prepare archive error = %v, want %v", err, ErrInvalidOperation) + archive, err := prepareDirectoryArchive(context.Background(), fsys, "/data/media/folder", "/data/media") + if err != nil { + t.Fatal(err) } -} - -func TestDirectoryArchiveRejectsWindowsTrailingDotCollisions(t *testing.T) { - fsys := filesystem.NewMemMapFS() - if err := fsys.MkdirAll("/data/media/folder", 0o755); err != nil { + var output bytes.Buffer + if err := archive.WriteTo(context.Background(), &output); err != nil { + t.Fatal(err) + } + reader, err := zip.NewReader(bytes.NewReader(output.Bytes()), int64(output.Len())) + if err != nil { t.Fatal(err) } - for _, name := range []string{"report", "report."} { - if err := fsys.WriteFile("/data/media/folder/"+name, []byte(name), 0o644); err != nil { + want := make(map[string]bool, len(files)) + for _, content := range files { + want[content] = true + } + seenNames := make(map[string]struct{}, len(files)) + for _, entry := range reader.File { + if entry.FileInfo().IsDir() { + continue + } + key := archiveNameKey(entry.Name) + if _, exists := seenNames[key]; exists { + t.Fatalf("portable ZIP path collision at %q", entry.Name) + } + seenNames[key] = struct{}{} + 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 %q: read=%v close=%v", entry.Name, readErr, closeErr) + } + if !want[string(content)] { + t.Fatalf("unexpected or duplicate file content %q in %q", content, entry.Name) + } + delete(want, string(content)) } - - if _, err := prepareDirectoryArchive(context.Background(), fsys, "/data/media/folder", "/data/media"); !errors.Is(err, ErrInvalidOperation) { - t.Fatalf("prepare archive error = %v, want %v", err, ErrInvalidOperation) + if len(want) != 0 { + t.Fatalf("archive omitted valid files: %v", want) } } -func TestArchiveCollisionKeyRejectsWindowsDotSegments(t *testing.T) { - for _, name := range []string{"folder/. /file.txt", "folder/.. /file.txt", ".. "} { - if _, ok := archiveCollisionKey(name); ok { - t.Errorf("archiveCollisionKey(%q) accepted a Windows dot segment", name) +func TestArchiveComponentsRemainSafeForWindowsExtraction(t *testing.T) { + allocator := newArchiveNameAllocator() + root := sanitizeArchiveComponent("folder") + for _, component := range []string{".", "..", ".. ", "report."} { + got := allocator.component(root, component) + if got == "" || got == "." || got == ".." || strings.TrimRight(got, " .") == "" { + t.Errorf("component %q produced unsafe ZIP name %q", component, got) + } + } + for _, component := range []string{"CON", "con.txt", "nul", "COM1.log"} { + got := sanitizeArchiveComponent(component) + if isWindowsReservedArchiveName(got) { + t.Errorf("reserved device %q stayed reserved as %q", component, got) } } } diff --git a/backend/internal/service/share.go b/backend/internal/service/share.go index b876c49..56c3e74 100644 --- a/backend/internal/service/share.go +++ b/backend/internal/service/share.go @@ -41,7 +41,7 @@ type ShareService interface { // List returns the user's active (non-revoked, non-expired) shares, newest first. List(username string) ([]model.Share, error) // Update changes the permissions and upload limit on one of the user's folder shares. - Update(username string, id string, settings ShareSettings) (*model.Share, error) + Update(username string, id string, settings ShareUpdateSettings) (*model.Share, error) // Revoke permanently disables one of the user's shares by ID. Revoke(username string, id string) error // ResolveForRecipient returns the share for a token, or ErrShareNotFound for @@ -72,6 +72,12 @@ type ShareSettings struct { MaxUploadBytes int64 } +// ShareUpdateSettings distinguishes an omitted upload limit from an explicit one. +type ShareUpdateSettings struct { + Permissions model.SharePermissions + MaxUploadBytes *int64 +} + // ShareServiceConfig holds configuration for the share service. // Mounts supplies the live mount list at access time so shares whose mount was // removed, renamed, or flipped read-only after creation are re-validated. @@ -276,17 +282,13 @@ func (s *shareService) Revoke(username string, id string) error { return ErrShareNotFound } -func (s *shareService) Update(username string, id string, settings ShareSettings) (*model.Share, error) { +func (s *shareService) Update(username string, id string, settings ShareUpdateSettings) (*model.Share, error) { if s.storageErr != nil { return nil, s.storageErr } if username == "" { return nil, ErrInvalidOperation } - maxUploadBytes, err := s.resolveUploadLimit(settings.MaxUploadBytes) - if err != nil { - return nil, err - } requestedPermissions := settings.Permissions if !requestedPermissions.View && !requestedPermissions.Download && !requestedPermissions.Upload && !requestedPermissions.Delete { return nil, ErrInvalidOperation @@ -314,7 +316,7 @@ func (s *shareService) Update(username string, id string, settings ShareSettings if record.CreatedBy != username || record.Revoked || isExpired(record.ExpiresAt, time.Now()) || !record.IsFolder { return nil, ErrShareNotFound } - share := fromShareRecord(*record) + share := s.shareFromRecord(*record) mount, _, err := s.resolveShareTarget(&share) if err != nil { return nil, err @@ -322,6 +324,13 @@ func (s *shareService) Update(username string, id string, settings ShareSettings if (permissions.Upload || permissions.Delete) && mount.ReadOnly { return nil, ErrPermissionDenied } + maxUploadBytes := share.MaxUploadBytes + if settings.MaxUploadBytes != nil { + maxUploadBytes, err = s.resolveUploadLimit(*settings.MaxUploadBytes) + if err != nil { + return nil, err + } + } share.Permissions = permissions share.MaxUploadBytes = maxUploadBytes data.Shares[i] = toShareRecord(share) @@ -683,17 +692,36 @@ func (s *shareService) DeleteForRecipientPath(ctx context.Context, token string, if err != nil { return err } - if mount.ReadOnly || target == root || !pathWithinRoot(root, target) { - return ErrPermissionDenied - } - info, err := s.statTarget(target) + cleanPath, err := cleanShareRelativePath(relativePath) if err != nil { return err } - if !info.IsDir() && !info.Mode().IsRegular() { - return ErrNotFile + entryPath := filepath.Join(root, filepath.FromSlash(cleanPath)) + if mount.ReadOnly || cleanPath == "" || !pathWithinRoot(root, target) || !pathWithinRoot(root, entryPath) { + return ErrPermissionDenied + } + components := strings.Split(cleanPath, "/") + current := root + for index, component := range components { + current = filepath.Join(current, component) + info, err := s.fs.Lstat(current) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + if index != len(components)-1 { + return ErrPermissionDenied + } + return s.fs.Remove(current) + } + if index < len(components)-1 && !info.IsDir() { + return ErrNotDirectory + } + if index == len(components)-1 && !info.IsDir() && !info.Mode().IsRegular() { + return ErrNotFile + } } - return s.fs.RemoveAll(target) + return s.fs.RemoveAll(entryPath) } func (s *shareService) resolveFolderPath(share *model.Share, relativePath string, allowMissing bool) (*model.MountPoint, string, string, error) { diff --git a/backend/internal/service/share_test.go b/backend/internal/service/share_test.go index 4f096c2..a10d4e0 100644 --- a/backend/internal/service/share_test.go +++ b/backend/internal/service/share_test.go @@ -255,9 +255,10 @@ func TestShareLegacyFolderWriteKeepsReplacementOnlyForLegacyUpdates(t *testing.T t.Fatalf("legacy delete error = %v, want %v", err, ErrPermissionDenied) } - updated, err := shares.Update("owner", share.ID, ShareSettings{ + maxUploadBytes := int64(24) + updated, err := shares.Update("owner", share.ID, ShareUpdateSettings{ Permissions: model.SharePermissions{Upload: true, LegacyReplace: true}, - MaxUploadBytes: 24, + MaxUploadBytes: &maxUploadBytes, }) if err != nil { t.Fatal(err) @@ -269,9 +270,9 @@ func TestShareLegacyFolderWriteKeepsReplacementOnlyForLegacyUpdates(t *testing.T t.Fatalf("legacy replacement stopped after settings update: %v", err) } - modernUpdate, err := shares.Update("owner", share.ID, ShareSettings{ + modernUpdate, err := shares.Update("owner", share.ID, ShareUpdateSettings{ Permissions: model.SharePermissions{View: true, Download: true, Upload: true}, - MaxUploadBytes: 24, + MaxUploadBytes: &maxUploadBytes, }) if err != nil { t.Fatal(err) @@ -625,9 +626,10 @@ func TestShareUploadOnlyCannotReplaceOrDeleteAndEnforcesLinkLimit(t *testing.T) t.Fatalf("oversized upload left a file (exists=%t, err=%v)", exists, err) } - managed, err := shares.Update("owner", share.ID, ShareSettings{ + newLimit := int64(8) + managed, err := shares.Update("owner", share.ID, ShareUpdateSettings{ Permissions: model.SharePermissions{Upload: true, Delete: true}, - MaxUploadBytes: 8, + MaxUploadBytes: &newLimit, }) if err != nil { t.Fatal(err) @@ -651,9 +653,9 @@ func TestShareUploadOnlyCannotReplaceOrDeleteAndEnforcesLinkLimit(t *testing.T) t.Fatalf("managed delete left a file (exists=%t, err=%v)", exists, err) } - viewer, err := shares.Update("owner", share.ID, ShareSettings{ + viewer, err := shares.Update("owner", share.ID, ShareUpdateSettings{ Permissions: model.SharePermissions{View: true}, - MaxUploadBytes: 8, + MaxUploadBytes: &newLimit, }) if err != nil { t.Fatal(err) @@ -778,6 +780,49 @@ func TestShareWriteRenamesOverResolvedTargetNotSymlink(t *testing.T) { assertOnlyEntries(t, media, "real.txt", "link.txt") } +func TestShareDeleteRemovesSymlinkWithoutDeletingTarget(t *testing.T) { + root := t.TempDir() + media := filepath.Join(root, "media") + target := filepath.Join(media, "target") + if err := os.MkdirAll(filepath.Join(target, "nested"), 0o755); err != nil { + t.Fatal(err) + } + targetFile := filepath.Join(target, "nested", "keep.txt") + if err := os.WriteFile(targetFile, []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(media, "linked") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + shares := NewShareService(filesystem.NewOsFS(), ShareServiceConfig{ + DataDir: filepath.Join(root, "data"), + Mounts: func() []model.MountPoint { + return []model.MountPoint{{Name: "media", Path: media}} + }, + }) + share, err := shares.Create(context.Background(), "owner", "media", ShareSettings{ + Permissions: model.SharePermissions{Upload: true, Delete: true}, + }, time.Time{}) + if err != nil { + t.Fatal(err) + } + if err := shares.DeleteForRecipientPath(context.Background(), share.Token, "linked"); err != nil { + t.Fatalf("delete symlink entry: %v", err) + } + if _, err := os.Lstat(link); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("symlink still exists or could not be inspected: %v", err) + } + content, err := os.ReadFile(targetFile) + if err != nil { + t.Fatalf("symlink target was removed: %v", err) + } + if string(content) != "keep" { + t.Fatalf("symlink target content = %q, want keep", content) + } +} + func TestShareRejectsSymlinkEscapingMount(t *testing.T) { root := t.TempDir() media := filepath.Join(root, "media") From 5ab7b3ed7fa3deca1f47fbc274d8d1582fe835bc Mon Sep 17 00:00:00 2001 From: "usehoplite[bot]" <288093033+usehoplite[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 09:34:26 +0000 Subject: [PATCH 3/6] Preserve v1 share permission compatibility Co-authored-by: Radhey Kalra --- backend/internal/model/share.go | 2 ++ backend/internal/model/share_test.go | 14 ++++++++++++++ docs/api.md | 6 +++--- docs/security.md | 1 + 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/backend/internal/model/share.go b/backend/internal/model/share.go index e0aa83d..669db29 100644 --- a/backend/internal/model/share.go +++ b/backend/internal/model/share.go @@ -45,6 +45,7 @@ type SharePermissionsResponse struct { Upload bool `json:"upload"` Delete bool `json:"delete"` CanReplace bool `json:"canReplace"` + Write bool `json:"write"` // Deprecated alias for Upload. } func (p SharePermissions) ToResponse() SharePermissionsResponse { @@ -54,6 +55,7 @@ func (p SharePermissions) ToResponse() SharePermissionsResponse { Upload: p.Upload, Delete: p.Delete, CanReplace: p.Delete || p.LegacyReplace, + Write: p.Upload, } } diff --git a/backend/internal/model/share_test.go b/backend/internal/model/share_test.go index db6ed78..37f1d31 100644 --- a/backend/internal/model/share_test.go +++ b/backend/internal/model/share_test.go @@ -53,6 +53,20 @@ func TestSharePermissionsResponseReportsEffectiveReplacement(t *testing.T) { if response.CanReplace != test.want { t.Fatalf("canReplace = %t, want %t", response.CanReplace, test.want) } + if response.Write != test.permissions.Upload { + t.Fatalf("legacy write alias = %t, want upload=%t", response.Write, test.permissions.Upload) + } + data, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + if got, ok := fields["write"].(bool); !ok || got != test.permissions.Upload { + t.Fatalf("serialized write alias = %v, want upload=%t", fields["write"], test.permissions.Upload) + } }) } } diff --git a/docs/api.md b/docs/api.md index 1c138eb..b3ed88c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -280,7 +280,7 @@ Content-Type: application/json } ``` -`expiresInSeconds` is optional; omit it for a share that never expires. Folder links always allow viewing and downloading. Set `upload` to allow new files, and set both `upload` and `delete` to allow replacing or removing items below the shared folder. The optional `maxUploadBytes` value is a per-file cap; omitting it uses the server's `max_upload_mb` limit. The server cap always applies. File links always use view/download permissions. Share responses include the effective `canReplace` capability; it is true when delete is allowed or a persisted legacy `write` permission retains replacement access. Requests cannot set `canReplace`. +`expiresInSeconds` is optional; omit it for a share that never expires. Folder links always allow viewing and downloading. Set `upload` to allow new files, and set both `upload` and `delete` to allow replacing or removing items below the shared folder. The optional `maxUploadBytes` value is a per-file cap; omitting it uses the server's `max_upload_mb` limit. The server cap always applies. File links always use view/download permissions. Share responses include the effective `canReplace` capability; it is true when delete is allowed or a persisted legacy `write` permission retains replacement access. For existing v1 clients, responses also include deprecated `write` as an alias for `upload`; new clients should use `upload`, `delete`, and `canReplace`. Requests cannot set `canReplace`. Response uses `201 Created`: @@ -290,7 +290,7 @@ Response uses `201 Created`: "token": "7nArUufciyjMLLFstZGU_zIerVgTpr2Qr2eL1lUTJFY", "url": "/s/7nArUufciyjMLLFstZGU_zIerVgTpr2Qr2eL1lUTJFY", "fileName": "shared", - "permissions": { "view": true, "download": true, "upload": true, "delete": false, "canReplace": false }, + "permissions": { "view": true, "download": true, "upload": true, "delete": false, "canReplace": false, "write": true }, "maxUploadBytes": 104857600, "isFolder": true, "createdAt": "2026-09-02T09:00:00Z", @@ -343,7 +343,7 @@ Returns recipient-facing metadata only; mount names and internal paths are never "fileName": "shared", "size": 1024, "mimeType": "application/octet-stream", - "permissions": { "view": true, "download": true, "upload": true, "delete": false, "canReplace": false }, + "permissions": { "view": true, "download": true, "upload": true, "delete": false, "canReplace": false, "write": true }, "maxUploadBytes": 104857600, "isFolder": true, "expiresAt": "2026-09-02T10:00:00Z" diff --git a/docs/security.md b/docs/security.md index df5e324..7f54920 100644 --- a/docs/security.md +++ b/docs/security.md @@ -132,6 +132,7 @@ Share links expose one file or a folder tree to anyone holding the token URL: - Folder ZIP archives validate traversal and resolved paths against the share root before streaming. - Recipient metadata responses never include mount names or internal paths. - The share store directory is restricted to owner access (`0700`) and its token-bearing `shares.json` file to `0600`, including existing stores when the service initializes. +- Before downgrading to a version predating split upload/delete permissions, back up `shares.json`; older versions cannot preserve the new per-link permissions and upload caps if they rewrite the store. ## Operational Checklist From 7c77b1fc0597b6217f6502986bc58d7568c18d81 Mon Sep 17 00:00:00 2001 From: "usehoplite[bot]" <288093033+usehoplite[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 09:39:57 +0000 Subject: [PATCH 4/6] Keep legacy share write semantics in responses Co-authored-by: Radhey Kalra --- backend/internal/model/share.go | 4 ++-- backend/internal/model/share_test.go | 8 ++++---- docs/api.md | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/internal/model/share.go b/backend/internal/model/share.go index 669db29..ba50103 100644 --- a/backend/internal/model/share.go +++ b/backend/internal/model/share.go @@ -45,7 +45,7 @@ type SharePermissionsResponse struct { Upload bool `json:"upload"` Delete bool `json:"delete"` CanReplace bool `json:"canReplace"` - Write bool `json:"write"` // Deprecated alias for Upload. + Write bool `json:"write"` // Deprecated alias for CanReplace. } func (p SharePermissions) ToResponse() SharePermissionsResponse { @@ -55,7 +55,7 @@ func (p SharePermissions) ToResponse() SharePermissionsResponse { Upload: p.Upload, Delete: p.Delete, CanReplace: p.Delete || p.LegacyReplace, - Write: p.Upload, + Write: p.Delete || p.LegacyReplace, } } diff --git a/backend/internal/model/share_test.go b/backend/internal/model/share_test.go index 37f1d31..dda10fb 100644 --- a/backend/internal/model/share_test.go +++ b/backend/internal/model/share_test.go @@ -53,8 +53,8 @@ func TestSharePermissionsResponseReportsEffectiveReplacement(t *testing.T) { if response.CanReplace != test.want { t.Fatalf("canReplace = %t, want %t", response.CanReplace, test.want) } - if response.Write != test.permissions.Upload { - t.Fatalf("legacy write alias = %t, want upload=%t", response.Write, test.permissions.Upload) + if response.Write != test.want { + t.Fatalf("legacy write alias = %t, want canReplace=%t", response.Write, test.want) } data, err := json.Marshal(response) if err != nil { @@ -64,8 +64,8 @@ func TestSharePermissionsResponseReportsEffectiveReplacement(t *testing.T) { if err := json.Unmarshal(data, &fields); err != nil { t.Fatal(err) } - if got, ok := fields["write"].(bool); !ok || got != test.permissions.Upload { - t.Fatalf("serialized write alias = %v, want upload=%t", fields["write"], test.permissions.Upload) + if got, ok := fields["write"].(bool); !ok || got != test.want { + t.Fatalf("serialized write alias = %v, want canReplace=%t", fields["write"], test.want) } }) } diff --git a/docs/api.md b/docs/api.md index b3ed88c..24aff02 100644 --- a/docs/api.md +++ b/docs/api.md @@ -280,7 +280,7 @@ Content-Type: application/json } ``` -`expiresInSeconds` is optional; omit it for a share that never expires. Folder links always allow viewing and downloading. Set `upload` to allow new files, and set both `upload` and `delete` to allow replacing or removing items below the shared folder. The optional `maxUploadBytes` value is a per-file cap; omitting it uses the server's `max_upload_mb` limit. The server cap always applies. File links always use view/download permissions. Share responses include the effective `canReplace` capability; it is true when delete is allowed or a persisted legacy `write` permission retains replacement access. For existing v1 clients, responses also include deprecated `write` as an alias for `upload`; new clients should use `upload`, `delete`, and `canReplace`. Requests cannot set `canReplace`. +`expiresInSeconds` is optional; omit it for a share that never expires. Folder links always allow viewing and downloading. Set `upload` to allow new files, and set both `upload` and `delete` to allow replacing or removing items below the shared folder. The optional `maxUploadBytes` value is a per-file cap; omitting it uses the server's `max_upload_mb` limit. The server cap always applies. File links always use view/download permissions. Share responses include the effective `canReplace` capability; it is true when delete is allowed or a persisted legacy `write` permission retains replacement access. For existing v1 clients, responses also include deprecated `write` as an alias for `canReplace`; new clients should use `upload`, `delete`, and `canReplace`. Requests cannot set `canReplace`. Response uses `201 Created`: From 787086b67b2c153c6b3debbfe2059368f11395ae Mon Sep 17 00:00:00 2001 From: "usehoplite[bot]" <288093033+usehoplite[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 09:44:50 +0000 Subject: [PATCH 5/6] Align share API examples with legacy alias Co-authored-by: Radhey Kalra --- docs/api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api.md b/docs/api.md index 24aff02..6f1a76e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -290,7 +290,7 @@ Response uses `201 Created`: "token": "7nArUufciyjMLLFstZGU_zIerVgTpr2Qr2eL1lUTJFY", "url": "/s/7nArUufciyjMLLFstZGU_zIerVgTpr2Qr2eL1lUTJFY", "fileName": "shared", - "permissions": { "view": true, "download": true, "upload": true, "delete": false, "canReplace": false, "write": true }, + "permissions": { "view": true, "download": true, "upload": true, "delete": false, "canReplace": false, "write": false }, "maxUploadBytes": 104857600, "isFolder": true, "createdAt": "2026-09-02T09:00:00Z", @@ -343,7 +343,7 @@ Returns recipient-facing metadata only; mount names and internal paths are never "fileName": "shared", "size": 1024, "mimeType": "application/octet-stream", - "permissions": { "view": true, "download": true, "upload": true, "delete": false, "canReplace": false, "write": true }, + "permissions": { "view": true, "download": true, "upload": true, "delete": false, "canReplace": false, "write": false }, "maxUploadBytes": 104857600, "isFolder": true, "expiresAt": "2026-09-02T10:00:00Z" From 11c5b52e385892ae33708e5f15e82b508b70bdac Mon Sep 17 00:00:00 2001 From: "usehoplite[bot]" <288093033+usehoplite[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:48:03 +0000 Subject: [PATCH 6/6] Align legacy share write alias with upload access Co-authored-by: Radhey Kalra --- backend/internal/model/share.go | 4 ++-- backend/internal/model/share_test.go | 28 +++++++++++++++------------- docs/api.md | 6 +++--- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/backend/internal/model/share.go b/backend/internal/model/share.go index ba50103..669db29 100644 --- a/backend/internal/model/share.go +++ b/backend/internal/model/share.go @@ -45,7 +45,7 @@ type SharePermissionsResponse struct { Upload bool `json:"upload"` Delete bool `json:"delete"` CanReplace bool `json:"canReplace"` - Write bool `json:"write"` // Deprecated alias for CanReplace. + Write bool `json:"write"` // Deprecated alias for Upload. } func (p SharePermissions) ToResponse() SharePermissionsResponse { @@ -55,7 +55,7 @@ func (p SharePermissions) ToResponse() SharePermissionsResponse { Upload: p.Upload, Delete: p.Delete, CanReplace: p.Delete || p.LegacyReplace, - Write: p.Delete || p.LegacyReplace, + Write: p.Upload, } } diff --git a/backend/internal/model/share_test.go b/backend/internal/model/share_test.go index dda10fb..68566e2 100644 --- a/backend/internal/model/share_test.go +++ b/backend/internal/model/share_test.go @@ -37,24 +37,26 @@ func TestSharePermissionsPreferExplicitUploadOverLegacyWrite(t *testing.T) { } } -func TestSharePermissionsResponseReportsEffectiveReplacement(t *testing.T) { +func TestSharePermissionsResponseReportsLegacyWriteAccess(t *testing.T) { tests := []struct { - name string - permissions SharePermissions - want bool + name string + permissions SharePermissions + wantCanReplace bool + wantLegacyWrite bool }{ - {name: "legacy write", permissions: SharePermissions{Upload: true, LegacyReplace: true}, want: true}, - {name: "upload only", permissions: SharePermissions{Upload: true}, want: false}, - {name: "upload and delete", permissions: SharePermissions{Upload: true, Delete: true}, want: true}, + {name: "view only", permissions: SharePermissions{View: true}, wantCanReplace: false, wantLegacyWrite: false}, + {name: "legacy write", permissions: SharePermissions{Upload: true, LegacyReplace: true}, wantCanReplace: true, wantLegacyWrite: true}, + {name: "upload only", permissions: SharePermissions{Upload: true}, wantCanReplace: false, wantLegacyWrite: true}, + {name: "upload and delete", permissions: SharePermissions{Upload: true, Delete: true}, wantCanReplace: true, wantLegacyWrite: true}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { response := test.permissions.ToResponse() - if response.CanReplace != test.want { - t.Fatalf("canReplace = %t, want %t", response.CanReplace, test.want) + if response.CanReplace != test.wantCanReplace { + t.Fatalf("canReplace = %t, want %t", response.CanReplace, test.wantCanReplace) } - if response.Write != test.want { - t.Fatalf("legacy write alias = %t, want canReplace=%t", response.Write, test.want) + if response.Write != test.wantLegacyWrite { + t.Fatalf("legacy write alias = %t, want upload=%t", response.Write, test.wantLegacyWrite) } data, err := json.Marshal(response) if err != nil { @@ -64,8 +66,8 @@ func TestSharePermissionsResponseReportsEffectiveReplacement(t *testing.T) { if err := json.Unmarshal(data, &fields); err != nil { t.Fatal(err) } - if got, ok := fields["write"].(bool); !ok || got != test.want { - t.Fatalf("serialized write alias = %v, want canReplace=%t", fields["write"], test.want) + if got, ok := fields["write"].(bool); !ok || got != test.wantLegacyWrite { + t.Fatalf("serialized write alias = %v, want upload=%t", fields["write"], test.wantLegacyWrite) } }) } diff --git a/docs/api.md b/docs/api.md index 6f1a76e..01e8542 100644 --- a/docs/api.md +++ b/docs/api.md @@ -280,7 +280,7 @@ Content-Type: application/json } ``` -`expiresInSeconds` is optional; omit it for a share that never expires. Folder links always allow viewing and downloading. Set `upload` to allow new files, and set both `upload` and `delete` to allow replacing or removing items below the shared folder. The optional `maxUploadBytes` value is a per-file cap; omitting it uses the server's `max_upload_mb` limit. The server cap always applies. File links always use view/download permissions. Share responses include the effective `canReplace` capability; it is true when delete is allowed or a persisted legacy `write` permission retains replacement access. For existing v1 clients, responses also include deprecated `write` as an alias for `canReplace`; new clients should use `upload`, `delete`, and `canReplace`. Requests cannot set `canReplace`. +`expiresInSeconds` is optional; omit it for a share that never expires. Folder links always allow viewing and downloading. Set `upload` to allow new files, and set both `upload` and `delete` to allow replacing or removing items below the shared folder. The optional `maxUploadBytes` value is a per-file cap; omitting it uses the server's `max_upload_mb` limit. The server cap always applies. File links always use view/download permissions. Share responses include the effective `canReplace` capability; it is true when delete is allowed or a persisted legacy `write` permission retains replacement access. For existing v1 clients, deprecated `write` mirrors `upload` so upload-only links remain usable; new clients should use `upload`, `delete`, and `canReplace`. Requests cannot set `canReplace`. Response uses `201 Created`: @@ -290,7 +290,7 @@ Response uses `201 Created`: "token": "7nArUufciyjMLLFstZGU_zIerVgTpr2Qr2eL1lUTJFY", "url": "/s/7nArUufciyjMLLFstZGU_zIerVgTpr2Qr2eL1lUTJFY", "fileName": "shared", - "permissions": { "view": true, "download": true, "upload": true, "delete": false, "canReplace": false, "write": false }, + "permissions": { "view": true, "download": true, "upload": true, "delete": false, "canReplace": false, "write": true }, "maxUploadBytes": 104857600, "isFolder": true, "createdAt": "2026-09-02T09:00:00Z", @@ -343,7 +343,7 @@ Returns recipient-facing metadata only; mount names and internal paths are never "fileName": "shared", "size": 1024, "mimeType": "application/octet-stream", - "permissions": { "view": true, "download": true, "upload": true, "delete": false, "canReplace": false, "write": false }, + "permissions": { "view": true, "download": true, "upload": true, "delete": false, "canReplace": false, "write": true }, "maxUploadBytes": 104857600, "isFolder": true, "expiresAt": "2026-09-02T10:00:00Z"