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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions shortcuts/common/download_path.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package common

import (
"mime"
"net/http"
"path"
"path/filepath"
"strings"

larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)

// DownloadExtensionResolution describes how a file extension was inferred.
type DownloadExtensionResolution struct {
Ext string
Source string
Detail string
}

var downloadMimeToExt = map[string]string{
"application/msword": ".doc",
"application/pdf": ".pdf",
"application/vnd.ms-excel": ".xls",
"application/vnd.ms-powerpoint": ".ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/xml": ".xml",
"application/zip": ".zip",
"image/bmp": ".bmp",
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/svg+xml": ".svg",
"image/webp": ".webp",
"text/csv": ".csv",
"text/html": ".html",
"text/plain": ".txt",
"text/xml": ".xml",
"video/mp4": ".mp4",
}

// ResolveDownloadFileName returns a sanitized filename from Content-Disposition,
// falling back to the caller-provided name when the header is absent or invalid.
func ResolveDownloadFileName(header http.Header, fallback string) string {
name := strings.TrimSpace(larkcore.FileNameByHeader(header))
if name == "" {
name = fallback
}
name = strings.ReplaceAll(strings.TrimSpace(name), "\\", "/")
name = path.Base(name)
if name == "" || name == "." || name == ".." {
return fallback

Check warning on line 56 in shortcuts/common/download_path.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/common/download_path.go#L56

Added line #L56 was not covered by tests
}
return name
}

// AutoAppendDownloadExtension appends an inferred file extension when the
// target path has no explicit suffix. If no extension can be inferred, the
// original basename is preserved without adding a synthetic fallback suffix.
func AutoAppendDownloadExtension(outputPath string, header http.Header, fallbackExt string) (string, *DownloadExtensionResolution) {
if hasExplicitDownloadExtension(outputPath) {
return outputPath, nil
}
normalizedPath := outputPath
if filepath.Ext(outputPath) == "." {
normalizedPath = strings.TrimSuffix(outputPath, ".")
}
if resolution := downloadExtensionByContentType(header.Get("Content-Type")); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
if resolution := downloadExtensionByContentDisposition(header); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
if fallbackExt != "" {
return normalizedPath + fallbackExt, &DownloadExtensionResolution{
Ext: fallbackExt,
Source: "fallback",
Detail: "default fallback",

Check warning on line 82 in shortcuts/common/download_path.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/common/download_path.go#L79-L82

Added lines #L79 - L82 were not covered by tests
}
}
return normalizedPath, nil
}

func hasExplicitDownloadExtension(path string) bool {
ext := filepath.Ext(path)
return ext != "" && ext != "."
}

func downloadExtensionByContentType(contentType string) *DownloadExtensionResolution {
if contentType == "" {
return nil

Check warning on line 95 in shortcuts/common/download_path.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/common/download_path.go#L95

Added line #L95 was not covered by tests
}
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = strings.TrimSpace(strings.Split(contentType, ";")[0])

Check warning on line 99 in shortcuts/common/download_path.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/common/download_path.go#L99

Added line #L99 was not covered by tests
}
if ext, ok := downloadMimeToExt[strings.ToLower(mediaType)]; ok {
return &DownloadExtensionResolution{
Ext: ext,
Source: "Content-Type",
Detail: contentType,
}
}
return nil
}

func downloadExtensionByContentDisposition(header http.Header) *DownloadExtensionResolution {
filename := strings.TrimSpace(larkcore.FileNameByHeader(header))
if filename == "" {
return nil
}
ext := filepath.Ext(filename)
if ext == "" || ext == "." {
return nil

Check warning on line 118 in shortcuts/common/download_path.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/common/download_path.go#L118

Added line #L118 was not covered by tests
}
return &DownloadExtensionResolution{
Ext: ext,
Source: "Content-Disposition",
Detail: filename,
}
}
115 changes: 115 additions & 0 deletions shortcuts/common/download_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package common

import (
"net/http"
"testing"
)

func TestResolveDownloadFileName(t *testing.T) {
t.Parallel()

tests := []struct {
name string
header http.Header
fallback string
want string
}{
{
name: "content disposition filename wins",
header: http.Header{
"Content-Disposition": []string{`attachment; filename="report-v7.md"`},
},
fallback: "boxcn123",
want: "report-v7.md",
},
{
name: "path traversal in header is stripped",
header: http.Header{
"Content-Disposition": []string{`attachment; filename="../nested/report-v7.md"`},
},
fallback: "boxcn123",
want: "report-v7.md",
},
{
name: "fallback when header missing",
header: http.Header{},
fallback: "boxcn123",
want: "boxcn123",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := ResolveDownloadFileName(tt.header, tt.fallback); got != tt.want {
t.Fatalf("ResolveDownloadFileName() = %q, want %q", got, tt.want)
}
})
}
}

func TestAutoAppendDownloadExtension(t *testing.T) {
t.Parallel()

tests := []struct {
name string
path string
header http.Header
want string
}{
{
name: "explicit extension is preserved",
path: "artifact.bin",
header: http.Header{
"Content-Type": []string{"text/csv; charset=utf-8"},
},
want: "artifact.bin",
},
{
name: "appends extension from content type",
path: "artifact",
header: http.Header{
"Content-Type": []string{"text/csv; charset=utf-8"},
},
want: "artifact.csv",
},
{
name: "appends extension from content disposition when content type is generic",
path: "artifact",
header: http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Disposition": []string{`attachment; filename="report-v7.md"`},
},
want: "artifact.md",
},
{
name: "trailing dot is normalized before append",
path: "artifact.",
header: http.Header{
"Content-Type": []string{"text/plain; charset=utf-8"},
},
want: "artifact.txt",
},
{
name: "unknown type keeps suffixless path",
path: "artifact.",
header: http.Header{
"Content-Type": []string{"application/octet-stream"},
},
want: "artifact",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, _ := AutoAppendDownloadExtension(tt.path, tt.header, "")
if got != tt.want {
t.Fatalf("AutoAppendDownloadExtension() = %q, want %q", got, tt.want)
}
})
}
}
Loading
Loading