fix(api): handle correctly filename that takes more that 200 bytes

This commit is contained in:
Julien Neuhart
2026-03-27 20:55:50 +01:00
parent 40d75882a2
commit 8f7c1c98ad
16 changed files with 233 additions and 38 deletions

View File

@@ -47,6 +47,7 @@ type Context struct {
values map[string][]string
files map[string]string
filesByField map[string][]string
diskToOriginal map[string]string
outputPaths []string
cancelled bool
@@ -200,6 +201,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
ctx.values = form.Value
ctx.files = make(map[string]string)
ctx.filesByField = make(map[string][]string)
ctx.diskToOriginal = make(map[string]string)
// First, try to download files listed in the "downloadFrom" form field, if
// any.
@@ -348,7 +350,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
// normalized.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename = norm.NFC.String(filepath.Base(filename))
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
// Use a UUID-based name on disk to avoid filesystem
// NAME_MAX limits with long filenames.
// See: https://github.com/gotenberg/gotenberg/issues/1500.
safeName := uuid.New().String() + filepath.Ext(filename)
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
out, err := os.Create(path)
if err != nil {
@@ -381,6 +388,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
dlSpan.End()
ctx.files[filename] = path
ctx.diskToOriginal[path] = filename
// Route the downloaded file to the appropriate field bucket.
switch {
@@ -422,7 +430,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
// normalized.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename := norm.NFC.String(filepath.Base(fh.Filename))
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
// Use a UUID-based name on disk to avoid filesystem
// NAME_MAX limits with long filenames.
// See: https://github.com/gotenberg/gotenberg/issues/1500.
safeName := uuid.New().String() + filepath.Ext(filename)
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
out, err := os.Create(path)
if err != nil {
@@ -441,6 +454,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
}
ctx.files[filename] = path
ctx.diskToOriginal[path] = filename
return nil
}
@@ -478,10 +492,26 @@ func (ctx *Context) FormData() *FormData {
values: ctx.values,
files: ctx.files,
filesByField: ctx.filesByField,
diskToOriginal: ctx.diskToOriginal,
errors: nil,
}
}
// OriginalFilename returns the original filename associated with a disk path.
// If no mapping exists, it falls back to [filepath.Base].
func (ctx *Context) OriginalFilename(diskPath string) string {
if original, ok := ctx.diskToOriginal[diskPath]; ok {
return original
}
return filepath.Base(diskPath)
}
// RegisterDiskPath associates a disk path with an original filename so that
// [Context.OriginalFilename] can resolve it later.
func (ctx *Context) RegisterDiskPath(diskPath, originalFilename string) {
ctx.diskToOriginal[diskPath] = originalFilename
}
// DirPath returns the path to the request's working directory.
func (ctx *Context) DirPath() string {
return ctx.dirPath
@@ -494,10 +524,14 @@ func (ctx *Context) GeneratePath(extension string) string {
}
// GeneratePathFromFilename generates a path within the context's working
// directory, using the given filename (with extension). It does not create
// a file.
// directory. It uses a UUID-based name on disk to avoid filesystem NAME_MAX
// limits but registers the given filename so that [Context.OriginalFilename]
// can resolve it. It does not create a file.
func (ctx *Context) GeneratePathFromFilename(filename string) string {
return fmt.Sprintf("%s/%s", ctx.dirPath, filename)
safeName := uuid.New().String() + filepath.Ext(filename)
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
ctx.diskToOriginal[path] = filename
return path
}
// CreateSubDirectory creates a subdirectory within the context's working
@@ -564,7 +598,7 @@ func (ctx *Context) BuildOutputFile() (string, error) {
filesInfo, err := archives.FilesFromDisk(ctx.Context, nil, func() map[string]string {
f := make(map[string]string)
for _, outputPath := range ctx.outputPaths {
f[outputPath] = ""
f[outputPath] = ctx.OriginalFilename(outputPath)
}
return f
}())
@@ -600,7 +634,7 @@ func (ctx *Context) OutputFilename(outputPath string) string {
filename := ctx.echoCtx.Get("outputFilename").(string)
if filename == "" {
return filepath.Base(outputPath)
return ctx.OriginalFilename(outputPath)
}
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))

View File

@@ -36,6 +36,7 @@ type FormData struct {
values map[string][]string
files map[string]string
filesByField map[string][]string
diskToOriginal map[string]string
errors error
}
@@ -450,6 +451,15 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
watermarks, wmOk := form.filesByField[WatermarkFormField]
stamps, stOk := form.filesByField[StampFormField]
// Collect (originalFilename, diskPath) pairs so that we can sort by
// original filename rather than by UUID-based disk name.
// See https://github.com/gotenberg/gotenberg/issues/1500.
type entry struct {
original string
disk string
}
var entries []entry
for filename, path := range form.files {
if ok && slices.Contains(embeds, path) {
continue
@@ -466,13 +476,26 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
for _, ext := range extensions {
// See https://github.com/gotenberg/gotenberg/issues/228.
if strings.ToLower(filepath.Ext(filename)) == ext {
*target = append(*target, path)
entries = append(entries, entry{original: filename, disk: path})
}
}
}
// See https://github.com/gotenberg/gotenberg/issues/139.
sort.Sort(gotenberg.AlphanumericSort(*target))
originals := make(gotenberg.AlphanumericSort, len(entries))
for i, e := range entries {
originals[i] = e.original
}
sort.Sort(originals)
// Build a lookup from original name to disk path.
lookup := make(map[string]string, len(entries))
for _, e := range entries {
lookup[e.original] = e.disk
}
for _, o := range originals {
*target = append(*target, lookup[o])
}
return form
}

View File

@@ -667,7 +667,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
var path string
for _, markdownPath := range markdownPaths {
markdownFilename := filepath.Base(markdownPath)
markdownFilename := ctx.OriginalFilename(markdownPath)
if filename == markdownFilename {
path = markdownPath

View File

@@ -420,7 +420,8 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
// document.docx -> document.docx.pdf, so that split naming
// document.docx_0.pdf, etc.
for i, inputPath := range inputPaths {
outputPath := fmt.Sprintf("%s.pdf", inputPath)
originalName := ctx.OriginalFilename(inputPath)
outputPath := ctx.GeneratePathFromFilename(originalName + ".pdf")
err = ctx.Rename(outputPaths[i], outputPath)
if err != nil {
@@ -502,7 +503,8 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
if len(outputPaths) > 1 && splitMode == zeroValuedSplitMode {
// If .zip archive, document.docx -> document.docx.pdf.
for i, inputPath := range inputPaths {
outputPath := fmt.Sprintf("%s.pdf", inputPath)
originalName := ctx.OriginalFilename(inputPath)
outputPath := ctx.GeneratePathFromFilename(originalName + ".pdf")
err = ctx.Rename(outputPaths[i], outputPath)
if err != nil {

View File

@@ -5,10 +5,12 @@ import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -282,9 +284,9 @@ func SplitPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, mode gotenberg.S
var outputPaths []string
for _, inputPath := range inputPaths {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
outputDirPath, err := ctx.CreateSubDirectory(strings.ReplaceAll(filepath.Base(filenameNoExt), ".", "_"))
originalName := ctx.OriginalFilename(inputPath)
originalNameNoExt := strings.TrimSuffix(originalName, filepath.Ext(originalName))
outputDirPath, err := ctx.CreateSubDirectory(uuid.New().String())
if err != nil {
return nil, fmt.Errorf("create subdirectory from input path: %w", err)
}
@@ -297,15 +299,18 @@ func SplitPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, mode gotenberg.S
// Keep the original filename.
for i, path := range paths {
var newPath string
var newOriginal string
if mode.Unify && mode.Mode == gotenberg.SplitModePages {
newOriginal = fmt.Sprintf("%s.pdf", originalNameNoExt)
newPath = fmt.Sprintf(
"%s/%s.pdf",
outputDirPath, filenameNoExt,
"%s/%s",
outputDirPath, uuid.New().String()+".pdf",
)
} else {
newOriginal = fmt.Sprintf("%s_%d.pdf", originalNameNoExt, i)
newPath = fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, i,
"%s/%s",
outputDirPath, uuid.New().String()+".pdf",
)
}
@@ -314,6 +319,7 @@ func SplitPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, mode gotenberg.S
return nil, fmt.Errorf("rename path: %w", err)
}
ctx.RegisterDiskPath(newPath, newOriginal)
outputPaths = append(outputPaths, newPath)
if mode.Unify && mode.Mode == gotenberg.SplitModePages {
@@ -413,7 +419,7 @@ func WriteBookmarksStub(ctx *api.Context, engine gotenberg.PdfEngine, bookmarks
}
case map[string][]gotenberg.Bookmark:
for _, inputPath := range inputPaths {
filename := filepath.Base(inputPath)
filename := ctx.OriginalFilename(inputPath)
if specificBookmarks, ok := b[filename]; ok {
err := engine.WriteBookmarks(ctx, ctx.Log(), inputPath, specificBookmarks)
if err != nil {
@@ -466,8 +472,28 @@ func EmbedFilesStub(ctx *api.Context, engine gotenberg.PdfEngine, embedPaths []s
return nil
}
// Engines like pdfcpu use filepath.Base(path) as the attachment name
// inside the PDF. Since disk filenames are now UUID-based, we create
// symlinks with the original names so that embeds are named correctly.
// See: https://github.com/gotenberg/gotenberg/issues/1500.
embedDir, err := ctx.CreateSubDirectory(uuid.New().String())
if err != nil {
return fmt.Errorf("create embed subdirectory: %w", err)
}
resolvedPaths := make([]string, len(embedPaths))
for i, embedPath := range embedPaths {
originalName := ctx.OriginalFilename(embedPath)
resolvedPath := fmt.Sprintf("%s/%s", embedDir, originalName)
err := os.Symlink(embedPath, resolvedPath)
if err != nil {
return fmt.Errorf("symlink embed file '%s': %w", originalName, err)
}
resolvedPaths[i] = resolvedPath
}
for _, inputPath := range inputPaths {
err := engine.EmbedFiles(ctx, ctx.Log(), embedPaths, inputPath)
err := engine.EmbedFiles(ctx, ctx.Log(), resolvedPaths, inputPath)
if err != nil {
return fmt.Errorf("embed files into PDF '%s': %w", inputPath, err)
}
@@ -683,7 +709,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
if bMap != nil || autoIndexBookmarks {
offset := 0
for _, inputPath := range inputPaths {
filename := filepath.Base(inputPath)
filename := ctx.OriginalFilename(inputPath)
var fileBookmarks []gotenberg.Bookmark
if bMap != nil {
@@ -973,7 +999,7 @@ func readMetadataRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("read metadata: %w", err)
}
res[filepath.Base(inputPath)] = metadata
res[ctx.OriginalFilename(inputPath)] = metadata
}
err = c.JSON(http.StatusOK, res)
@@ -1051,7 +1077,7 @@ func readBookmarksRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("read bookmarks: %w", err)
}
res[filepath.Base(inputPath)] = bookmarks
res[ctx.OriginalFilename(inputPath)] = bookmarks
}
err = c.JSON(http.StatusOK, res)

View File

@@ -1152,3 +1152,14 @@ Feature: /forms/chromium/convert/html
| files | testdata/page-1-html/index.html | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
# See: https://github.com/gotenberg/gotenberg/issues/1500.
Scenario: POST /forms/chromium/convert/html (Long Filename)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "foo.pdf" PDF should have 1 page(s)

View File

@@ -1128,3 +1128,15 @@ Feature: /forms/chromium/convert/markdown
| files | testdata/page-1-markdown/page_1.md | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
# See: https://github.com/gotenberg/gotenberg/issues/1500.
Scenario: POST /forms/chromium/convert/markdown (Long Filename)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "foo.pdf" PDF should have 1 page(s)

View File

@@ -781,3 +781,14 @@ Feature: /forms/libreoffice/convert
| files | testdata/page_1.docx | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
# See: https://github.com/gotenberg/gotenberg/issues/1500.
Scenario: POST /forms/libreoffice/convert (Long Filename)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/Longitudinell_jämförelse_mellan_laserkirurgi_och_strålbehandling_gällande_röstkvalitet_och_självskattad_kommunikation_upp_till_två_år_efter_tidig_stämbandscancer_i_ett_randomiserat_kontrollerat_försök.docx | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "foo.pdf" PDF should have 1 page(s)

View File

@@ -180,3 +180,15 @@ Feature: /forms/pdfengines/encrypt
| userPassword | foo | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
# See: https://github.com/gotenberg/gotenberg/issues/1500.
Scenario: POST /forms/pdfengines/encrypt (Long Filename)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
| files | testdata/Longitudinell_jämförelse_mellan_laserkirurgi_och_strålbehandling_gällande_röstkvalitet_och_självskattad_kommunikation_upp_till_två_år_efter_tidig_stämbandscancer_i_ett_randomiserat_kontrollerat_försök.pdf | file |
| userPassword | foo | field |
| ownerPassword | bar | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response

View File

@@ -118,3 +118,14 @@ Feature: /forms/pdfengines/flatten
| files | testdata/page_1.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
# See: https://github.com/gotenberg/gotenberg/issues/1500.
Scenario: POST /forms/pdfengines/flatten (Long Filename)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
| files | testdata/Longitudinell_jämförelse_mellan_laserkirurgi_och_strålbehandling_gällande_röstkvalitet_och_självskattad_kommunikation_upp_till_två_år_efter_tidig_stämbandscancer_i_ett_randomiserat_kontrollerat_försök.pdf | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should be flatten

View File

@@ -664,3 +664,14 @@ Feature: /forms/pdfengines/merge
| pdfa | PDF/A-3b | field |
| embeds | testdata/embed_1.xml | file |
Then the response status code should be 200
# See: https://github.com/gotenberg/gotenberg/issues/1500.
Scenario: POST /forms/pdfengines/merge (Long Filename)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/Longitudinell_jämförelse_mellan_laserkirurgi_och_strålbehandling_gällande_röstkvalitet_och_självskattad_kommunikation_upp_till_två_år_efter_tidig_stämbandscancer_i_ett_randomiserat_kontrollerat_försök.pdf | file |
| files | testdata/page_2.pdf | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response

View File

@@ -293,3 +293,21 @@ Feature: /forms/pdfengines/metadata/{write|read}
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
# See: https://github.com/gotenberg/gotenberg/issues/1500.
Scenario: POST /forms/pdfengines/metadata/read (Long Filename)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):
| files | testdata/Longitudinell_jämförelse_mellan_laserkirurgi_och_strålbehandling_gällande_röstkvalitet_och_självskattad_kommunikation_upp_till_två_år_efter_tidig_stämbandscancer_i_ett_randomiserat_kontrollerat_försök.pdf | file |
Then the response status code should be 200
# See: https://github.com/gotenberg/gotenberg/issues/1500.
Scenario: POST /forms/pdfengines/metadata/write (Long Filename)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
| files | testdata/Longitudinell_jämförelse_mellan_laserkirurgi_och_strålbehandling_gällande_röstkvalitet_och_självskattad_kommunikation_upp_till_två_år_efter_tidig_stämbandscancer_i_ett_randomiserat_kontrollerat_försök.pdf | file |
| metadata | {"Author":"Test"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response

View File

@@ -162,3 +162,14 @@ Feature: /forms/pdfengines/rotate
| rotateAngle | 90 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
# See: https://github.com/gotenberg/gotenberg/issues/1500.
Scenario: POST /forms/pdfengines/rotate (Long Filename)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/Longitudinell_jämförelse_mellan_laserkirurgi_och_strålbehandling_gällande_röstkvalitet_och_självskattad_kommunikation_upp_till_två_år_efter_tidig_stämbandscancer_i_ett_randomiserat_kontrollerat_försök.pdf | file |
| rotateAngle | 90 | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response

View File

@@ -764,3 +764,16 @@ Feature: /forms/pdfengines/split
| pdfa | PDF/A-3b | field |
| embeds | testdata/embed_1.xml | file |
Then the response status code should be 200
# See: https://github.com/gotenberg/gotenberg/issues/1500.
Scenario: POST /forms/pdfengines/split (Long Filename)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
| files | testdata/Longitudinell_jämförelse_mellan_laserkirurgi_och_strålbehandling_gällande_röstkvalitet_och_självskattad_kommunikation_upp_till_två_år_efter_tidig_stämbandscancer_i_ett_randomiserat_kontrollerat_försök.pdf | file |
| splitMode | pages | field |
| splitSpan | 1 | field |
| splitUnify | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response